Compare commits

...

36 commits

Author SHA1 Message Date
Melissa LeBlanc-Williams
e6b8dd872a Remove push force because it works as expected now 2023-03-14 15:23:16 -07:00
Melissa LeBlanc-Williams
20f36f2a97 Merge branch 'main' of https://github.com/makermelissa/web-firmware-installer-js 2023-03-14 15:19:49 -07:00
Melissa LeBlanc-Williams
9afe11ab72 Try adding push option force 2023-03-14 15:19:42 -07:00
makermelissa
b66f607ab3 Github Action: Updated dist files 2023-03-14 22:02:09 +00:00
Melissa LeBlanc-Williams
69557e8019 Always update dist 2023-03-14 15:01:26 -07:00
Melissa LeBlanc-Williams
7a8abf4ce4 Only make dist on release 2023-03-14 14:47:49 -07:00
makermelissa
d0f52f6d6e Github Action: Updated dist files 2023-03-14 21:45:18 +00:00
Melissa LeBlanc-Williams
ae1f91ab84 Mystery solved about dist 2023-03-14 14:44:43 -07:00
makermelissa
3fd0f368ac Github Action: JS Build files 2023-03-14 21:42:15 +00:00
Melissa LeBlanc-Williams
9d67588f46 Go back to build because dist fails for mysterious reasons 2023-03-14 14:41:28 -07:00
Melissa LeBlanc-Williams
4878d5e9aa Comment out conditional 2023-03-14 14:39:01 -07:00
Melissa LeBlanc-Williams
8a3a818f4c make exist folder to avoid loop 2023-03-14 14:37:31 -07:00
Melissa LeBlanc-Williams
2d5d03a163 Make a dist folder 2023-03-14 14:34:03 -07:00
Melissa LeBlanc-Williams
c247464b43 Fix rm breaking on no files 2023-03-14 14:26:20 -07:00
Melissa LeBlanc-Williams
1f28f73c83 Change to dist and only commit on release 2023-03-14 14:24:26 -07:00
Melissa LeBlanc-Williams
4a4441761c Fix copy issue by removing existing files 2023-03-14 14:06:19 -07:00
makermelissa
fc05d34160 Github Action: JS Build files 2023-03-14 21:00:51 +00:00
Melissa LeBlanc-Williams
81578a7334 Merge branch 'main' of https://github.com/makermelissa/web-firmware-installer-js 2023-03-14 13:59:54 -07:00
Melissa LeBlanc-Williams
56d67f3752 Try another strategy 2023-03-14 13:59:41 -07:00
makermelissa
7cf47e81c8 Github Action: JS Build files 2023-03-14 20:48:09 +00:00
Melissa LeBlanc-Williams
5763360795 Fix indentation 2023-03-14 13:47:31 -07:00
Melissa LeBlanc-Williams
889e220b60 Merge branch 'main' of https://github.com/makermelissa/web-firmware-installer-js 2023-03-14 13:46:38 -07:00
Melissa LeBlanc-Williams
0b980ae5c0 Do some command line magic to create .min.js files 2023-03-14 13:46:15 -07:00
makermelissa
df343cda35 Github Action: JS Build files 2023-03-14 19:42:02 +00:00
Melissa LeBlanc-Williams
62c5f45971 Try the first minifier again, but with uglify 2023-03-14 12:41:28 -07:00
makermelissa
9156930bb1 Github Action: JS Build files 2023-03-14 18:59:09 +00:00
Melissa LeBlanc-Williams
9a4cbe8dbb Move working dir to with 2023-03-14 11:58:23 -07:00
Melissa LeBlanc-Williams
0925cb1bb2 Try swapping actions line order 2023-03-14 11:56:58 -07:00
Melissa LeBlanc-Williams
e806b3bf75 Run minify inside build folder 2023-03-14 11:54:02 -07:00
makermelissa
e0b6d68fb5 Github Action: JS Build files 2023-03-14 18:48:48 +00:00
Melissa LeBlanc-Williams
e023a5a4a3 Try a different minify action package 2023-03-14 11:47:48 -07:00
makermelissa
d4e6eb0249 Github Action: JS Build files 2023-03-14 18:32:13 +00:00
Melissa LeBlanc-Williams
1f24cacff4 Change to using .mjs extension 2023-03-14 11:31:32 -07:00
Melissa LeBlanc-Williams
8a15d64026 Update cp command 2023-03-14 11:24:58 -07:00
Melissa LeBlanc-Williams
1280dc3d38 Create GitHub Actions to build Installer 2023-03-14 11:21:28 -07:00
Melissa LeBlanc-Williams
7689fec0e9 Updated to use esp32_boards.json 2023-03-14 10:36:34 -07:00
12 changed files with 2061 additions and 9 deletions

