4 Commits
v7 ... v9

Author SHA1 Message Date
MasterPhooey
89260f1f14 Add Nvidia Docker release notes 2026-06-27 09:39:47 -05:00
MasterPhooey
0140dfb56f Fix Docker image release tags 2026-06-27 09:36:59 -05:00
MasterPhooey
1fc7d80bae Add RTX 50 Blackwell image support 2026-06-27 07:46:53 -05:00
MasterPhooey
31a6388da4 Improve trainer capture metadata and USB flashing 2026-06-27 06:39:58 -05:00
7 changed files with 910 additions and 27 deletions

View File

@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: write
packages: write packages: write
concurrency: concurrency:
@@ -41,6 +41,8 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=ref,event=tag type=ref,event=tag
@@ -56,3 +58,69 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker cache-from: type=gha,scope=mww-trainer-nvidia-docker
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker
- name: Docker metadata (Blackwell)
id: meta-blackwell
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=blackwell
type=ref,event=tag,suffix=-blackwell
- name: Build and push Blackwell image
uses: docker/build-push-action@v6
with:
context: .
file: dockerfile.blackwell
platforms: linux/amd64
push: true
tags: ${{ steps.meta-blackwell.outputs.tags }}
labels: ${{ steps.meta-blackwell.outputs.labels }}
cache-from: type=gha,scope=mww-trainer-nvidia-docker-blackwell
cache-to: type=gha,mode=max,scope=mww-trainer-nvidia-docker-blackwell
- name: Create release notes
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
title="microWakeWord Nvidia Trainer ${TAG_NAME}"
generated_notes="$(mktemp)"
release_notes="$(mktemp)"
gh api "repos/${REPO}/releases/generate-notes" \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${GITHUB_SHA}" \
--jq '.body' > "${generated_notes}"
{
echo "## Docker Images"
echo
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:latest\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:${TAG_NAME}-blackwell\`"
echo "- \`ghcr.io/tatertotterson/microwakeword:blackwell\`"
echo
cat "${generated_notes}"
} > "${release_notes}"
if gh release view "${TAG_NAME}" >/dev/null 2>&1; then
gh release edit "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
else
gh release create "${TAG_NAME}" \
--title "${title}" \
--notes-file "${release_notes}" \
--latest \
--verify-tag
fi

View File

@@ -25,6 +25,19 @@ Tagged releases also publish matching immutable image tags:
docker pull ghcr.io/tatertotterson/microwakeword:v5 docker pull ghcr.io/tatertotterson/microwakeword:v5
``` ```
RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
Python 3.13 TensorFlow build for `sm_120`:
```bash
docker pull ghcr.io/tatertotterson/microwakeword:blackwell
docker pull ghcr.io/tatertotterson/microwakeword:v5-blackwell
```
Use the Blackwell image only for RTX 50-series cards. It includes the
community-built TensorFlow wheel from
[chivitiH/tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313),
which is unofficial and licensed CC BY-NC 4.0.
--- ---
## Run The Container ## Run The Container
@@ -39,6 +52,9 @@ docker run -d \
``` ```
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v5` when you want to pin a known release instead of tracking `latest`. Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v5` when you want to pin a known release instead of tracking `latest`.
For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v5-blackwell`
in the same `docker run` command.
The flags: The flags:
@@ -153,6 +169,8 @@ Personal samples are optional. Training can run with zero personal samples after
Reviewed negative samples are converted into `/data/work/reviewed_negative_features/` and inserted into the training YAML as a hard-negative feature set when present. Reviewed negative samples are converted into `/data/work/reviewed_negative_features/` and inserted into the training YAML as a hard-negative feature set when present.
On RTX 50-series / Blackwell GPUs, the Blackwell Docker image keeps sample generation and augmentation in the normal Python 3.12 trainer environment, then runs only the TensorFlow training/export stage in `/data/.venv-blackwell` with Python 3.13 and the Blackwell-native TensorFlow wheel.
--- ---
## Language Support ## Language Support
@@ -253,3 +271,4 @@ Built on top of:
- [microWakeWord](https://github.com/kahrendt/microWakeWord) - [microWakeWord](https://github.com/kahrendt/microWakeWord)
- [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator) - [piper-sample-generator](https://github.com/rhasspy/piper-sample-generator)
- [tensorflow-blackwell-python313](https://github.com/chivitiH/tensorflow-blackwell-python313) for the optional RTX 50-series / Blackwell image

112
cli/setup_blackwell_venv Executable file
View File

@@ -0,0 +1,112 @@
#!/bin/bash
set -euo pipefail
PROGDIR="$(dirname "$(realpath "$0")")"
ROOTDIR="$(dirname "${PROGDIR}")"
KNOWN_ARGS=( data-dir force python )
source "${PROGDIR}/shell.functions"
if [ ${#UNKNOWN_ARGS[@]} -gt 0 ] ; then
echo "Unknown argument(s): ${UNKNOWN_ARGS[*]}" >&2
HELP=true
fi
if [ "${HELP}" == "true" ] ; then
cat <<EOF >&2
Usage: setup_blackwell_venv [ --data-dir=/data ] [ --force ] [ --python=python3.13 ]
Creates /data/.venv-blackwell for RTX 50 / Blackwell TensorFlow training.
Sample generation and augmentation continue to use /data/.venv.
Environment overrides:
MWW_BLACKWELL_TF_WHEEL_URL: TensorFlow Blackwell wheel URL.
EOF
exit 1
fi
[ -n "${DATA_DIR}" ] && DATA_DIR="$(realpath "${DATA_DIR}")"
[ -d "${DATA_DIR}" ] || {
echo "Data directory '${DATA_DIR}' doesn't exist." >&2
exit 1
}
PYTHON="${PYTHON:-python3.13}"
VENV="${DATA_DIR}/.venv-blackwell"
MARKER="${VENV}/.mww-blackwell-venv"
TF_WHEEL_URL="${MWW_BLACKWELL_TF_WHEEL_URL:-https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl}"
if ! command -v "${PYTHON}" >/dev/null 2>&1 ; then
echo "Python 3.13 is required for the Blackwell TensorFlow wheel. Missing: ${PYTHON}" >&2
exit 1
fi
if [ "${FORCE:-false}" != "true" ] && [ -x "${VENV}/bin/python" ] && [ -f "${MARKER}" ] ; then
echo " Blackwell TensorFlow venv found (skipping setup_blackwell_venv)"
exit 0
fi
echo "===== Setting up Blackwell TensorFlow environment ${VENV} ====="
rm -rf "${VENV}" || :
"${PYTHON}" -m venv --upgrade-deps "${VENV}"
source "${VENV}/bin/activate"
export PIP_PROGRESS_BAR=off
export PIP_NO_COLOR=1
export PIP_QUIET=0
pip_install() {
if $VERBOSE ; then
pip install "$@" || return 1
else
{ pip install "$@" || return 1 ; } | stdbuf -i0 -o0 tr -d '[:print:]' | stdbuf -i0 -o0 tr '\n' '.'
fi
echo
}
echo " ===== Installing Blackwell TensorFlow wheel ====="
pip_install --upgrade pip setuptools wheel
pip_install "${TF_WHEEL_URL}"
echo " ===== Installing microWakeWord training dependencies ====="
pip_install \
audiomentations \
audio_metadata \
datasets \
mmap_ninja \
pymicro-features \
pyyaml \
webrtcvad-wheels \
ai-edge-litert \
numpy-minmax \
numpy-rms \
absl-py \
"numpy==2.3.5"
echo " ===== Checking microwakeword ====="
MWW="${DATA_DIR}/tools/microWakeWord"
if [ ! -d "${MWW}" ] || [ -n "$(git -C "${MWW}" status --porcelain 2>/dev/null || true)" ] ; then
rm -rf "${MWW}" || :
mkdir -p "${DATA_DIR}/tools"
echo " Cloning micro-wake-word to ${DATA_DIR}/tools"
git clone https://github.com/TaterTotterson/micro-wake-word "${MWW}" &>/dev/null
fi
echo " Installing microwakeword into Blackwell venv"
pip_install --no-deps -e "${MWW}"
echo " ===== Testing Blackwell TensorFlow environment ====="
"${VENV}/bin/python" - <<'PY'
import tensorflow as tf
from ai_edge_litert.interpreter import Interpreter
from microwakeword.data import FeatureHandler
from microwakeword.inference import Model
print("TensorFlow:", tf.__version__)
print("CUDA build:", tf.test.is_built_with_cuda())
print("GPU:", tf.config.list_physical_devices("GPU"))
print("microWakeWord Blackwell imports available")
PY
touch "${MARKER}"
echo "Blackwell TensorFlow environment ready: ${VENV}"

View File

@@ -84,6 +84,21 @@ if [ "${IS_BLACKWELL}" = "true" ]; then
echo " Using GPU compatibility retries; CPU fallback is ${ALLOW_CPU_FALLBACK} (override with MWW_ALLOW_CPU_FALLBACK=true|false)." echo " Using GPU compatibility retries; CPU fallback is ${ALLOW_CPU_FALLBACK} (override with MWW_ALLOW_CPU_FALLBACK=true|false)."
fi fi
BLACKWELL_TF_MODE="${MWW_BLACKWELL_TF:-auto}"
BLACKWELL_TF_REQUIRED="false"
BLACKWELL_TF_ACTIVE="false"
case "${BLACKWELL_TF_MODE,,}" in
1|true|yes|on|required)
BLACKWELL_TF_REQUIRED="true"
;;
0|false|no|off|disabled)
BLACKWELL_TF_MODE="disabled"
;;
*)
BLACKWELL_TF_MODE="auto"
;;
esac
# Enable driver-side PTX JIT fallback when ptxas/nvlink are unavailable. # Enable driver-side PTX JIT fallback when ptxas/nvlink are unavailable.
if [ -z "${XLA_FLAGS:-}" ]; then if [ -z "${XLA_FLAGS:-}" ]; then
export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found" export XLA_FLAGS="--xla_gpu_unsafe_fallback_to_driver_on_ptxas_not_found"
@@ -238,6 +253,32 @@ fi
echo " Wrote training_parameters.yaml" echo " Wrote training_parameters.yaml"
rm -rf "${WORK_DIR}/trained_models/wakeword" rm -rf "${WORK_DIR}/trained_models/wakeword"
if [ "${IS_BLACKWELL}" = "true" ] && [ "${BLACKWELL_TF_MODE}" != "disabled" ]; then
BLACKWELL_SETUP="${PROGDIR}/setup_blackwell_venv"
BLACKWELL_PYTHON="${DATA_DIR}/.venv-blackwell/bin/python"
if [ -x "${BLACKWELL_SETUP}" ] && command -v python3.13 >/dev/null 2>&1; then
echo "↪️ Preparing Blackwell-native TensorFlow training environment."
if "${BLACKWELL_SETUP}" --data-dir="${DATA_DIR}"; then
PYTHON_BIN="${BLACKWELL_PYTHON}"
BLACKWELL_TF_ACTIVE="true"
echo "✅ Blackwell TensorFlow training enabled: ${PYTHON_BIN}"
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow setup failed and MWW_BLACKWELL_TF is required." >&2
exit 1
fi
echo "⚠️ Blackwell TensorFlow setup failed; continuing with compatibility retries."
fi
else
if [ "${BLACKWELL_TF_REQUIRED}" = "true" ]; then
echo "❌ Blackwell TensorFlow was required, but python3.13/setup_blackwell_venv is unavailable." >&2
exit 1
fi
echo " Blackwell TensorFlow image support not available; continuing with compatibility retries."
fi
fi
wake_word_filename="$( wake_word_filename="$(
echo "${WAKE_WORD}" \ echo "${WAKE_WORD}" \
| tr '[:upper:]' '[:lower:]' \ | tr '[:upper:]' '[:lower:]' \

54
dockerfile.blackwell Normal file
View File

@@ -0,0 +1,54 @@
# RTX 50 / Blackwell image
FROM nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV CUDA_HOME=/usr/local/cuda
ENV PATH=/usr/local/cuda/bin:${PATH}
ENV LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH}
ENV MWW_BLACKWELL_IMAGE=1
ENV MWW_BLACKWELL_TF=auto
ENV MWW_BLACKWELL_TF_WHEEL_URL=https://github.com/chivitiH/tensorflow-blackwell-python313/releases/download/v2.22.0-selfbuilt/tensorflow-2.22.0.dev0+selfbuilt-cp313-cp313-linux_x86_64.whl
# System deps. Python 3.12 remains the main trainer/runtime venv, while
# Python 3.13 is used only for the Blackwell TensorFlow training step.
RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common ca-certificates curl git wget unzip patch \
ninja-build nano less \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
python3.12 python3.12-venv python3.12-dev \
python3.13 python3.13-venv python3.13-dev \
python3-pip python-is-python3 \
&& ldconfig \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data
# Trainer UI port
EXPOSE 8789
# Script root
WORKDIR /root/mww-scripts
# Bash environment
COPY --chown=root:root --chmod=0755 .bashrc /root/
# Root-level entrypoints
COPY --chown=root:root --chmod=0755 \
train_wake_word \
run.sh \
trainer_server.py \
requirements.txt \
/root/mww-scripts/
# CLI folder
COPY --chown=root:root cli/ /root/mww-scripts/cli/
# Make all CLI scripts executable (avoids "Permission denied")
RUN chmod -R a+x /root/mww-scripts/cli
# Static UI for trainer
COPY --chown=root:root --chmod=0644 static/index.html /root/mww-scripts/static/index.html
# trainer server
CMD ["/bin/bash", "-lc", "/root/mww-scripts/run.sh"]

View File

@@ -509,6 +509,34 @@
max-width: 420px; 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 { .studioHero {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -1186,6 +1214,7 @@
.firmwareGrid { grid-template-columns: 1fr; } .firmwareGrid { grid-template-columns: 1fr; }
.firmwareLayout, .firmwareLayout,
.firmwareTargetGrid, .firmwareTargetGrid,
.firmwareUsbWifiGrid,
.firmwareActionsPanel { .firmwareActionsPanel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1591,7 +1620,8 @@
<label class="field"> <label class="field">
<strong>Detected Device</strong> <strong>Detected Device</strong>
<select id="firmwareDeviceSelect"> <select id="firmwareDeviceSelect">
<option value="">No devices scanned yet</option> <option value="">Choose target...</option>
<option value="__browser_usb_flash__">Browser USB Flash (new/recovery)</option>
</select> </select>
</label> </label>
<label class="field"> <label class="field">
@@ -1623,6 +1653,22 @@
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button> <button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button> <button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
</div> </div>
<section class="firmwareUsbWifiPanel" aria-label="Browser USB Wi-Fi setup">
<label class="firmwareUsbWifiToggle">
<input id="firmwareUsbWifiEnabled" type="checkbox" checked />
<span>Set up Wi-Fi after Browser USB Flash</span>
</label>
<div class="firmwareUsbWifiGrid">
<label class="field">
<strong>Wi-Fi SSID</strong>
<input id="firmwareUsbSsid" type="text" placeholder="Network name" autocomplete="off" />
</label>
<label class="field">
<strong>Wi-Fi Password</strong>
<input id="firmwareUsbPassword" type="password" placeholder="Network password" autocomplete="current-password" />
</label>
</div>
</section>
<div class="usbFlashHint">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 <code>http://127.0.0.1:8789</code> in Chrome or Edge; port <code>3232</code> is only for OTA updates after firmware is installed.</div> <div class="usbFlashHint">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 <code>http://127.0.0.1:8789</code> in Chrome or Edge; port <code>3232</code> is only for OTA updates after firmware is installed.</div>
</section> </section>
</div> </div>
@@ -2031,7 +2077,7 @@
} }
function escapeHtml(text) { function escapeHtml(text) {
return String(text || "") return String(text ?? "")
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;"); .replaceAll(">", "&gt;");
@@ -2056,6 +2102,12 @@
return { label: item.capture_label || "Captured", cls: "" }; return { label: item.capture_label || "Captured", cls: "" };
} }
function formatDetectionProfile(value) {
return String(value || "")
.replaceAll("_", " ")
.replace(/\b\w/g, (ch) => ch.toUpperCase());
}
function renderCapturedItems(payload) { function renderCapturedItems(payload) {
const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 }; const data = payload || { items: [], captured_count: 0, negative_count: 0, personal_count: 0 };
uiState.captured = data; uiState.captured = data;
@@ -2084,6 +2136,18 @@
if (item.wake_word) meta.push(`<span class="pill">${escapeHtml(item.wake_word)}</span>`); if (item.wake_word) meta.push(`<span class="pill">${escapeHtml(item.wake_word)}</span>`);
if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`); if (item.max_probability !== null && item.max_probability !== undefined) meta.push(`<span class="pill">max ${escapeHtml(item.max_probability)}</span>`);
if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`); if (item.average_probability !== null && item.average_probability !== undefined) meta.push(`<span class="pill">avg ${escapeHtml(item.average_probability)}</span>`);
if (item.detection_profile) meta.push(`<span class="pill">profile ${escapeHtml(formatDetectionProfile(item.detection_profile))}</span>`);
if (item.peak_probability_cutoff !== null && item.peak_probability_cutoff !== undefined) meta.push(`<span class="pill">peak cutoff ${escapeHtml(item.peak_probability_cutoff)}</span>`);
if (item.probability_cutoff !== null && item.probability_cutoff !== undefined) meta.push(`<span class="pill">avg cutoff ${escapeHtml(item.probability_cutoff)}</span>`);
if (item.active_window_count !== null && item.active_window_count !== undefined && item.min_active_windows !== null && item.min_active_windows !== undefined) {
meta.push(`<span class="pill">windows ${escapeHtml(item.active_window_count)}/${escapeHtml(item.min_active_windows)}</span>`);
}
if (item.rise_score !== null && item.rise_score !== undefined) meta.push(`<span class="pill">rise ${escapeHtml(item.rise_score)}</span>`);
if (item.vad_max_probability !== null && item.vad_max_probability !== undefined) meta.push(`<span class="pill">VAD max ${escapeHtml(item.vad_max_probability)}</span>`);
if (item.vad_average_probability !== null && item.vad_average_probability !== undefined) meta.push(`<span class="pill">VAD avg ${escapeHtml(item.vad_average_probability)}</span>`);
if (Array.isArray(item.probability_history) && item.probability_history.length) {
meta.push(`<span class="pill" title="${escapeAttr(item.probability_history.join(", "))}">history ${escapeHtml(item.probability_history.length)}</span>`);
}
const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit"; const formatSummary = item.final_format ? describeFormat(item.final_format) : "16 kHz, mono, 16-bit";
const when = formatTimestamp(item.captured_at || item.received_at); const when = formatTimestamp(item.captured_at || item.received_at);
const actionDisabled = uiState.reviewBusy ? "disabled" : ""; const actionDisabled = uiState.reviewBusy ? "disabled" : "";
@@ -2449,6 +2513,18 @@
return port; 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) { function normalizeEsptoolJsModule(module) {
if (module?.ESPLoader && module?.Transport) { if (module?.ESPLoader && module?.Transport) {
return module; return module;
@@ -2507,19 +2583,15 @@
return chunks.join(""); return chunks.join("");
} }
async function browserUsbSetSignals(port, signals, timeoutMs = 1200) { async function browserUsbWithTimeout(promise, timeoutMs, message) {
if (!port || typeof port.setSignals !== "function") {
return false;
}
let timeoutId = 0; let timeoutId = 0;
try { try {
await Promise.race([ return await Promise.race([
port.setSignals(signals), promise,
new Promise((_resolve, reject) => { 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 { } finally {
if (timeoutId) { if (timeoutId) {
clearTimeout(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) { async function browserUsbHardResetAfterFlash(transport, loader, port) {
try { try {
appendFirmwareLog("Resetting device after USB flash."); 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) { async function flashBrowserUsbPort(port, artifact) {
const module = await ensureEsptoolJsLoaded(); const module = await ensureEsptoolJsLoaded();
const { ESPLoader, Transport } = module; 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() { function closeConsole() {
$("consoleOverlay").classList.remove("open"); $("consoleOverlay").classList.remove("open");
$("consoleOverlay").setAttribute("aria-hidden", "true"); $("consoleOverlay").setAttribute("aria-hidden", "true");
@@ -2648,26 +3106,46 @@
: "No files selected"; : "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) { function renderFirmwareDevices(devices, message) {
const list = Array.isArray(devices) ? devices : []; const list = Array.isArray(devices) ? devices : [];
uiState.firmware.devices = list; uiState.firmware.devices = list;
if (!list.length) {
$("firmwareDeviceSelect").innerHTML = `<option value="">No devices detected</option>`;
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
return;
}
$("firmwareDeviceSelect").innerHTML = [ $("firmwareDeviceSelect").innerHTML = [
`<option value="">Choose detected device...</option>`, `<option value="">Choose target...</option>`,
`<option value="${FIRMWARE_USB_DEVICE_VALUE}">Browser USB Flash (new/recovery)</option>`,
...list.map((device, index) => { ...list.map((device, index) => {
const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`; const label = `${device.name || device.host} (${device.host}:${device.port || 3232})`;
return `<option value="${index}">${escapeHtml(label)}</option>`; return `<option value="${index}">${escapeHtml(label)}</option>`;
}), }),
].join(""); ].join("");
setPill($("firmwareDetectStatus"), `${list.length} detected`, "ok"); if (!list.length) {
setPill($("firmwareDetectStatus"), message || "No devices detected", "warn");
if (!($("firmwareHost").value || "").trim()) { 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() && !firmwareUsbSelected()) {
$("firmwareDeviceSelect").value = "0"; $("firmwareDeviceSelect").value = "0";
applySelectedFirmwareDevice().catch((error) => { applySelectedFirmwareDevice().catch((error) => {
setPill($("firmwareStatus"), "Device settings failed", "warn"); setPill($("firmwareStatus"), "Device settings failed", "warn");
@@ -2687,6 +3165,14 @@
async function applySelectedFirmwareDevice() { async function applySelectedFirmwareDevice() {
const indexText = $("firmwareDeviceSelect").value; const indexText = $("firmwareDeviceSelect").value;
if (indexText === "") return; 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)]; const device = uiState.firmware.devices[Number(indexText)];
if (!device) return; if (!device) return;
await flushFirmwareProfileSave(); await flushFirmwareProfileSave();
@@ -2711,6 +3197,7 @@
function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) { function applyFirmwareTemplateTarget(template = selectedFirmwareTemplate()) {
if (!template) return; if (!template) return;
if (firmwareUsbSelected()) return;
if (template.target_host) { if (template.target_host) {
$("firmwareHost").value = template.target_host; $("firmwareHost").value = template.target_host;
} }
@@ -3023,6 +3510,9 @@
function firmwareTemplateQuery() { function firmwareTemplateQuery() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (firmwareUsbSelected()) {
return "";
}
const host = ($("firmwareHost").value || "").trim(); const host = ($("firmwareHost").value || "").trim();
const port = ($("firmwarePort").value || "3232").trim(); const port = ($("firmwarePort").value || "3232").trim();
if (host) params.set("target_host", host); 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."); 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; 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; if (!ok) return;
let port; let port;
try { try {
port = await browserUsbSelectPort(template.value); port = browserUsbStoredPort(template.value) || await browserUsbSelectPort(template.value);
} catch (error) { } catch (error) {
alert("USB device selection failed: " + error.message); alert("USB device selection failed: " + error.message);
return; return;
@@ -3233,8 +3731,16 @@
}); });
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash..."); appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
await flashBrowserUsbPort(port, artifact); await flashBrowserUsbPort(port, artifact);
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."); appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
setPill($("firmwareStatus"), "USB flash finished", "ok"); setPill($("firmwareStatus"), "USB flash finished", "ok");
}
} catch (error) { } catch (error) {
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed."); appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
setPill($("firmwareStatus"), "USB flash failed", "err"); setPill($("firmwareStatus"), "USB flash failed", "err");
@@ -3320,6 +3826,11 @@
const firmwareTemplate = ($("firmwareTemplate").value || "").trim(); const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
const firmwareSelection = selectedFirmwareTemplate(); const firmwareSelection = selectedFirmwareTemplate();
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available); 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; $("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy; $("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
@@ -3331,11 +3842,11 @@
$("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0; $("clearNegativeBtn").disabled = uiState.reviewBusy || negativeCount === 0;
$("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy; $("refreshSamplesBtn").disabled = uiState.reviewBusy || uiState.uploadBusy;
$("refreshFirmwareBtn").disabled = uiState.firmwareBusy; $("refreshFirmwareBtn").disabled = uiState.firmwareBusy;
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate; $("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate;
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy; $("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
$("openFirmwareConsoleBtn").disabled = false; $("openFirmwareConsoleBtn").disabled = false;
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate || !firmwareAvailable; $("flashFirmwareBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path; $("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path || !usbWifiReady;
} }
function refreshSessionUI(session) { function refreshSessionUI(session) {
@@ -3695,6 +4206,10 @@
alert("Device settings failed: " + error.message); alert("Device settings failed: " + error.message);
}); });
}); });
["firmwareUsbWifiEnabled", "firmwareUsbSsid", "firmwareUsbPassword"].forEach((id) => {
$(id).addEventListener("input", syncButtons);
$(id).addEventListener("change", syncButtons);
});
$("refreshFirmwareBtn").addEventListener("click", async () => { $("refreshFirmwareBtn").addEventListener("click", async () => {
try { try {
await refreshFirmwareDevices(); await refreshFirmwareDevices();

View File

@@ -108,26 +108,31 @@ FIRMWARE_TEMPLATE_SPECS = (
"key": "voicepe", "key": "voicepe",
"label": "VoicePE", "label": "VoicePE",
"description": "VoicePE satellite prebuilt firmware", "description": "VoicePE satellite prebuilt firmware",
"flash_size": "16MB",
}, },
{ {
"key": "satellite1", "key": "satellite1",
"label": "Sat1", "label": "Sat1",
"description": "Satellite1 prebuilt firmware", "description": "Satellite1 prebuilt firmware",
"flash_size": "16MB",
}, },
{ {
"key": "respeaker_lite", "key": "respeaker_lite",
"label": "ReSpeaker Lite", "label": "ReSpeaker Lite",
"description": "ReSpeaker Lite prebuilt firmware", "description": "ReSpeaker Lite prebuilt firmware",
"flash_size": "8MB",
}, },
{ {
"key": "koala", "key": "koala",
"label": "Koala Satellite", "label": "Koala Satellite",
"description": "Koala satellite prebuilt firmware", "description": "Koala satellite prebuilt firmware",
"flash_size": "16MB",
}, },
{ {
"key": "respeaker_xvf3800", "key": "respeaker_xvf3800",
"label": "ReSpeaker XVF3800", "label": "ReSpeaker XVF3800",
"description": "ReSpeaker XVF3800 prebuilt firmware", "description": "ReSpeaker XVF3800 prebuilt firmware",
"flash_size": "8MB",
}, },
) )
FIRMWARE_PREBUILT_LATEST_URL = ( FIRMWARE_PREBUILT_LATEST_URL = (
@@ -684,6 +689,30 @@ def _parse_float(value: Any) -> float | None:
return 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: def _audio_sidecar_path(audio_path: Path) -> Path:
return audio_path.with_suffix(".json") 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")), "blocked_by_vad": bool(meta.get("blocked_by_vad")),
"max_probability": meta.get("max_probability"), "max_probability": meta.get("max_probability"),
"average_probability": meta.get("average_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 {}, "detected_format": meta.get("detected_format") or {},
"final_format": final_format, "final_format": final_format,
"postprocess": meta.get("postprocess") or {}, "postprocess": meta.get("postprocess") or {},
@@ -1453,6 +1491,15 @@ def _firmware_template_spec(template_key: str) -> Dict[str, Any]:
raise ValueError("Unknown firmware template.") 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: def _firmware_raw_url(path: str) -> str:
clean = str(path or "").strip().lstrip("/") clean = str(path or "").strip().lstrip("/")
return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{clean}" 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), "source_binary": str(binary_path),
"binary_size": int(target_binary_path.stat().st_size), "binary_size": int(target_binary_path.stat().st_size),
"erase_all": True, "erase_all": True,
"flash_size": "4MB", "flash_size": _firmware_template_flash_size(template_key),
"flash_mode": "dio", "flash_mode": "dio",
"flash_freq": "40m", "flash_freq": "40m",
} }
@@ -2967,6 +3014,15 @@ async def upload_captured_audio(
"average_probability": _parse_float( "average_probability": _parse_float(
extra_meta.get("average_probability") if average_probability is None else average_probability 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 "", "notes": notes or extra_meta.get("notes") or "",
"converted": result["converted"], "converted": result["converted"],
"detected_format": result["detected_format"], "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_blocked_by_vad: str | None = Header(default=None),
x_max_probability: str | None = Header(default=None), x_max_probability: str | None = Header(default=None),
x_average_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), x_notes: str | None = Header(default=None),
): ):
raw_data = await request.body() raw_data = await request.body()
@@ -3031,6 +3096,15 @@ async def upload_captured_audio_raw(
"blocked_by_vad": _parse_bool(x_blocked_by_vad), "blocked_by_vad": _parse_bool(x_blocked_by_vad),
"max_probability": _parse_float(x_max_probability), "max_probability": _parse_float(x_max_probability),
"average_probability": _parse_float(x_average_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 "", "notes": x_notes or "",
"converted": result["converted"], "converted": result["converted"],
"detected_format": result["detected_format"], "detected_format": result["detected_format"],