diff --git a/static/index.html b/static/index.html index 5048904..e68000b 100644 --- a/static/index.html +++ b/static/index.html @@ -509,6 +509,34 @@ max-width: 420px; } + .firmwareUsbWifiPanel { + grid-column: 1 / -1; + display: grid; + gap: 12px; + padding: 14px; + border-radius: 14px; + border: 1px solid rgba(57,212,160,0.16); + background: rgba(57,212,160,0.055); + } + + .firmwareUsbWifiToggle { + display: flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 700; + } + + .firmwareUsbWifiToggle input { + width: auto; + } + + .firmwareUsbWifiGrid { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + gap: 12px; + } + .studioHero { position: relative; overflow: hidden; @@ -1186,6 +1214,7 @@ .firmwareGrid { grid-template-columns: 1fr; } .firmwareLayout, .firmwareTargetGrid, + .firmwareUsbWifiGrid, .firmwareActionsPanel { grid-template-columns: 1fr; } @@ -1591,7 +1620,8 @@ Detected Device - No devices scanned yet + Choose target... + Browser USB Flash (new/recovery) @@ -1623,6 +1653,22 @@ Clear downloaded images Open firmware console + + + + Set up Wi-Fi after Browser USB Flash + + + + Wi-Fi SSID + + + + Wi-Fi Password + + + + 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. In the macOS app, open http://127.0.0.1:8789 in Chrome or Edge; port 3232 is only for OTA updates after firmware is installed. @@ -2031,7 +2077,7 @@ } function escapeHtml(text) { - return String(text || "") + return String(text ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">"); @@ -2056,6 +2102,12 @@ return { label: item.capture_label || "Captured", cls: "" }; } + function formatDetectionProfile(value) { + return String(value || "") + .replaceAll("_", " ") + .replace(/\b\w/g, (ch) => ch.toUpperCase()); + } + function renderCapturedItems(payload) { const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 }; uiState.captured = data; @@ -2084,6 +2136,18 @@ if (item.wake_word) meta.push(`${escapeHtml(item.wake_word)}`); if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`max ${escapeHtml(item.max_probability)}`); if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`avg ${escapeHtml(item.average_probability)}`); + if (item.detection_profile) meta.push(`profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}`); + if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`peak cutoff ${escapeHtml(item.peak_probability_cutoff)}`); + if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`avg cutoff ${escapeHtml(item.probability_cutoff)}`); + if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) { + meta.push(`windows ${escapeHtml(item.active_window_count)}/${escapeHtml(item.min_active_windows)}`); + } + if (item.rise_score !== null && item.rise_score !== undefined) meta.push(`rise ${escapeHtml(item.rise_score)}`); + if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) meta.push(`VAD max ${escapeHtml(item.vad_max_probability)}`); + if (item.vad_average_probability !== null && item.vad_average_probability !== undefined) meta.push(`VAD avg ${escapeHtml(item.vad_average_probability)}`); + if (Array.isArray(item.probability_history) && item.probability_history.length) { + meta.push(`history ${escapeHtml(item.probability_history.length)}`); + } const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit"; const when = formatTimestamp(item.captured_at || item.received_at); const actionDisabled = uiState.reviewBusy ? "disabled" : ""; @@ -2449,6 +2513,18 @@ return port; } + async function browserUsbAuthorizedPorts() { + if (!window.isSecureContext || !navigator.serial || typeof navigator.serial.getPorts !== "function") { + return []; + } + return navigator.serial.getPorts(); + } + + function browserUsbStoredPort(selector = "") { + const key = String(selector || "default").trim() || "default"; + return uiState.browserUsb.ports?.[key] || null; + } + function normalizeEsptoolJsModule(module) { if (module?.ESPLoader && module?.Transport) { return module; @@ -2507,19 +2583,15 @@ return chunks.join(""); } - async function browserUsbSetSignals(port, signals, timeoutMs = 1200) { - if (!port || typeof port.setSignals !== "function") { - return false; - } + async function browserUsbWithTimeout(promise, timeoutMs, message) { let timeoutId = 0; try { - await Promise.race([ - port.setSignals(signals), + return await Promise.race([ + promise, new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => reject(new Error("Timed out setting USB serial control signals.")), Math.max(1, Number(timeoutMs) || 1)); + timeoutId = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1)); }), ]); - return true; } finally { if (timeoutId) { clearTimeout(timeoutId); @@ -2527,6 +2599,18 @@ } } + async function browserUsbSetSignals(port, signals, timeoutMs = 1200) { + if (!port || typeof port.setSignals !== "function") { + return false; + } + await browserUsbWithTimeout( + port.setSignals(signals), + timeoutMs, + "Timed out setting USB serial control signals." + ); + return true; + } + async function browserUsbHardResetAfterFlash(transport, loader, port) { try { appendFirmwareLog("Resetting device after USB flash."); @@ -2547,6 +2631,42 @@ } } + function browserUsbSameDevice(left, right) { + const leftInfo = left?.getInfo?.() || {}; + const rightInfo = right?.getInfo?.() || {}; + const leftVendor = Number(leftInfo.usbVendorId || 0); + const leftProduct = Number(leftInfo.usbProductId || 0); + const rightVendor = Number(rightInfo.usbVendorId || 0); + const rightProduct = Number(rightInfo.usbProductId || 0); + return Boolean(leftVendor && leftProduct && leftVendor === rightVendor && leftProduct === rightProduct); + } + + async function browserUsbWaitForReconnect(previousPort, selector, timeoutMs = 30000) { + const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 1000); + let lastLogAt = 0; + while (Date.now() < deadline) { + let ports = []; + try { + ports = await browserUsbAuthorizedPorts(); + } catch (_error) { + ports = []; + } + const matchingPort = ports.find((port) => browserUsbSameDevice(previousPort, port)) || (ports.length === 1 ? ports[0] : null); + if (matchingPort) { + const key = String(selector || "default").trim() || "default"; + uiState.browserUsb.ports[key] = matchingPort; + appendFirmwareLog(`Reconnected ${browserUsbPortLabel(matchingPort)} for Wi-Fi setup.`, "USB serial reconnected for Wi-Fi setup..."); + return matchingPort; + } + if (Date.now() - lastLogAt > 2500) { + lastLogAt = Date.now(); + appendFirmwareLog("Waiting for Chrome to expose the rebooted USB serial device...", "Waiting for rebooted USB device..."); + } + await sleep(750); + } + throw new Error("The device rebooted, but Chrome did not expose the reconnected USB serial device. Click Browser USB Flash again and select the device if needed."); + } + async function flashBrowserUsbPort(port, artifact) { const module = await ensureEsptoolJsLoaded(); const { ESPLoader, Transport } = module; @@ -2636,6 +2756,344 @@ } } + const IMPROV_SERIAL_HEADER = [0x49, 0x4d, 0x50, 0x52, 0x4f, 0x56]; + const IMPROV_SERIAL_VERSION = 0x01; + const IMPROV_SERIAL_TYPE = { + CURRENT_STATE: 0x01, + ERROR_STATE: 0x02, + RPC: 0x03, + RPC_RESPONSE: 0x04, + }; + const IMPROV_SERIAL_COMMAND = { + WIFI_SETTINGS: 0x01, + GET_CURRENT_STATE: 0x02, + GET_DEVICE_INFO: 0x03, + }; + const IMPROV_SERIAL_STATE = { + STOPPED: 0x00, + AWAITING_AUTHORIZATION: 0x01, + AUTHORIZED: 0x02, + PROVISIONING: 0x03, + PROVISIONED: 0x04, + }; + const IMPROV_SERIAL_ERROR = { + NONE: 0x00, + INVALID_RPC: 0x01, + UNKNOWN_RPC: 0x02, + UNABLE_TO_CONNECT: 0x03, + NOT_AUTHORIZED: 0x04, + UNKNOWN: 0xff, + }; + const IMPROV_TEXT_ENCODER = new TextEncoder(); + const IMPROV_TEXT_DECODER = new TextDecoder(); + + function improvSerialStateLabel(stateValue) { + switch (Number(stateValue)) { + case IMPROV_SERIAL_STATE.STOPPED: + return "Stopped"; + case IMPROV_SERIAL_STATE.AWAITING_AUTHORIZATION: + return "Awaiting authorization"; + case IMPROV_SERIAL_STATE.AUTHORIZED: + return "Ready for Wi-Fi credentials"; + case IMPROV_SERIAL_STATE.PROVISIONING: + return "Connecting to Wi-Fi"; + case IMPROV_SERIAL_STATE.PROVISIONED: + return "Wi-Fi connected"; + default: + return `Unknown state ${stateValue}`; + } + } + + function improvSerialErrorLabel(errorValue) { + switch (Number(errorValue)) { + case IMPROV_SERIAL_ERROR.NONE: + return "No error"; + case IMPROV_SERIAL_ERROR.INVALID_RPC: + return "Invalid Improv command"; + case IMPROV_SERIAL_ERROR.UNKNOWN_RPC: + return "Unknown Improv command"; + case IMPROV_SERIAL_ERROR.UNABLE_TO_CONNECT: + return "Unable to connect to Wi-Fi"; + case IMPROV_SERIAL_ERROR.NOT_AUTHORIZED: + return "Not authorized"; + case IMPROV_SERIAL_ERROR.UNKNOWN: + return "Unknown Improv error"; + default: + return `Improv error ${errorValue}`; + } + } + + function improvEncodeString(value) { + const bytes = IMPROV_TEXT_ENCODER.encode(String(value || "")); + if (bytes.length > 255) { + throw new Error("Improv Serial values must be shorter than 256 bytes."); + } + return [bytes.length, ...bytes]; + } + + function improvBuildRpcPayload(command, strings = []) { + const data = []; + (Array.isArray(strings) ? strings : []).forEach((value) => { + data.push(...improvEncodeString(value)); + }); + if (data.length > 255) { + throw new Error("Improv Serial command is too large."); + } + return [command, data.length, ...data]; + } + + function improvBuildSerialPacket(type, payload = []) { + const bytes = [...IMPROV_SERIAL_HEADER, IMPROV_SERIAL_VERSION, type, payload.length, ...payload]; + const checksum = bytes.reduce((sum, byte) => (sum + byte) & 0xff, 0); + return new Uint8Array([...bytes, checksum, 0x0a]); + } + + function improvParseRpcPayload(payload) { + const bytes = Array.from(payload || []); + const command = Number(bytes[0] || 0); + const dataLength = Number(bytes[1] || 0); + const dataEnd = Math.min(bytes.length, 2 + dataLength); + const strings = []; + let offset = 2; + while (offset < dataEnd) { + const length = Number(bytes[offset] || 0); + offset += 1; + if (offset + length > dataEnd) { + break; + } + strings.push(IMPROV_TEXT_DECODER.decode(new Uint8Array(bytes.slice(offset, offset + length)))); + offset += length; + } + return { command, strings }; + } + + class ImprovSerialParser { + constructor() { + this.buffer = []; + } + + feed(chunk) { + const bytes = chunk instanceof Uint8Array ? Array.from(chunk) : []; + this.buffer.push(...bytes); + const packets = []; + while (this.buffer.length >= IMPROV_SERIAL_HEADER.length) { + const headerIndex = this.buffer.findIndex((byte, index, rows) => + IMPROV_SERIAL_HEADER.every((headerByte, headerOffset) => rows[index + headerOffset] === headerByte) + ); + if (headerIndex < 0) { + this.buffer.splice(0, Math.max(0, this.buffer.length - IMPROV_SERIAL_HEADER.length + 1)); + break; + } + if (headerIndex > 0) { + this.buffer.splice(0, headerIndex); + } + if (this.buffer.length < 10) { + break; + } + if (this.buffer[6] !== IMPROV_SERIAL_VERSION) { + this.buffer.shift(); + continue; + } + const type = Number(this.buffer[7] || 0); + const payloadLength = Number(this.buffer[8] || 0); + const checksumIndex = 9 + payloadLength; + if (this.buffer.length <= checksumIndex) { + break; + } + const checksum = this.buffer + .slice(0, checksumIndex) + .reduce((sum, byte) => (sum + Number(byte || 0)) & 0xff, 0); + if (checksum !== Number(this.buffer[checksumIndex] || 0)) { + this.buffer.shift(); + continue; + } + const payload = new Uint8Array(this.buffer.slice(9, 9 + payloadLength)); + const nextOffset = this.buffer[checksumIndex + 1] === 0x0a ? checksumIndex + 2 : checksumIndex + 1; + this.buffer.splice(0, nextOffset); + packets.push({ type, payload }); + } + return packets; + } + } + + async function openImprovSerialPort(port) { + for (let attempt = 1; attempt <= 8; attempt += 1) { + try { + await port.open({ baudRate: 115200, bufferSize: 255 }); + return; + } catch (error) { + const message = String(error?.message || error || ""); + if (error?.name === "InvalidStateError" || message.toLowerCase().includes("already open")) { + return; + } + if (attempt >= 8) { + throw error; + } + appendFirmwareLog(`Waiting for USB serial to reconnect (${attempt}/8)...`, "Waiting for USB serial..."); + await sleep(1000); + } + } + } + + async function setupImprovWifi(port, options) { + const ssid = String(options?.ssid || "").trim(); + const password = String(options?.password || ""); + if (!ssid) { + appendFirmwareLog("Wi-Fi setup skipped."); + return; + } + + appendFirmwareLog("Waiting for the flashed device to restart into Improv Serial.", "Waiting for Improv Serial..."); + appendFirmwareLog("If the screen stays off, unplug and reconnect USB once; the trainer will keep watching for Improv."); + await sleep(10000); + await openImprovSerialPort(port); + appendFirmwareLog("USB serial reopened. Checking Improv Serial.", "Checking Improv Serial..."); + + const parser = new ImprovSerialParser(); + const reader = port.readable?.getReader?.(); + const writer = port.writable?.getWriter?.(); + if (!reader || !writer) { + throw new Error("USB serial reader/writer was not available for Wi-Fi setup."); + } + + let stopped = false; + let currentState = null; + let currentError = IMPROV_SERIAL_ERROR.NONE; + let lastStateLabel = ""; + let responseUrls = []; + let seenImprovPacket = false; + + const handlePacket = (packet) => { + if (!packet || typeof packet !== "object") { + return; + } + seenImprovPacket = true; + if (packet.type === IMPROV_SERIAL_TYPE.CURRENT_STATE) { + currentState = Number(packet.payload?.[0] ?? -1); + const label = improvSerialStateLabel(currentState); + if (label !== lastStateLabel) { + lastStateLabel = label; + appendFirmwareLog(`Improv: ${label}.`, `Wi-Fi setup: ${label}.`); + } + return; + } + if (packet.type === IMPROV_SERIAL_TYPE.ERROR_STATE) { + currentError = Number(packet.payload?.[0] ?? IMPROV_SERIAL_ERROR.UNKNOWN); + if (currentError !== IMPROV_SERIAL_ERROR.NONE) { + appendFirmwareLog(`Improv error: ${improvSerialErrorLabel(currentError)}.`, "Wi-Fi setup failed."); + } + return; + } + if (packet.type === IMPROV_SERIAL_TYPE.RPC_RESPONSE) { + const response = improvParseRpcPayload(packet.payload); + if (response.command === IMPROV_SERIAL_COMMAND.GET_DEVICE_INFO && response.strings.length) { + appendFirmwareLog(`Device info: ${response.strings.filter(Boolean).join(" • ")}`); + } + if ( + response.command === IMPROV_SERIAL_COMMAND.WIFI_SETTINGS || + response.command === IMPROV_SERIAL_COMMAND.GET_CURRENT_STATE + ) { + responseUrls = response.strings.filter(Boolean); + if (responseUrls.length) { + appendFirmwareLog(`Device URL: ${responseUrls[0]}`); + } + } + } + }; + + const readLoop = (async () => { + while (!stopped) { + try { + const result = await reader.read(); + if (result?.done) { + break; + } + parser.feed(result?.value).forEach(handlePacket); + } catch (error) { + if (!stopped) { + appendFirmwareLog(`Improv read warning: ${String(error?.message || error)}`); + } + break; + } + } + })(); + + const sendRpc = async (command, strings = []) => { + await browserUsbWithTimeout( + writer.write(improvBuildSerialPacket(IMPROV_SERIAL_TYPE.RPC, improvBuildRpcPayload(command, strings))), + 4000, + "Timed out writing Improv Serial command." + ); + }; + + let pollTimer = 0; + try { + await sendRpc(IMPROV_SERIAL_COMMAND.GET_DEVICE_INFO); + await sendRpc(IMPROV_SERIAL_COMMAND.GET_CURRENT_STATE); + const helloDeadline = Date.now() + 8000; + while (!seenImprovPacket && Date.now() < helloDeadline) { + if (currentError && currentError !== IMPROV_SERIAL_ERROR.NONE) { + throw new Error(improvSerialErrorLabel(currentError)); + } + await sleep(150); + } + if (!seenImprovPacket) { + throw new Error("No Improv Serial response from the flashed firmware. Turn off Wi-Fi setup for images with saved Wi-Fi, or flash an image that includes improv_serial."); + } + appendFirmwareLog("Connected to Improv Serial.", "Connected to Improv Serial."); + await sleep(300); + appendFirmwareLog(`Sending Wi-Fi credentials for ${ssid}.`, `Sending Wi-Fi credentials for ${ssid}...`); + await sendRpc(IMPROV_SERIAL_COMMAND.WIFI_SETTINGS, [ssid, password]); + pollTimer = setInterval(() => { + void sendRpc(IMPROV_SERIAL_COMMAND.GET_CURRENT_STATE).catch(() => {}); + }, 1500); + + const deadline = Date.now() + 45000; + while (Date.now() < deadline) { + if (currentError && currentError !== IMPROV_SERIAL_ERROR.NONE) { + throw new Error(improvSerialErrorLabel(currentError)); + } + if (currentState === IMPROV_SERIAL_STATE.PROVISIONED) { + const urlSuffix = responseUrls.length ? ` ${responseUrls[0]}` : ""; + appendFirmwareLog(`Wi-Fi setup complete.${urlSuffix}`, "Wi-Fi setup complete."); + return; + } + await sleep(300); + } + throw new Error("Timed out waiting for Improv Serial Wi-Fi setup."); + } finally { + if (pollTimer) { + clearInterval(pollTimer); + } + stopped = true; + try { + await reader.cancel(); + } catch (_error) { + // Ignore reader cancellation cleanup failures. + } + try { + await readLoop; + } catch (_error) { + // Ignore read-loop cleanup failures. + } + try { + reader.releaseLock(); + } catch (_error) { + // Ignore lock cleanup failures. + } + try { + writer.releaseLock(); + } catch (_error) { + // Ignore lock cleanup failures. + } + try { + await port.close(); + } catch (_error) { + // Ignore close cleanup failures. + } + } + } + function closeConsole() { $("consoleOverlay").classList.remove("open"); $("consoleOverlay").setAttribute("aria-hidden", "true"); @@ -2648,26 +3106,46 @@ : "No files selected"; } + const FIRMWARE_USB_DEVICE_VALUE = "__browser_usb_flash__"; + + function firmwareUsbSelected() { + return $("firmwareDeviceSelect").value === FIRMWARE_USB_DEVICE_VALUE; + } + + function syncUsbFirmwareControls() { + const enabled = $("firmwareUsbWifiEnabled").checked; + $("firmwareUsbSsid").disabled = !enabled; + $("firmwareUsbPassword").disabled = !enabled; + $("usbFirmwareBtn").textContent = enabled ? "Flash + Set Up Wi-Fi" : "Browser USB Flash"; + } + function renderFirmwareDevices(devices, message) { const list = Array.isArray(devices) ? devices : []; uiState.firmware.devices = list; - if (!list.length) { - $("firmwareDeviceSelect").innerHTML = `No devices detected`; - setPill($("firmwareDetectStatus"), message || "No devices detected", "warn"); - return; - } - $("firmwareDeviceSelect").innerHTML = [ - `Choose detected device...`, + `Choose target...`, + `Browser USB Flash (new/recovery)`, ...list.map((device, index) => { const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`; return `${escapeHtml(label)}`; }), ].join(""); + if (!list.length) { + setPill($("firmwareDetectStatus"), message || "No devices detected", "warn"); + if (!($("firmwareHost").value || "").trim()) { + $("firmwareDeviceSelect").value = FIRMWARE_USB_DEVICE_VALUE; + applySelectedFirmwareDevice().catch((error) => { + setPill($("firmwareStatus"), "USB settings failed", "warn"); + console.warn("USB settings load failed", error); + }); + } + return; + } + setPill($("firmwareDetectStatus"), `${list.length} detected`, "ok"); - if (!($("firmwareHost").value || "").trim()) { + if (!($("firmwareHost").value || "").trim() && !firmwareUsbSelected()) { $("firmwareDeviceSelect").value = "0"; applySelectedFirmwareDevice().catch((error) => { setPill($("firmwareStatus"), "Device settings failed", "warn"); @@ -2687,6 +3165,14 @@ async function applySelectedFirmwareDevice() { const indexText = $("firmwareDeviceSelect").value; if (indexText === "") return; + if (indexText === FIRMWARE_USB_DEVICE_VALUE) { + $("firmwareHost").value = ""; + $("firmwarePort").value = "3232"; + setPill($("firmwareStatus"), "Browser USB selected", "ok"); + await refreshFirmwareTemplates(); + syncButtons(); + return; + } const device = uiState.firmware.devices[Number(indexText)]; if (!device) return; await flushFirmwareProfileSave(); @@ -2711,6 +3197,7 @@ function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) { if (!template) return; + if (firmwareUsbSelected()) return; if (template.target_host) { $("firmwareHost").value = template.target_host; } @@ -3023,6 +3510,9 @@ function firmwareTemplateQuery() { const params = new URLSearchParams(); + if (firmwareUsbSelected()) { + return ""; + } const host = ($("firmwareHost").value || "").trim(); const port = ($("firmwarePort").value || "3232").trim(); if (host) params.set("target_host", host); @@ -3200,12 +3690,20 @@ 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.`); + const wifiEnabled = $("firmwareUsbWifiEnabled").checked; + const wifiSsid = ($("firmwareUsbSsid").value || "").trim(); + const wifiPassword = $("firmwareUsbPassword").value || ""; + if (wifiEnabled && !wifiSsid) { + alert("Enter a Wi-Fi SSID or turn off Wi-Fi setup before Browser USB Flash."); + return; + } + const actionLabel = wifiEnabled ? "USB flash and set up Wi-Fi" : "USB flash"; + const ok = confirm(`${actionLabel} for 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); + port = browserUsbStoredPort(template.value) || await browserUsbSelectPort(template.value); } catch (error) { alert("USB device selection failed: " + error.message); return; @@ -3233,8 +3731,16 @@ }); 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"); + if (wifiEnabled) { + port = await browserUsbWaitForReconnect(port, template.value); + await setupImprovWifi(port, { ssid: wifiSsid, password: wifiPassword }); + appendFirmwareLog("Browser USB flash and Wi-Fi setup finished.", "Browser USB flash and Wi-Fi setup finished."); + setPill($("firmwareStatus"), "USB flash + Wi-Fi finished", "ok"); + } else { + appendFirmwareLog("Wi-Fi setup skipped by user."); + 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"); @@ -3320,6 +3826,11 @@ const firmwareTemplate = ($("firmwareTemplate").value || "").trim(); const firmwareSelection = selectedFirmwareTemplate(); const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available); + const usbSelected = firmwareUsbSelected(); + const usbWifiEnabled = $("firmwareUsbWifiEnabled").checked; + const usbWifiSsid = ($("firmwareUsbSsid").value || "").trim(); + const usbWifiReady = !usbWifiEnabled || Boolean(usbWifiSsid); + syncUsbFirmwareControls(); $("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy; $("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy; @@ -3331,11 +3842,11 @@ $("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0; $("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy; $("refreshFirmwareBtn").disabled = uiState.firmwareBusy; - $("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate; + $("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate; $("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; + $("flashFirmwareBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate || !firmwareAvailable; + $("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path || !usbWifiReady; } function refreshSessionUI(session) { @@ -3695,6 +4206,10 @@ alert("Device settings failed: " + error.message); }); }); + ["firmwareUsbWifiEnabled", "firmwareUsbSsid", "firmwareUsbPassword"].forEach((id) => { + $(id).addEventListener("input", syncButtons); + $(id).addEventListener("change", syncButtons); + }); $("refreshFirmwareBtn").addEventListener("click", async () => { try { await refreshFirmwareDevices(); diff --git a/trainer_server.py b/trainer_server.py index be06995..b0cca38 100644 --- a/trainer_server.py +++ b/trainer_server.py @@ -108,26 +108,31 @@ FIRMWARE_TEMPLATE_SPECS = ( "key": "voicepe", "label": "VoicePE", "description": "VoicePE satellite prebuilt firmware", + "flash_size": "16MB", }, { "key": "satellite1", "label": "Sat1", "description": "Satellite1 prebuilt firmware", + "flash_size": "16MB", }, { "key": "respeaker_lite", "label": "ReSpeaker Lite", "description": "ReSpeaker Lite prebuilt firmware", + "flash_size": "8MB", }, { "key": "koala", "label": "Koala Satellite", "description": "Koala satellite prebuilt firmware", + "flash_size": "16MB", }, { "key": "respeaker_xvf3800", "label": "ReSpeaker XVF3800", "description": "ReSpeaker XVF3800 prebuilt firmware", + "flash_size": "8MB", }, ) FIRMWARE_PREBUILT_LATEST_URL = ( @@ -684,6 +689,30 @@ def _parse_float(value: Any) -> float | None: return None +def _parse_int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(float(value)) + except Exception: + return None + + +def _parse_probability_history(value: Any) -> List[int]: + if value in (None, ""): + return [] + if isinstance(value, list): + raw_values = value + else: + raw_values = str(value).split(",") + history: List[int] = [] + for raw_value in raw_values: + parsed = _parse_int(raw_value) + if parsed is not None: + history.append(parsed) + return history + + def _audio_sidecar_path(audio_path: Path) -> Path: return audio_path.with_suffix(".json") @@ -1001,6 +1030,15 @@ def _captured_item_from_path(audio_path: Path) -> Dict[str, Any]: "blocked_by_vad": bool(meta.get("blocked_by_vad")), "max_probability": meta.get("max_probability"), "average_probability": meta.get("average_probability"), + "probability_cutoff": meta.get("probability_cutoff"), + "peak_probability_cutoff": meta.get("peak_probability_cutoff"), + "active_window_count": meta.get("active_window_count"), + "min_active_windows": meta.get("min_active_windows"), + "rise_score": meta.get("rise_score"), + "vad_max_probability": meta.get("vad_max_probability"), + "vad_average_probability": meta.get("vad_average_probability"), + "detection_profile": meta.get("detection_profile") or "", + "probability_history": meta.get("probability_history") or [], "detected_format": meta.get("detected_format") or {}, "final_format": final_format, "postprocess": meta.get("postprocess") or {}, @@ -1453,6 +1491,15 @@ def _firmware_template_spec(template_key: str) -> Dict[str, Any]: raise ValueError("Unknown firmware template.") +def _firmware_template_flash_size(template_key: Any) -> str: + try: + spec = _firmware_template_spec(_text(template_key)) + except Exception: + spec = {} + flash_size = _text(spec.get("flash_size")).upper() + return flash_size if flash_size in {"4MB", "8MB", "16MB", "32MB"} else "8MB" + + def _firmware_raw_url(path: str) -> str: clean = str(path or "").strip().lstrip("/") return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{clean}" @@ -1713,7 +1760,7 @@ def _create_browser_flash_artifact(template_key: Any, prebuilt: Dict[str, Any], "source_binary": str(binary_path), "binary_size": int(target_binary_path.stat().st_size), "erase_all": True, - "flash_size": "4MB", + "flash_size": _firmware_template_flash_size(template_key), "flash_mode": "dio", "flash_freq": "40m", } @@ -2967,6 +3014,15 @@ async def upload_captured_audio( "average_probability": _parse_float( extra_meta.get("average_probability") if average_probability is None else average_probability ), + "probability_cutoff": _parse_int(extra_meta.get("probability_cutoff")), + "peak_probability_cutoff": _parse_int(extra_meta.get("peak_probability_cutoff")), + "active_window_count": _parse_int(extra_meta.get("active_window_count")), + "min_active_windows": _parse_int(extra_meta.get("min_active_windows")), + "rise_score": _parse_int(extra_meta.get("rise_score")), + "vad_max_probability": _parse_int(extra_meta.get("vad_max_probability")), + "vad_average_probability": _parse_int(extra_meta.get("vad_average_probability")), + "detection_profile": str(extra_meta.get("detection_profile") or "").strip(), + "probability_history": _parse_probability_history(extra_meta.get("probability_history")), "notes": notes or extra_meta.get("notes") or "", "converted": result["converted"], "detected_format": result["detected_format"], @@ -2996,6 +3052,15 @@ async def upload_captured_audio_raw( x_blocked_by_vad: str | None = Header(default=None), x_max_probability: str | None = Header(default=None), x_average_probability: str | None = Header(default=None), + x_probability_cutoff: str | None = Header(default=None), + x_peak_probability_cutoff: str | None = Header(default=None), + x_active_windows: str | None = Header(default=None), + x_min_active_windows: str | None = Header(default=None), + x_rise_score: str | None = Header(default=None), + x_vad_max_probability: str | None = Header(default=None), + x_vad_average_probability: str | None = Header(default=None), + x_detection_profile: str | None = Header(default=None), + x_probability_history: str | None = Header(default=None), x_notes: str | None = Header(default=None), ): raw_data = await request.body() @@ -3031,6 +3096,15 @@ async def upload_captured_audio_raw( "blocked_by_vad": _parse_bool(x_blocked_by_vad), "max_probability": _parse_float(x_max_probability), "average_probability": _parse_float(x_average_probability), + "probability_cutoff": _parse_int(x_probability_cutoff), + "peak_probability_cutoff": _parse_int(x_peak_probability_cutoff), + "active_window_count": _parse_int(x_active_windows), + "min_active_windows": _parse_int(x_min_active_windows), + "rise_score": _parse_int(x_rise_score), + "vad_max_probability": _parse_int(x_vad_max_probability), + "vad_average_probability": _parse_int(x_vad_average_probability), + "detection_profile": (x_detection_profile or "").strip(), + "probability_history": _parse_probability_history(x_probability_history), "notes": x_notes or "", "converted": result["converted"], "detected_format": result["detected_format"],
http://127.0.0.1:8789
3232