diff --git a/README.md b/README.md index 90474ff..00d169c 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,12 @@ After those assets are prepared, later runs reuse the local copies unless the mo The `Firmware` tab flashes prebuilt Tater firmware for supported ESPHome satellites. -- Downloads the latest prebuilt firmware manifest and OTA image from `TaterTotterson/microWakeWords`. +- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from `TaterTotterson/microWakeWords`. - Verifies downloaded images by size and SHA before upload. - Auto-detects ESPHome devices with mDNS when the container is running with host networking. - Allows manual IP or hostname entry if discovery does not find the device. - Saves the selected OTA target for each firmware family. +- Flashes the prebuilt factory image over Browser USB for first installs or recovery when opened in Chrome or Edge. - Lists locally trained wake words from `/data/trained_wake_words/` for live model switching. - Streams download, verification, and OTA upload progress in a colorized firmware console. diff --git a/static/index.html b/static/index.html index ba43b7d..ac21e69 100644 --- a/static/index.html +++ b/static/index.html @@ -503,6 +503,12 @@ justify-content: flex-end; } + .usbFlashHint { + color: var(--muted); + font-size: 12px; + max-width: 420px; + } + .studioHero { position: relative; overflow: hidden; @@ -1524,8 +1530,7 @@
1 Pick firmware 2 Select target - 3 Verify image - 4 Flash OTA + 3 Flash OTA or USB
Flasher idle @@ -1601,35 +1606,24 @@ -
-
-
- 3 -
-

Prebuilt Image

-

The trainer downloads the latest verified OTA image and checks its size and SHA before flashing.

-
-
- -
-
-
Firmware image details will appear here.
-
-
+
- 4 + 3
-

Flash OTA

-

The console shows download, verification, OTA upload, and reboot progress.

+

Flash Firmware

+

The console shows download, verification, OTA upload, USB flash, and reboot progress.

- + + +
+
Use Browser USB Flash for new satellites, recovery, or devices older than Tater firmware 3.0.3. Chrome or Edge must be used on localhost or HTTPS.
@@ -1710,6 +1704,7 @@ captured: { items: [], captured_count: 0, negative_count: 0, personal_count: 0 }, samples: { personal: [], negative: [], personal_count: 0, negative_count: 0, activeBucket: "personal", pages: { personal: 0, negative: 0 } }, firmware: { devices: [], templates: [], wakeWords: [], flashing: null, logLines: [], activeTemplateKey: "" }, + browserUsb: { ports: {}, esptoolJsModule: null, esptoolJsLoadPromise: null }, uploadBusy: false, reviewBusy: false, firmwareBusy: false, @@ -2362,6 +2357,23 @@ $("firmwareLogStatus").textContent = String(text || "Waiting for firmware output...").trim() || "Waiting for firmware output..."; } + function appendFirmwareLog(line, statusText = "") { + const text = String(line || "").trim(); + if (!text) return; + uiState.firmware.logLines = [...(uiState.firmware.logLines || []), text].slice(-500); + renderFirmwareLogLines(uiState.firmware.logLines, true); + if (statusText) { + setFirmwareLogStatus(statusText); + } + } + + function appendFirmwareLogs(lines, statusText = "") { + (Array.isArray(lines) ? lines : []).forEach((line) => appendFirmwareLog(line)); + if (statusText) { + setFirmwareLogStatus(statusText); + } + } + function openFirmwareConsole(wobble = true, text = null, statusText = "") { const template = selectedFirmwareTemplate(); const host = ($("firmwareHost").value || "").trim(); @@ -2409,6 +2421,221 @@ }); } + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))); + } + + function browserUsbPortLabel(port) { + if (!port || typeof port.getInfo !== "function") { + return "USB serial device"; + } + const info = port.getInfo() || {}; + const vendor = Number(info.usbVendorId || 0); + const product = Number(info.usbProductId || 0); + if (!vendor && !product) { + return "USB serial device"; + } + const hex = (value) => value.toString(16).padStart(4, "0"); + return `USB ${hex(vendor)}:${hex(product)}`; + } + + async function browserUsbSelectPort(selector = "") { + if (!window.isSecureContext || !navigator.serial) { + throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge."); + } + const port = await navigator.serial.requestPort(); + const key = String(selector || "default").trim() || "default"; + uiState.browserUsb.ports[key] = port; + return port; + } + + function normalizeEsptoolJsModule(module) { + if (module?.ESPLoader && module?.Transport) { + return module; + } + if (module?.default?.ESPLoader && module?.default?.Transport) { + return module.default; + } + return null; + } + + async function ensureEsptoolJsLoaded() { + if (uiState.browserUsb.esptoolJsModule) { + return uiState.browserUsb.esptoolJsModule; + } + if (uiState.browserUsb.esptoolJsLoadPromise instanceof Promise) { + return uiState.browserUsb.esptoolJsLoadPromise; + } + const moduleUrls = [ + "https://esm.sh/esptool-js@0.5.7?bundle", + "https://unpkg.com/esptool-js@0.5.7/bundle.js", + "https://cdn.jsdelivr.net/npm/esptool-js@0.5.7/bundle.js", + ]; + uiState.browserUsb.esptoolJsLoadPromise = (async () => { + const errors = []; + for (const moduleUrl of moduleUrls) { + try { + const module = normalizeEsptoolJsModule(await import(moduleUrl)); + if (module) { + uiState.browserUsb.esptoolJsModule = module; + return module; + } + errors.push(`${moduleUrl}: missing ESPLoader/Transport exports`); + } catch (error) { + errors.push(`${moduleUrl}: ${String(error?.message || error || "load failed")}`); + } + } + throw new Error(`Could not load esptool-js. ${errors.join(" | ")}`); + })(); + return uiState.browserUsb.esptoolJsLoadPromise; + } + + async function fetchFirmwareBinary(binaryUrl) { + const response = await fetch(binaryUrl, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`Firmware download failed: HTTP ${response.status}`); + } + return new Uint8Array(await response.arrayBuffer()); + } + + function uint8ArrayToBinaryString(bytes) { + const chunks = []; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))); + } + return chunks.join(""); + } + + async function browserUsbSetSignals(port, signals, timeoutMs = 1200) { + if (!port || typeof port.setSignals !== "function") { + return false; + } + let timeoutId = 0; + try { + await Promise.race([ + port.setSignals(signals), + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error("Timed out setting USB serial control signals.")), Math.max(1, Number(timeoutMs) || 1)); + }), + ]); + return true; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } + + async function browserUsbHardResetAfterFlash(transport, loader, port) { + try { + appendFirmwareLog("Resetting device after USB flash."); + if (transport && typeof transport.setRTS === "function") { + await transport.setRTS(true); + } else { + await browserUsbSetSignals(port, { dataTerminalReady: false, requestToSend: true, break: false }); + } + await sleep(100); + if (loader && typeof loader.after === "function") { + await loader.after(); + } + await sleep(1000); + return true; + } catch (error) { + appendFirmwareLog(`USB reset warning: ${String(error?.message || error)}`); + return false; + } + } + + async function flashBrowserUsbPort(port, artifact) { + const module = await ensureEsptoolJsLoaded(); + const { ESPLoader, Transport } = module; + if (!ESPLoader || !Transport) { + throw new Error("esptool-js did not expose ESPLoader/Transport."); + } + const binaryUrl = String(artifact?.binary_url || "").trim(); + if (!binaryUrl) { + throw new Error("Browser flash binary URL is missing."); + } + + const transport = new Transport(port); + const terminal = { + clean() {}, + writeLine(data) { + const text = String(data || "").trim(); + if (text) appendFirmwareLog(text); + }, + write(data) { + const text = String(data || "").trim(); + if (text) appendFirmwareLog(text); + }, + }; + const loader = new ESPLoader({ + transport, + baudrate: 115200, + terminal, + debugLogging: false, + }); + + let lastProgress = -1; + try { + appendFirmwareLog(`Connecting to ${browserUsbPortLabel(port)}...`, "Connecting to selected USB device..."); + const chipName = await loader.main(); + appendFirmwareLog(`Connected to ${chipName || "ESP device"}.`); + + setFirmwareLogStatus("Downloading prepared factory firmware..."); + const firmwareData = await fetchFirmwareBinary(binaryUrl); + appendFirmwareLog(`Firmware downloaded (${firmwareData.byteLength} bytes).`); + + const eraseAll = artifact?.erase_all !== false; + if (eraseAll) { + appendFirmwareLog("Erasing flash and writing factory image at offset 0x0."); + } else { + appendFirmwareLog("Writing factory image at offset 0x0."); + } + const flashOptions = { + fileArray: [{ data: firmwareData, address: 0 }], + flashMode: String(artifact?.flash_mode || "dio"), + flashFreq: String(artifact?.flash_freq || "40m"), + flashSize: String(artifact?.flash_size || "4MB"), + eraseAll, + compress: true, + reportProgress: (_fileIndex, written, total) => { + const pct = total > 0 ? Math.floor((written / total) * 100) : 0; + if (pct >= lastProgress + 5 || pct === 100) { + lastProgress = pct; + appendFirmwareLog(`USB flash progress: ${pct}% (${written}/${total} bytes)`, `Flashing over USB... ${pct}%`); + } + }, + }; + try { + await loader.writeFlash(flashOptions); + } catch (error) { + const message = String(error?.message || error || ""); + if (!message.includes("charCodeAt")) { + throw error; + } + appendFirmwareLog("Retrying flash with binary-string firmware format."); + await loader.writeFlash({ + ...flashOptions, + eraseAll: false, + fileArray: [{ data: uint8ArrayToBinaryString(firmwareData), address: 0 }], + }); + } + appendFirmwareLog("USB flash complete."); + const resetWorked = await browserUsbHardResetAfterFlash(transport, loader, port); + if (!resetWorked) { + appendFirmwareLog("If the device does not restart, unplug and reconnect USB once to leave bootloader mode."); + } + } finally { + try { + await transport.disconnect(); + } catch (_error) { + // Ignore disconnect cleanup failures. + } + } + } + function closeConsole() { $("consoleOverlay").classList.remove("open"); $("consoleOverlay").setAttribute("aria-hidden", "true"); @@ -3033,6 +3260,66 @@ } } + async function startUsbFirmwareFlash() { + const template = selectedFirmwareTemplate(); + if (!template) { + alert("Choose a firmware family first."); + return; + } + const factory = template.prebuilt_firmware?.artifacts?.factory || {}; + if (!factory.path) { + alert("No prebuilt USB factory image is available for this firmware family."); + return; + } + if (!window.isSecureContext || !navigator.serial) { + alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge."); + return; + } + const ok = confirm(`USB flash the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices older than Tater firmware 3.0.3. It erases flash and writes the factory image over USB.`); + if (!ok) return; + + let port; + try { + port = await browserUsbSelectPort(template.value); + } catch (error) { + alert("USB device selection failed: " + error.message); + return; + } + + uiState.firmwareBusy = true; + uiState.firmware.logLines = [ + "===== Browser USB Firmware Flash Console =====", + `Firmware: ${template.label || template.value}`, + `USB device: ${browserUsbPortLabel(port)}`, + "Preparing verified factory image...", + ]; + setPill($("firmwareStatus"), "Preparing USB flash...", "warn"); + openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Preparing browser USB flash..."); + syncButtons(); + await waitForPaint(); + + try { + const artifact = await api("/api/firmware/browser_flash", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + template_key: template.value, + }), + }); + appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash..."); + await flashBrowserUsbPort(port, artifact); + appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished."); + setPill($("firmwareStatus"), "USB flash finished", "ok"); + } catch (error) { + appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed."); + setPill($("firmwareStatus"), "USB flash failed", "err"); + alert("Browser USB flash failed: " + error.message); + } finally { + uiState.firmwareBusy = false; + syncButtons(); + } + } + async function pollFirmwareFlash(sessionId) { if (uiState.firmwarePoller || !sessionId) return; uiState.firmwarePoller = true; @@ -3123,6 +3410,7 @@ $("cleanFirmwareBtn").disabled = uiState.firmwareBusy; $("openFirmwareConsoleBtn").disabled = false; $("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate || !firmwareAvailable; + $("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path; } function refreshSessionUI(session) { @@ -3491,6 +3779,7 @@ } }); $("flashFirmwareBtn").addEventListener("click", startFirmwareFlash); + $("usbFirmwareBtn").addEventListener("click", startUsbFirmwareFlash); $("saveFirmwareSettingsBtn").addEventListener("click", async () => { const host = ($("firmwareHost").value || "").trim(); const template = ($("firmwareTemplate").value || "").trim(); diff --git a/trainer_server.py b/trainer_server.py index d20e87f..be06995 100644 --- a/trainer_server.py +++ b/trainer_server.py @@ -94,6 +94,7 @@ FIRMWARE_OTA_BLOCK_SIZE = int(os.environ.get("FIRMWARE_OTA_BLOCK_SIZE", "8192")) FIRMWARE_PROFILE_FILE = Path( os.environ.get("FIRMWARE_PROFILE_FILE", str(FIRMWARE_CACHE_DIR / "profiles.json")) ).resolve() +FIRMWARE_WEB_FLASH_DIR = FIRMWARE_CACHE_DIR / "web_flash" WAKE_SOUND_MANIFEST_PATHS = ("wake_sound_manifest.json", "wake-sound-manifest.json") WAKE_SOUND_CATALOG_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": {}} WAKE_SOUND_CATALOG_LOCK = threading.Lock() @@ -1683,6 +1684,57 @@ def _download_prebuilt_firmware_binary( return {"path": target_path, "artifact": artifact, "url": url, "cached": False} +def _browser_flash_artifact_id(template_key: Any) -> str: + return "_".join( + part + for part in [ + _sanitize_token(template_key), + str(int(time.time())), + uuid.uuid4().hex[:8], + ] + if part + ) + + +def _create_browser_flash_artifact(template_key: Any, prebuilt: Dict[str, Any], binary_path: Path) -> Dict[str, Any]: + artifact_id = _browser_flash_artifact_id(template_key) + artifact_dir = FIRMWARE_WEB_FLASH_DIR / artifact_id + artifact_dir.mkdir(parents=True, exist_ok=True) + + target_binary_name = "firmware.bin" + target_binary_path = artifact_dir / target_binary_name + shutil.copy2(binary_path, target_binary_path) + return { + "artifact_id": artifact_id, + "binary_url": f"/api/firmware/browser_flash/{artifact_id}/{target_binary_name}", + "binary_name": target_binary_name, + "template_key": _text(template_key), + "firmware_version": _text(prebuilt.get("version")), + "source_binary": str(binary_path), + "binary_size": int(target_binary_path.stat().st_size), + "erase_all": True, + "flash_size": "4MB", + "flash_mode": "dio", + "flash_freq": "40m", + } + + +def _browser_flash_artifact_path(artifact_id: str, relative_path: str) -> Path: + artifact = _sanitize_token(artifact_id) + if not artifact: + raise KeyError("Browser flash artifact is missing.") + rel = Path(_text(relative_path)) + if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts): + raise KeyError("Browser flash artifact path is invalid.") + root = (FIRMWARE_WEB_FLASH_DIR / artifact).resolve() + target = (root / rel).resolve() + if root not in target.parents and target != root: + raise KeyError("Browser flash artifact path is invalid.") + if not target.is_file(): + raise KeyError("Browser flash artifact file was not found.") + return target + + class _NativeOTAError(RuntimeError): pass @@ -3346,6 +3398,50 @@ def firmware_build_flash(payload: Dict[str, Any]): return _firmware_session_payload(session_id) +@app.post("/api/firmware/browser_flash") +def firmware_browser_flash(payload: Dict[str, Any]): + body = payload if isinstance(payload, dict) else {} + try: + template_key = str(body.get("template_key") or "").strip() + template_spec = _firmware_template_spec(template_key) + prebuilt = _prebuilt_firmware_info(template_key, force_refresh=True) + if not bool(prebuilt.get("available")): + raise RuntimeError(_text(prebuilt.get("error")) or "No prebuilt firmware image is available for this firmware target.") + _prebuilt_artifact_meta(prebuilt, "factory") + binary = _download_prebuilt_firmware_binary(template_key, prebuilt, "factory", force_refresh=True) + artifact = _create_browser_flash_artifact(template_key, prebuilt, Path(binary["path"])) + template_label = str(template_spec.get("label") or template_key) + cached_text = "cached" if bool(binary.get("cached")) else "downloaded" + return { + "ok": True, + "template_key": template_key, + "template_label": template_label, + "firmware_version": _text(prebuilt.get("version")), + "message": f"Prepared prebuilt USB firmware for {template_label}.", + "entries": [ + f"Using prebuilt {template_label} factory firmware {_text(prebuilt.get('version')) or 'latest'}.", + f"Factory image {cached_text}: {Path(binary['path']).name}", + f"Browser flash binary ready: {artifact['binary_name']} ({artifact['binary_size']} bytes)", + ], + **artifact, + } + except Exception as e: + return JSONResponse({"ok": False, "error": str(e)}, status_code=400) + + +@app.get("/api/firmware/browser_flash/{artifact_id}/{filename}") +def firmware_browser_flash_binary(artifact_id: str, filename: str): + try: + target = _browser_flash_artifact_path(artifact_id, filename) + except KeyError as exc: + return JSONResponse({"ok": False, "error": str(exc)}, status_code=404) + return FileResponse( + str(target), + media_type="application/octet-stream", + filename=target.name, + ) + + @app.post("/api/firmware/clean") def firmware_clean(): active = [] @@ -3357,7 +3453,7 @@ def firmware_clean(): return JSONResponse({"ok": False, "error": f"Wait for active firmware session(s) to finish: {', '.join(active[:3])}."}, status_code=400) removed = [] - for child in ("prebuilt_firmware", "uploads"): + for child in ("prebuilt_firmware", "uploads", "web_flash"): path = FIRMWARE_CACHE_DIR / child if path.exists(): shutil.rmtree(path, ignore_errors=True)