34
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Build Distribution Files
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Create dist Folder
run: "mkdir -p dist"
- name: Remove existing dist files
run: "rm -f dist/*"
- name: Copy files to dist folder
run: "cp *.{js,py,json} dist/"
- name: Minify JavaScript
uses: nizarmah/auto-minify@v2.1
with:
directory: 'dist'
js_engine: 'uglify-js'
- name: Commit Distribution Files
uses: stefanzweifel/git-auto-commit-action@v4
with:
repository: 'dist'
commit_message: "Github Action: Updated dist files"
branch: ${{ github.ref }}

1
.gitignore vendored
View file

@ -84,7 +84,6 @@ out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/

View file

@ -23,6 +23,8 @@ import { InstallButton, ESP_ROM_BAUD } from "./base_installer.js";
const PREFERRED_BAUDRATE = 921600;
const COPY_CHUNK_SIZE = 64 * 1024; // 64 KB Chunks
const DEFAULT_RELEASE_LATEST = false; // Use the latest release or the stable release if not specified
const BOARD_DEFS = "https://adafruit-circuit-python.s3.amazonaws.com/esp32_boards.json";
const CSS_DIALOG_CLASS = "cp-installer-dialog";
const FAMILY_TO_CHIP_MAP = {
@ -49,7 +51,6 @@ export class CPInstallButton extends InstallButton {
this.binFileUrl = null;
this.releaseVersion = 0;
this.chipFamily = null;
this.bootloadId = null;
this.dialogCssClass = CSS_DIALOG_CLASS;
this.dialogs = { ...this.dialogs, ...this.cpDialogs };
this.bootDriveHandle = null;
@ -69,17 +70,102 @@ export class CPInstallButton extends InstallButton {
return Object.keys(attrMap);
}
connectedCallback() {
this.boardName = this.getAttribute("boardname") || "ESP32-based device";
this.menuTitle = `CircuitPython Installer for ${this.boardName}`;
parseVersion(version) {
const versionRegex = /(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?/;
const versionInfo = {};
let matches = version.match(versionRegex);
if (matches && matches.length >= 4) {
versionInfo.major = matches[1];
versionInfo.minor = matches[2];
versionInfo.patch = matches[3];
if (matches[4] && matches[5]) {
versionInfo.suffix = matches[4];
versionInfo.suffixVersion = matches[5];
} else {
versionInfo.suffix = "stable";
versionInfo.suffixVersion = 0;
}
}
return versionInfo;
}
// If this is empty, it's a problem
sortReleases(releases) {
// Return a sorted list of releases by parsed version number
const sortHieratchy = ["major", "minor", "patch", "suffix", "suffixVersion"];
releases.sort((a, b) => {
const aVersionInfo = this.parseVersion(a.version);
const bVersionInfo = this.parseVersion(b.version);
for (let sortKey of sortHieratchy) {
if (aVersionInfo[sortKey] < bVersionInfo[sortKey]) {
return -1;
} else if (aVersionInfo[sortKey] > bVersionInfo[sortKey]) {
return 1;
}
}
return 0;
});
return releases;
}
async connectedCallback() {
// Required
this.boardId = this.getAttribute("boardid");
this.releaseVersion = this.getAttribute("version");
// If not provided, it will use the stable release if DEFAULT_RELEASE_LATEST is false
if (this.getAttribute("version")) {
this.releaseVersion = this.getAttribute("version");
}
// Pull in the info from the json as the default values. These can be overwritten by the attributes.
const response = await fetch(BOARD_DEFS);
const boardDefs = await response.json();
let releaseInfo = null;
if (Object.keys(boardDefs).includes(this.boardId)) {
const boardDef = boardDefs[this.boardId];
this.chipFamily = boardDef.chipfamily;
if (boardDef.name) {
this.boardName = boardDef.name;
}
if (boardDef.bootloader) {
this.bootloaderUrl = this.updateBinaryUrl(boardDef.bootloader);
}
const sortedReleases = this.sortReleases(boardDef.releases);
if (this.releaseVersion) { // User specified a release
for (let release of sortedReleases) {
if (release.version == this.releaseVersion) {
releaseInfo = release;
break;
}
}
}
if (!releaseInfo) { // Release version not found or not specified
if (DEFAULT_RELEASE_LATEST) {
releaseInfo = sortedReleases[sortedReleases.length - 1];
} else {
releaseInfo = sortedReleases[0];
}
this.releaseVersion = releaseInfo.version;
}
if (releaseInfo.uf2file) {
this.uf2FileUrl = this.updateBinaryUrl(releaseInfo.uf2file);
}
if (releaseInfo.binfile) {
this.binFileUrl = this.updateBinaryUrl(releaseInfo.binfile);
}
}
// Nice to have for now
this.chipFamily = this.getAttribute("chipfamily");
this.bootloaderId = this.getAttribute("bootloaderid"); // This could be used to check serial output from board matches the UF2 file
if (this.getAttribute("chipfamily")) {
this.chipFamily = this.getAttribute("chipfamily");
}
if (this.getAttribute("boardname")) {
this.boardName = this.getAttribute("boardname");
}
this.menuTitle = `CircuitPython Installer for ${this.boardName}`;
super.connectedCallback();
}

465
dist/base_installer.js vendored Normal file
View file

@ -0,0 +1,465 @@
// SPDX-FileCopyrightText: 2023 Melissa LeBlanc-Williams for Adafruit Industries
//
// SPDX-License-Identifier: MIT
'use strict';
import {html, render} from 'https://unpkg.com/lit-html?module';
import {asyncAppend} from 'https://unpkg.com/lit-html/directives/async-append?module';
import * as esptoolPackage from "https://unpkg.com/esp-web-flasher@5.1.2/dist/web/index.js?module"
// TODO: Figure out how to make the Web Serial from ESPTool and Web Serial to communicate with CircuitPython not conflict
// I think at the very least we'll have to reuse the same port so the user doesn't need to reselct, though it's possible it
// may change after reset. Since it's not
//
// For now, we'll use the following procedure for ESP32-S2 and ESP32-S3:
// 1. Install the bin file
// 2. Reset the board
// (if version 8.0.0-beta.6 or later)
// 3. Generate the settings.toml file
// 4. Write the settings.toml to the board via the REPL
// 5. Reset the board again
//
// For the esp32 and esp32c3, the procedure may be slightly different and going through the
// REPL may be required for the settings.toml file.
// 1. Install the bin file
// 2. Reset the board
// (if version 8.0.0-beta.6 or later)
// 3. Generate the settings.toml file
// 4. Write the settings.toml to the board via the REPL
// 5. Reset the board again
//
// To run REPL code, I may need to modularize the work I did for code.circuitpython.org
// That allows you to run code in the REPL and get the output back. I may end up creating a
// library that uses Web Serial and allows you to run code in the REPL and get the output back
// because it's very integrated into the serial recieve and send code.
//
export const ESP_ROM_BAUD = 115200;
export class InstallButton extends HTMLButtonElement {
static isSupported = 'serial' in navigator;
static isAllowed = window.isSecureContext;
constructor() {
super();
this.dialogElements = {};
this.currentFlow = null;
this.currentStep = 0;
this.currentDialogElement = null;
this.port = null;
this.espStub = null;
this.dialogCssClass = "install-dialog";
this.connected = this.connectionStates.DISCONNECTED;
this.menuTitle = "Installer Menu";
}
init() {
this.preloadDialogs();
}
// Define some common buttons
/* Buttons should have a label, and a callback and optionally a condition function on whether they should be enabled */
previousButton = {
label: "Previous",
onClick: this.prevStep,
isEnabled: async () => { return this.currentStep > 0 },
}
nextButton = {
label: "Next",
onClick: this.nextStep,
isEnabled: async () => { return this.currentStep < this.currentFlow.steps.length - 1; },
}
closeButton = {
label: "Close",
onClick: async (e) => {
this.closeDialog();
},
}
// Default Buttons
defaultButtons = [this.previousButton, this.nextButton];
// States and Button Labels
connectionStates = {
DISCONNECTED: "Connect",
CONNECTING: "Connecting...",
CONNECTED: "Disconnect",
}
dialogs = {
notSupported: {
preload: false,
closeable: true,
template: (data) => html`
Sorry, <b>Web Serial</b> is not supported on your browser at this time. Browsers we expect to work:
<ul>
<li>Google Chrome 89 (and higher)</li>
<li>Microsoft Edge 89 (and higher)</li>
<li>Opera 75 (and higher)</li>
</ul>
`,
buttons: [this.closeButton],
},
menu: {
closeable: true,
template: (data) => html`
<p>${this.menuTitle}</p>
<ul class="flow-menu">
${asyncAppend(this.generateMenu(
(flowId, flow) => html`<li><a href="#" @click=${this.runFlow.bind(this)} id="${flowId}">${flow.label.replace('[version]', this.releaseVersion)}</a></li>`
))}
</ul>`,
buttons: [this.closeButton],
},
};
flows = {};
baudRates = [
115200,
128000,
153600,
230400,
460800,
921600,
1500000,
2000000,
];
connectedCallback() {
if (InstallButton.isSupported && InstallButton.isAllowed) {
this.toggleAttribute("install-supported", true);
} else {
this.toggleAttribute("install-unsupported", true);
}
this.addEventListener("click", async (e) => {
e.preventDefault();
// WebSerial feature detection
if (!InstallButton.isSupported) {
await this.showNotSupported();
} else {
await this.showMenu();
}
});
}
// Parse out the url parameters from the current url
getUrlParams() {
// This should look for and validate very specific values
var hashParams = {};
if (location.hash) {
location.hash.substr(1).split("&").forEach(function(item) {hashParams[item.split("=")[0]] = item.split("=")[1];});
}
return hashParams;
}
// Get a url parameter by name and optionally remove it from the current url in the process
getUrlParam(name) {
let urlParams = this.getUrlParams();
let paramValue = null;
if (name in urlParams) {
paramValue = urlParams[name];
}
return paramValue;
}
async enabledFlowCount() {
let enabledFlowCount = 0;
for (const [flowId, flow] of Object.entries(this.flows)) {
if (await flow.isEnabled()) {
enabledFlowCount++;
}
}
return enabledFlowCount;
}
async * generateMenu(templateFunc) {
if (await this.enabledFlowCount() == 0) {
yield html`<li>No installable options available for this board.</li>`;
}
for (const [flowId, flow] of Object.entries(this.flows)) {
if (await flow.isEnabled()) {
yield templateFunc(flowId, flow);
}
}
}
preloadDialogs() {
for (const [id, dialog] of Object.entries(this.dialogs)) {
if ('preload' in dialog && !dialog.preload) {
continue;
}
this.dialogElements[id] = this.getDialogElement(dialog);
}
}
createIdFromLabel(text) {
return text.replace(/^[^a-z]+|[^\w:.-]+/gi, "");
}
createDialogElement(id, dialogData) {
// Check if an existing dialog with the same id exists and remove it if so
let existingDialog = this.querySelector(`#cp-installer-${id}`);
if (existingDialog) {
this.remove(existingDialog);
}
// Create a dialog element
let dialogElement = document.createElement("dialog");
dialogElement.id = id;
dialogElement.classList.add(this.dialogCssClass);
// Add a close button
let closeButton = document.createElement("button");
closeButton.href = "#";
closeButton.classList.add("close-button");
closeButton.addEventListener("click", (e) => {
e.preventDefault();
dialogElement.close();
});
dialogElement.appendChild(closeButton);
// Add a body element
let body = document.createElement("div");
body.classList.add("dialog-body");
dialogElement.appendChild(body);
let buttons = this.defaultButtons;
if (dialogData && dialogData.buttons) {
buttons = dialogData.buttons;
}
dialogElement.appendChild(
this.createNavigation(buttons)
);
// Return the dialog element
document.body.appendChild(dialogElement);
return dialogElement;
}
createNavigation(buttonData) {
// Add buttons according to config data
const navigation = document.createElement("div");
navigation.classList.add("dialog-navigation");
for (const button of buttonData) {
let buttonElement = document.createElement("button");
buttonElement.innerText = button.label;
buttonElement.id = this.createIdFromLabel(button.label);
buttonElement.addEventListener("click", async (e) => {
e.preventDefault();
await button.onClick.bind(this)();
});
buttonElement.addEventListener("update", async (e) => {
if ("onUpdate" in button) {
await button.onUpdate.bind(this)(e);
}
if ("isEnabled" in button) {
e.target.disabled = !(await button.isEnabled.bind(this)());
}
});
navigation.appendChild(buttonElement);
}
return navigation;
}
getDialogElement(dialog, forceReload = false) {
function getKeyByValue(object, value) {
return Object.keys(object).find(key => object[key] === value);
}
const dialogId = getKeyByValue(this.dialogs, dialog);
if (dialogId) {
if (dialogId in this.dialogElements && !forceReload) {
return this.dialogElements[dialogId];
} else {
return this.createDialogElement(dialogId, dialog);
}
}
return null;
}
updateButtons() {
// Call each button's custom update event for the current dialog
if (this.currentDialogElement) {
const navButtons = this.currentDialogElement.querySelectorAll(".dialog-navigation button");
for (const button of navButtons) {
button.dispatchEvent(new Event("update"));
}
}
}
showDialog(dialog, templateData = {}) {
if (this.currentDialogElement) {
this.closeDialog();
}
this.currentDialogElement = this.getDialogElement(dialog);
if (!this.currentDialogElement) {
console.error(`Dialog not found`);
}
if (this.currentDialogElement) {
const dialogBody = this.currentDialogElement.querySelector(".dialog-body");
if ('template' in dialog) {
render(dialog.template(templateData), dialogBody);
}
// Close button should probably hide during certain steps such as flashing and erasing
if ("closeable" in dialog && dialog.closeable) {
this.currentDialogElement.querySelector(".close-button").style.display = "block";
} else {
this.currentDialogElement.querySelector(".close-button").style.display = "none";
}
let dialogButtons = this.defaultButtons;
if ('buttons' in dialog) {
dialogButtons = dialog.buttons;
}
this.updateButtons();
this.currentDialogElement.showModal();
}
}
closeDialog() {
this.currentDialogElement.close();
this.currentDialogElement = null;
}
errorMsg(text) {
text = this.stripHtml(text);
console.error(text);
this.showError(text);
}
logMsg(text, showTrace = false) {
// TODO: Eventually add to an internal log that the user can bring up
console.info(this.stripHtml(text));
if (showTrace) {
console.trace();
}
}
updateEspConnected(connected) {
if (Object.values(this.connectionStates).includes(connected)) {
this.connected = connected;
this.updateButtons();
}
}
stripHtml(html) {
let tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
}
formatMacAddr(macAddr) {
return macAddr.map((value) => value.toString(16).toUpperCase().padStart(2, "0")).join(":");
}
async disconnect() {
if (this.espStub) {
await espStub.disconnect();
await espStub.port.close();
this.updateUIConnected(this.connectionStates.DISCONNECTED);
this.espStub = null;
}
}
async runFlow(flow) {
if (flow instanceof Event) {
flow.preventDefault();
flow.stopImmediatePropagation();
if (flow.target.id in this.flows) {
flow = this.flows[flow.target.id];
} else {
return;
}
}
this.currentFlow = flow;
this.currentStep = 0;
await this.currentFlow.steps[this.currentStep].bind(this)();
}
async nextStep() {
if (!this.currentFlow) {
return;
}
if (this.currentStep < this.currentFlow.steps.length) {
this.currentStep++;
await this.currentFlow.steps[this.currentStep].bind(this)();
}
}
async prevStep() {
if (!this.currentFlow) {
return;
}
if (this.currentStep > 0) {
this.currentStep--;
await this.currentFlow.steps[this.currentStep].bind(this)();
}
}
async showMenu() {
// Display Menu
this.showDialog(this.dialogs.menu);
}
async showNotSupported() {
// Display Not Supported Message
this.showDialog(this.dialogs.notSupported);
}
async showError(message) {
// Display Menu
this.showDialog(this.dialogs.error, {message: message});
}
async setBaudRateIfChipSupports(chipType, baud) {
if (baud == ESP_ROM_BAUD) { return } // already the default
if (chipType == esptoolPackage.CHIP_FAMILY_ESP32) { // only supports the default
this.logMsg(`ESP32 Chip only works at 115200 instead of the preferred ${baud}. Staying at 115200...`);
return
}
await this.changeBaudRate(baud);
}
async changeBaudRate(baud) {
if (this.espStub && this.baudRates.includes(baud)) {
await this.espStub.setBaudrate(baud);
}
}
async espHardReset(bootloader = false) {
if (this.espStub) {
await this.espStub.hardReset(bootloader);
}
}
async espConnect(logger) {
// - Request a port and open a connection.
this.port = await navigator.serial.requestPort();
logger.log("Connecting...");
await this.port.open({ baudRate: ESP_ROM_BAUD });
logger.log("Connected successfully.");
return new esptoolPackage.ESPLoader(this.port, logger);
};
}

12
dist/base_installer.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
dist/boot.py vendored Normal file
View file

@ -0,0 +1,3 @@
import storage
storage.disable_usb_drive()

1263
dist/cpinstaller.js vendored Normal file

File diff suppressed because it is too large Load diff

96
dist/cpinstaller.min.js vendored Normal file

File diff suppressed because one or more lines are too long

14
dist/package-lock.json generated vendored Normal file
View file

@ -0,0 +1,14 @@
{
"name": "@adafruit/web-firmware-installer-js",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@adafruit/web-firmware-installer-js",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {}
}
}
}

33
dist/package.json vendored Normal file
View file

@ -0,0 +1,33 @@
{
"name": "@adafruit/web-firmware-installer-js",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"version": "1.0.0",
"description": "ESP32 Web-based Firmware Installation Tool for CircuitPython and more",
"main": "base_installer.js",
"dependencies": {
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git://github.com/adafruit/web-firmware-installer-js.git"
},
"keywords": [
"CircuitPython",
"installer",
"install",
"install tool",
"firmware",
"esp32"
],
"author": "Melissa LeBlanc-Williams",
"license": "MIT",
"bugs": {
"url": "https://github.com/adafruit/web-firmware-installer-js/issues"
},
"homepage": "https://github.com/adafruit/web-firmware-installer-js#readme"
}

14
package-lock.json generated Normal file
View file

@ -0,0 +1,14 @@
{
"name": "@adafruit/web-firmware-installer-js",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@adafruit/web-firmware-installer-js",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {}
}
}
}

33
package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "@adafruit/web-firmware-installer-js",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
"version": "1.0.0",
"description": "ESP32 Web-based Firmware Installation Tool for CircuitPython and more",
"main": "base_installer.js",
"dependencies": {
},
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git://github.com/adafruit/web-firmware-installer-js.git"
},
"keywords": [
"CircuitPython",
"installer",
"install",
"install tool",
"firmware",
"esp32"
],
"author": "Melissa LeBlanc-Williams",
"license": "MIT",
"bugs": {
"url": "https://github.com/adafruit/web-firmware-installer-js/issues"
},
"homepage": "https://github.com/adafruit/web-firmware-installer-js#readme"
}