mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 07:55:33 -06:00
Merge pull request #52 from TaterTotterson/prebuilt-firmware-tagged-docker
Update prebuilt firmware flasher and tagged Docker releases
This commit is contained in:
18
.github/workflows/docker-publish.yml
vendored
18
.github/workflows/docker-publish.yml
vendored
@@ -1,9 +1,9 @@
|
|||||||
name: Publish Docker Image
|
name: Publish Docker Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -36,6 +36,15 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=ref,event=tag
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -43,6 +52,7 @@ jobs:
|
|||||||
file: dockerfile
|
file: dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
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
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -7,7 +7,7 @@
|
|||||||
<a href="https://taterassistant.com">taterassistant.com</a>
|
<a href="https://taterassistant.com">taterassistant.com</a>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, device-captured samples, reviewed false-wake negatives, live training logs, and ESPHome firmware flashing.
|
Train custom microWakeWord models in Docker with NVIDIA/CUDA acceleration, generated Piper samples, device-captured samples, reviewed false-wake negatives, live training logs, and prebuilt Tater firmware flashing.
|
||||||
|
|
||||||
Real samples come from device-captured wake audio, close misses, or manual uploads. Every saved sample is normalized to `16 kHz / mono / 16-bit PCM WAV` before training.
|
Real samples come from device-captured wake audio, close misses, or manual uploads. Every saved sample is normalized to `16 kHz / mono / 16-bit PCM WAV` before training.
|
||||||
|
|
||||||
@@ -19,6 +19,12 @@ Real samples come from device-captured wake audio, close misses, or manual uploa
|
|||||||
docker pull ghcr.io/tatertotterson/microwakeword:latest
|
docker pull ghcr.io/tatertotterson/microwakeword:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tagged releases also publish matching immutable image tags:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/tatertotterson/microwakeword:v5
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run The Container
|
## Run The Container
|
||||||
@@ -32,6 +38,8 @@ docker run -d \
|
|||||||
ghcr.io/tatertotterson/microwakeword:latest
|
ghcr.io/tatertotterson/microwakeword:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v5` when you want to pin a known release instead of tracking `latest`.
|
||||||
|
|
||||||
The flags:
|
The flags:
|
||||||
|
|
||||||
- `--gpus all` enables GPU acceleration.
|
- `--gpus all` enables GPU acceleration.
|
||||||
@@ -56,14 +64,14 @@ If you change `REC_PORT`, open that port instead and use the same port in the ES
|
|||||||
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
|
- `Trainer` starts a wake-word session, shows positive/negative sample counts, and launches training.
|
||||||
- `Captured Audio` reviews clips sent by ESPHome sats, including wake hits, close misses, and false wakes.
|
- `Captured Audio` reviews clips sent by ESPHome sats, including wake hits, close misses, and false wakes.
|
||||||
- `Samples` plays, removes, clears, and manually imports personal or negative samples.
|
- `Samples` plays, removes, clears, and manually imports personal or negative samples.
|
||||||
- `Firmware` builds the latest `microWakeWords` ESPHome YAMLs from GitHub and flashes VoicePE or Satellite1 over OTA.
|
- `Firmware` pulls verified prebuilt Tater firmware images from GitHub and flashes supported satellites over OTA.
|
||||||
- Popup consoles show colorized training and firmware logs while long-running jobs are active.
|
- Popup consoles show colorized training and firmware logs while long-running jobs are active.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Captured Audio Workflow
|
## Captured Audio Workflow
|
||||||
|
|
||||||
To collect samples from a sat, flash it with the Tater firmware from [TaterTotterson/microWakeWords](https://github.com/TaterTotterson/microWakeWords). The `Firmware` tab can build and flash the VoicePE or Satellite1 YAMLs directly from that repo.
|
To collect samples from a sat, flash it with the Tater firmware from [TaterTotterson/microWakeWords](https://github.com/TaterTotterson/microWakeWords). The `Firmware` tab can pull verified prebuilt OTA images from that repo for fast firmware updates.
|
||||||
|
|
||||||
After flashing, the device exposes ESPHome entities for capture setup:
|
After flashing, the device exposes ESPHome entities for capture setup:
|
||||||
|
|
||||||
@@ -177,18 +185,17 @@ After those assets are prepared, later runs reuse the local copies unless the mo
|
|||||||
|
|
||||||
## Firmware Flashing
|
## Firmware Flashing
|
||||||
|
|
||||||
The `Firmware` tab builds and flashes Tater firmware for supported ESPHome sats.
|
The `Firmware` tab flashes prebuilt Tater firmware for supported ESPHome satellites.
|
||||||
|
|
||||||
- Downloads the latest firmware YAML templates from `TaterTotterson/microWakeWords` on GitHub.
|
- Downloads the latest prebuilt firmware manifest and OTA image from `TaterTotterson/microWakeWords`.
|
||||||
- Lets you choose `VoicePE` or `Satellite1`.
|
- Verifies downloaded images by size and SHA before upload.
|
||||||
- Auto-detects ESPHome devices with mDNS when the container is running with host networking.
|
- 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.
|
- Allows manual IP or hostname entry if discovery does not find the device.
|
||||||
- Saves firmware form values so you do not re-enter sounds and URLs every run.
|
- Saves the selected OTA target for each firmware family.
|
||||||
- Lists locally trained wake words from `/data/trained_wake_words/` for easy model selection.
|
- Lists locally trained wake words from `/data/trained_wake_words/` for live model switching.
|
||||||
- Builds with ESPHome and flashes OTA.
|
- Streams download, verification, and OTA upload progress in a colorized firmware console.
|
||||||
- Streams ESPHome output in a colorized firmware console.
|
|
||||||
|
|
||||||
Firmware YAMLs are intentionally pulled from GitHub each time. There is no local fallback path in the trainer UI.
|
You usually only flash for firmware updates. New satellites, or devices older than Tater firmware `3.0.3`, need one USB flash first before OTA updates and live wake-word switching are available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,7 +232,7 @@ That removes:
|
|||||||
- cached datasets
|
- cached datasets
|
||||||
- training environments
|
- training environments
|
||||||
- trained models
|
- trained models
|
||||||
- firmware build caches
|
- downloaded firmware images
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
11
run.sh
11
run.sh
@@ -17,7 +17,6 @@ PIN_FILE="${VENV_DIR}/.pinned_installed"
|
|||||||
FASTAPI_VERSION="${REC_FASTAPI_VERSION:-0.115.6}"
|
FASTAPI_VERSION="${REC_FASTAPI_VERSION:-0.115.6}"
|
||||||
UVICORN_VERSION="${REC_UVICORN_VERSION:-0.30.6}"
|
UVICORN_VERSION="${REC_UVICORN_VERSION:-0.30.6}"
|
||||||
PY_MULTIPART_VERSION="${REC_PY_MULTIPART_VERSION:-0.0.9}"
|
PY_MULTIPART_VERSION="${REC_PY_MULTIPART_VERSION:-0.0.9}"
|
||||||
ESPHOME_VERSION="${REC_ESPHOME_VERSION:-2026.5.1}"
|
|
||||||
|
|
||||||
echo "microWakeWord Trainer UI (Docker)"
|
echo "microWakeWord Trainer UI (Docker)"
|
||||||
echo "-> ROOTDIR: ${ROOTDIR}"
|
echo "-> ROOTDIR: ${ROOTDIR}"
|
||||||
@@ -31,7 +30,7 @@ install_ui_deps() {
|
|||||||
"fastapi==${FASTAPI_VERSION}" \
|
"fastapi==${FASTAPI_VERSION}" \
|
||||||
"uvicorn[standard]==${UVICORN_VERSION}" \
|
"uvicorn[standard]==${UVICORN_VERSION}" \
|
||||||
"python-multipart==${PY_MULTIPART_VERSION}" \
|
"python-multipart==${PY_MULTIPART_VERSION}" \
|
||||||
"esphome==${ESPHOME_VERSION}" \
|
"zeroconf>=0.132.2" \
|
||||||
"silero-vad>=5.0.0" \
|
"silero-vad>=5.0.0" \
|
||||||
"numpy>=1.24.0"
|
"numpy>=1.24.0"
|
||||||
}
|
}
|
||||||
@@ -54,11 +53,11 @@ if [[ ! -f "${PIN_FILE}" ]]; then
|
|||||||
touch "${PIN_FILE}"
|
touch "${PIN_FILE}"
|
||||||
else
|
else
|
||||||
echo "Reusing existing trainer UI venv (no upgrades)"
|
echo "Reusing existing trainer UI venv (no upgrades)"
|
||||||
if ! "${PY}" - "${FASTAPI_VERSION}" "${UVICORN_VERSION}" "${PY_MULTIPART_VERSION}" "${ESPHOME_VERSION}" <<'PY' >/dev/null 2>&1
|
if ! "${PY}" - "${FASTAPI_VERSION}" "${UVICORN_VERSION}" "${PY_MULTIPART_VERSION}" <<'PY' >/dev/null 2>&1
|
||||||
import importlib.metadata as md
|
import importlib.metadata as md
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
fastapi_version, uvicorn_version, multipart_version, esphome_version = sys.argv[1:5]
|
fastapi_version, uvicorn_version, multipart_version = sys.argv[1:4]
|
||||||
|
|
||||||
def version_tuple(value):
|
def version_tuple(value):
|
||||||
parts = []
|
parts = []
|
||||||
@@ -76,13 +75,13 @@ exact = {
|
|||||||
"fastapi": fastapi_version,
|
"fastapi": fastapi_version,
|
||||||
"uvicorn": uvicorn_version,
|
"uvicorn": uvicorn_version,
|
||||||
"python-multipart": multipart_version,
|
"python-multipart": multipart_version,
|
||||||
"esphome": esphome_version,
|
|
||||||
}
|
}
|
||||||
minimum = {
|
minimum = {
|
||||||
"silero-vad": "5.0.0",
|
"silero-vad": "5.0.0",
|
||||||
"numpy": "1.24.0",
|
"numpy": "1.24.0",
|
||||||
|
"zeroconf": "0.132.2",
|
||||||
}
|
}
|
||||||
present = ("torch", "zeroconf")
|
present = ("torch",)
|
||||||
|
|
||||||
for package, expected in exact.items():
|
for package, expected in exact.items():
|
||||||
if md.version(package) != expected:
|
if md.version(package) != expected:
|
||||||
|
|||||||
@@ -1519,13 +1519,13 @@
|
|||||||
<div class="row space">
|
<div class="row space">
|
||||||
<div>
|
<div>
|
||||||
<div class="firmwareKicker">Firmware Studio</div>
|
<div class="firmwareKicker">Firmware Studio</div>
|
||||||
<h3>ESPHome Firmware Flasher</h3>
|
<h3>Prebuilt Tater Firmware Flasher</h3>
|
||||||
<p>Build a <code>microWakeWords</code> ESPHome firmware template and flash it to a sat over OTA. Auto-detect is best effort; if the device does not show up, enter its IP or hostname manually.</p>
|
<p>Flash only for firmware updates. If this is a new satellite or it is not already on Tater firmware <code>3.0.3</code> or newer, do one USB flash first, then use this tab for fast OTA updates.</p>
|
||||||
<div class="firmwareSteps" aria-label="Firmware flashing steps">
|
<div class="firmwareSteps" aria-label="Firmware flashing steps">
|
||||||
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
|
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
|
||||||
<span class="firmwareStepChip"><b>2</b> Select target</span>
|
<span class="firmwareStepChip"><b>2</b> Select target</span>
|
||||||
<span class="firmwareStepChip"><b>3</b> Review settings</span>
|
<span class="firmwareStepChip"><b>3</b> Verify image</span>
|
||||||
<span class="firmwareStepChip"><b>4</b> Build + flash</span>
|
<span class="firmwareStepChip"><b>4</b> Flash OTA</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span id="firmwareStatus" class="pill">Flasher idle</span>
|
<span id="firmwareStatus" class="pill">Flasher idle</span>
|
||||||
@@ -1555,15 +1555,15 @@
|
|||||||
<div class="firmwarePanelTitle">
|
<div class="firmwarePanelTitle">
|
||||||
<span class="firmwareStepBadge">1</span>
|
<span class="firmwareStepBadge">1</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Firmware YAML</h3>
|
<h3>Firmware Image</h3>
|
||||||
<p>Choose a Tater firmware YAML to build from the shared firmware repo.</p>
|
<p>Choose a prebuilt Tater firmware image from the shared firmware repo.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<strong>YAML File</strong>
|
<strong>Firmware Family</strong>
|
||||||
<select id="firmwareTemplate">
|
<select id="firmwareTemplate">
|
||||||
<option value="">Loading templates...</option>
|
<option value="">Loading firmware images...</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
@@ -1606,14 +1606,14 @@
|
|||||||
<div class="firmwarePanelTitle">
|
<div class="firmwarePanelTitle">
|
||||||
<span class="firmwareStepBadge">3</span>
|
<span class="firmwareStepBadge">3</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Device Settings</h3>
|
<h3>Prebuilt Image</h3>
|
||||||
<p>Each build fetches the latest YAML, then applies the saved substitutions for this target device.</p>
|
<p>The trainer downloads the latest verified OTA image and checks its size and SHA before flashing.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="saveFirmwareSettingsBtn" type="button">Save settings</button>
|
<button id="saveFirmwareSettingsBtn" type="button">Save target</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="firmwareFields" class="firmwareFields stack">
|
<div id="firmwareFields" class="firmwareFields stack">
|
||||||
<div class="emptyState">Firmware template settings will appear here.</div>
|
<div class="emptyState">Firmware image details will appear here.</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1621,13 +1621,13 @@
|
|||||||
<div class="firmwarePanelTitle">
|
<div class="firmwarePanelTitle">
|
||||||
<span class="firmwareStepBadge">4</span>
|
<span class="firmwareStepBadge">4</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Build + Flash</h3>
|
<h3>Flash OTA</h3>
|
||||||
<p>The ESPHome output opens in the console so you can follow build, upload, and reboot progress.</p>
|
<p>The console shows download, verification, OTA upload, and reboot progress.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row firmwareActions">
|
<div class="row firmwareActions">
|
||||||
<button id="flashFirmwareBtn" class="primary" type="button" disabled>Build + Flash firmware</button>
|
<button id="flashFirmwareBtn" class="primary" type="button" disabled>Flash prebuilt firmware</button>
|
||||||
<button id="cleanFirmwareBtn" type="button">Clean build files</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>
|
</section>
|
||||||
@@ -1653,13 +1653,13 @@
|
|||||||
<div id="firmwareLogDialog" class="firmwareLogDialog" role="dialog" aria-modal="true" aria-labelledby="firmwareLogTitle">
|
<div id="firmwareLogDialog" class="firmwareLogDialog" role="dialog" aria-modal="true" aria-labelledby="firmwareLogTitle">
|
||||||
<div class="firmwareLogHeader">
|
<div class="firmwareLogHeader">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="firmwareLogTitle" class="firmwareLogTitle">Firmware Build + Flash</h3>
|
<h3 id="firmwareLogTitle" class="firmwareLogTitle">Prebuilt Firmware Flash</h3>
|
||||||
<p id="firmwareLogMeta" class="firmwareLogMeta">ESPHome output will appear here.</p>
|
<p id="firmwareLogMeta" class="firmwareLogMeta">Firmware output will appear here.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeFirmwareLogBtn" type="button">Close</button>
|
<button id="closeFirmwareLogBtn" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="firmwareLogStatus" class="firmwareLogStatus">Waiting for firmware output...</div>
|
<div id="firmwareLogStatus" class="firmwareLogStatus">Waiting for firmware output...</div>
|
||||||
<div id="firmwareLogConsole" class="firmwareLogConsole" role="log" aria-live="polite" aria-label="Firmware build and flash log">
|
<div id="firmwareLogConsole" class="firmwareLogConsole" role="log" aria-live="polite" aria-label="Firmware flash log">
|
||||||
<div class="firmwareLogEmpty">No firmware flash started yet.</div>
|
<div class="firmwareLogEmpty">No firmware flash started yet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2341,7 +2341,7 @@
|
|||||||
consoleEl.innerHTML = "";
|
consoleEl.innerHTML = "";
|
||||||
}
|
}
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
consoleEl.innerHTML = `<div class="firmwareLogEmpty">Waiting for ESPHome build output...</div>`;
|
consoleEl.innerHTML = `<div class="firmwareLogEmpty">Waiting for firmware flash output...</div>`;
|
||||||
} else {
|
} else {
|
||||||
consoleEl.innerHTML = rows.map((line) => {
|
consoleEl.innerHTML = rows.map((line) => {
|
||||||
const tone = firmwareLogTone(line);
|
const tone = firmwareLogTone(line);
|
||||||
@@ -2366,17 +2366,17 @@
|
|||||||
const template = selectedFirmwareTemplate();
|
const template = selectedFirmwareTemplate();
|
||||||
const host = ($("firmwareHost").value || "").trim();
|
const host = ($("firmwareHost").value || "").trim();
|
||||||
const port = ($("firmwarePort").value || "3232").trim();
|
const port = ($("firmwarePort").value || "3232").trim();
|
||||||
$("firmwareLogTitle").textContent = "Firmware Build + Flash";
|
$("firmwareLogTitle").textContent = "Prebuilt Firmware Flash";
|
||||||
$("firmwareLogMeta").textContent = [
|
$("firmwareLogMeta").textContent = [
|
||||||
template?.label || template?.value || "Firmware",
|
template?.label || template?.value || "Firmware",
|
||||||
host ? `${host}:${port || "3232"}` : "",
|
host ? `${host}:${port || "3232"}` : "",
|
||||||
].filter(Boolean).join(" • ") || "ESPHome output will appear here.";
|
].filter(Boolean).join(" • ") || "Firmware output will appear here.";
|
||||||
if (text !== null) {
|
if (text !== null) {
|
||||||
renderFirmwareLogLines(String(text).split("\n"), true);
|
renderFirmwareLogLines(String(text).split("\n"), true);
|
||||||
} else {
|
} else {
|
||||||
renderFirmwareLogLines(uiState.firmware.logLines || [], true);
|
renderFirmwareLogLines(uiState.firmware.logLines || [], true);
|
||||||
}
|
}
|
||||||
setFirmwareLogStatus(statusText || "ESPHome build, upload, and flash output appears here.");
|
setFirmwareLogStatus(statusText || "Prebuilt firmware download, verification, and upload output appears here.");
|
||||||
const modal = $("firmwareLogModal");
|
const modal = $("firmwareLogModal");
|
||||||
const dialog = $("firmwareLogDialog");
|
const dialog = $("firmwareLogDialog");
|
||||||
modal.classList.add("active");
|
modal.classList.add("active");
|
||||||
@@ -2499,8 +2499,11 @@
|
|||||||
uiState.firmware.wakeWords = Array.isArray(payload?.wake_words) ? payload.wake_words : [];
|
uiState.firmware.wakeWords = Array.isArray(payload?.wake_words) ? payload.wake_words : [];
|
||||||
renderRuntimeWakeWordLinks();
|
renderRuntimeWakeWordLinks();
|
||||||
$("firmwareTemplate").innerHTML = templates.length
|
$("firmwareTemplate").innerHTML = templates.length
|
||||||
? templates.map((item) => `<option value="${escapeAttr(item.value)}">${escapeHtml(item.label || item.value)}</option>`).join("")
|
? templates.map((item) => {
|
||||||
: `<option value="">No firmware templates found</option>`;
|
const version = item.firmware_version ? ` ${item.firmware_version}` : "";
|
||||||
|
return `<option value="${escapeAttr(item.value)}">${escapeHtml((item.label || item.value) + version)}</option>`;
|
||||||
|
}).join("")
|
||||||
|
: `<option value="">No firmware images found</option>`;
|
||||||
if (previousTemplateKey && templates.some((item) => String(item.value || "") === previousTemplateKey)) {
|
if (previousTemplateKey && templates.some((item) => String(item.value || "") === previousTemplateKey)) {
|
||||||
$("firmwareTemplate").value = previousTemplateKey;
|
$("firmwareTemplate").value = previousTemplateKey;
|
||||||
} else if (payload?.active_template_key) {
|
} else if (payload?.active_template_key) {
|
||||||
@@ -2560,6 +2563,46 @@
|
|||||||
function renderFirmwareFields() {
|
function renderFirmwareFields() {
|
||||||
resetWakeSoundPreview();
|
resetWakeSoundPreview();
|
||||||
const template = selectedFirmwareTemplate();
|
const template = selectedFirmwareTemplate();
|
||||||
|
if (!template) {
|
||||||
|
$("firmwareFields").innerHTML = `<div class="emptyState">Choose a firmware family to see image details.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prebuilt = template.prebuilt_firmware || {};
|
||||||
|
const artifacts = prebuilt.artifacts || {};
|
||||||
|
const ota = artifacts.ota || {};
|
||||||
|
const factory = artifacts.factory || {};
|
||||||
|
const statusClass = prebuilt.available ? "ok" : "warn";
|
||||||
|
const statusText = prebuilt.available ? "OTA image available" : (prebuilt.error || "Prebuilt image unavailable");
|
||||||
|
$("firmwareFields").innerHTML = `
|
||||||
|
<section class="firmwareSettingsSection">
|
||||||
|
<div class="row space">
|
||||||
|
<h3 style="margin:0;">Firmware Details</h3>
|
||||||
|
<span class="pill ${statusClass}">${escapeHtml(statusText)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="firmwareSettingsGrid">
|
||||||
|
<label class="field">
|
||||||
|
<strong>Latest Version</strong>
|
||||||
|
<span class="readOnlyValue">${escapeHtml(prebuilt.version || template.firmware_version || "Unknown")}</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>OTA Image</strong>
|
||||||
|
<span class="readOnlyValue">${escapeHtml(ota.path || "Not available")}</span>
|
||||||
|
<span class="muted">${ota.size_bytes ? `${Number(ota.size_bytes).toLocaleString()} bytes` : "Downloaded when you flash."}</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>USB Factory Image</strong>
|
||||||
|
<span class="readOnlyValue">${escapeHtml(factory.path || "Not available")}</span>
|
||||||
|
<span class="muted">Use a USB flash once if the satellite is new or older than 3.0.3.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<strong>Manifest</strong>
|
||||||
|
<span class="readOnlyValue">${escapeHtml(prebuilt.manifest_url || template.source_url || "Unavailable")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
|
||||||
const fields = Array.isArray(template?.fields) ? template.fields : [];
|
const fields = Array.isArray(template?.fields) ? template.fields : [];
|
||||||
if (!fields.length) {
|
if (!fields.length) {
|
||||||
$("firmwareFields").innerHTML = `<div class="emptyState">No editable settings were found for this firmware template. You can continue with the selected template and target device.</div>`;
|
$("firmwareFields").innerHTML = `<div class="emptyState">No editable settings were found for this firmware template. You can continue with the selected template and target device.</div>`;
|
||||||
@@ -2932,29 +2975,33 @@
|
|||||||
const port = ($("firmwarePort").value || "3232").trim();
|
const port = ($("firmwarePort").value || "3232").trim();
|
||||||
const template = selectedFirmwareTemplate();
|
const template = selectedFirmwareTemplate();
|
||||||
if (!template) {
|
if (!template) {
|
||||||
alert("Choose a firmware template first.");
|
alert("Choose a firmware family first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!host) {
|
if (!host) {
|
||||||
alert("Enter the device IP or hostname first.");
|
alert("Enter the device IP or hostname first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (template.prebuilt_firmware && !template.prebuilt_firmware.available) {
|
||||||
|
alert(template.prebuilt_firmware.error || "No prebuilt OTA image is available for this firmware family.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wakeSoundSelect = document.querySelector("select[data-wake-sound-select]");
|
const wakeSoundSelect = document.querySelector("select[data-wake-sound-select]");
|
||||||
if (wakeSoundSelect instanceof HTMLSelectElement) {
|
if (wakeSoundSelect instanceof HTMLSelectElement) {
|
||||||
syncRenderedWakeSoundSelection({ fromPicker: true });
|
syncRenderedWakeSoundSelection({ fromPicker: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = confirm(`Build and flash ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nMake sure this is the correct device before continuing.`);
|
const ok = confirm(`Flash prebuilt ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nOnly continue if this device is already running Tater firmware 3.0.3 or newer. New devices need one USB flash first.`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
uiState.firmwareBusy = true;
|
uiState.firmwareBusy = true;
|
||||||
uiState.firmware.logLines = [
|
uiState.firmware.logLines = [
|
||||||
"===== Firmware Build + Flash Console =====",
|
"===== Prebuilt Firmware Flash Console =====",
|
||||||
`→ Target: ${host}:${port || "3232"}`,
|
`→ Target: ${host}:${port || "3232"}`,
|
||||||
"→ Contacting trainer server to start the build...",
|
"→ Contacting trainer server to prepare the OTA image...",
|
||||||
];
|
];
|
||||||
setPill($("firmwareStatus"), "Starting build + flash...", "warn");
|
setPill($("firmwareStatus"), "Starting firmware flash...", "warn");
|
||||||
openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Starting firmware build + flash...");
|
openFirmwareConsole(true, uiState.firmware.logLines.join("\n"), "Starting prebuilt firmware flash...");
|
||||||
syncButtons();
|
syncButtons();
|
||||||
await flushFirmwareProfileSave();
|
await flushFirmwareProfileSave();
|
||||||
await waitForPaint();
|
await waitForPaint();
|
||||||
@@ -2973,7 +3020,7 @@
|
|||||||
uiState.firmware.flashing = status;
|
uiState.firmware.flashing = status;
|
||||||
uiState.firmware.logLines = (status.log_lines || []).length
|
uiState.firmware.logLines = (status.log_lines || []).length
|
||||||
? status.log_lines
|
? status.log_lines
|
||||||
: [...uiState.firmware.logLines, "✓ Build session started. Waiting for ESPHome output..."];
|
: [...uiState.firmware.logLines, "✓ Flash session started. Waiting for firmware output..."];
|
||||||
openFirmwareConsole(false, uiState.firmware.logLines.join("\n") || "(waiting for flash output)", status.message || "Firmware session started.");
|
openFirmwareConsole(false, uiState.firmware.logLines.join("\n") || "(waiting for flash output)", status.message || "Firmware session started.");
|
||||||
pollFirmwareFlash(status.session_id);
|
pollFirmwareFlash(status.session_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2999,16 +3046,16 @@
|
|||||||
renderFirmwareLogLines(lines.length ? lines : ["(waiting for flash output)"], true);
|
renderFirmwareLogLines(lines.length ? lines : ["(waiting for flash output)"], true);
|
||||||
|
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
setPill($("firmwareStatus"), status.message || "Firmware build + flash running", "warn");
|
setPill($("firmwareStatus"), status.message || "Firmware flash running", "warn");
|
||||||
setFirmwareLogStatus(status.message || "Firmware build + flash running.");
|
setFirmwareLogStatus(status.message || "Firmware flash running.");
|
||||||
} else {
|
} else {
|
||||||
uiState.firmwareBusy = false;
|
uiState.firmwareBusy = false;
|
||||||
if (status.exit_code === 0) {
|
if (status.exit_code === 0) {
|
||||||
setPill($("firmwareStatus"), "Firmware flashed successfully", "ok");
|
setPill($("firmwareStatus"), "Firmware flashed successfully", "ok");
|
||||||
setFirmwareLogStatus("Firmware flashed successfully.");
|
setFirmwareLogStatus("Firmware flashed successfully.");
|
||||||
} else {
|
} else {
|
||||||
setPill($("firmwareStatus"), `Firmware build + flash failed (${status.exit_code})`, "err");
|
setPill($("firmwareStatus"), `Firmware flash failed (${status.exit_code})`, "err");
|
||||||
setFirmwareLogStatus(`Firmware build + flash failed (${status.exit_code}).`);
|
setFirmwareLogStatus(`Firmware flash failed (${status.exit_code}).`);
|
||||||
}
|
}
|
||||||
syncButtons();
|
syncButtons();
|
||||||
break;
|
break;
|
||||||
@@ -3059,6 +3106,8 @@
|
|||||||
const negativeCount = Number(uiState.samples?.negative_count ?? uiState.captured?.negative_count ?? 0);
|
const negativeCount = Number(uiState.samples?.negative_count ?? uiState.captured?.negative_count ?? 0);
|
||||||
const firmwareHost = ($("firmwareHost").value || "").trim();
|
const firmwareHost = ($("firmwareHost").value || "").trim();
|
||||||
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
|
const firmwareTemplate = ($("firmwareTemplate").value || "").trim();
|
||||||
|
const firmwareSelection = selectedFirmwareTemplate();
|
||||||
|
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
|
||||||
|
|
||||||
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
|
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
|
||||||
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
|
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
|
||||||
@@ -3073,7 +3122,7 @@
|
|||||||
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
|
$("saveFirmwareSettingsBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
|
||||||
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
||||||
$("openFirmwareConsoleBtn").disabled = false;
|
$("openFirmwareConsoleBtn").disabled = false;
|
||||||
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate;
|
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSessionUI(session) {
|
function refreshSessionUI(session) {
|
||||||
@@ -3341,8 +3390,8 @@
|
|||||||
$("tabFirmware").addEventListener("click", () => {
|
$("tabFirmware").addEventListener("click", () => {
|
||||||
setActiveView("firmware");
|
setActiveView("firmware");
|
||||||
refreshFirmwareTemplates().catch((error) => {
|
refreshFirmwareTemplates().catch((error) => {
|
||||||
setPill($("firmwareStatus"), "Templates failed", "err");
|
setPill($("firmwareStatus"), "Firmware list failed", "err");
|
||||||
uiState.firmware.logLines = [`Template load failed: ${error.message}`];
|
uiState.firmware.logLines = [`Firmware list load failed: ${error.message}`];
|
||||||
setConsoleLogAutoScroll($("trainLog"), uiState.firmware.logLines.join("\n"));
|
setConsoleLogAutoScroll($("trainLog"), uiState.firmware.logLines.join("\n"));
|
||||||
});
|
});
|
||||||
if (!uiState.firmware.devices.length) {
|
if (!uiState.firmware.devices.length) {
|
||||||
@@ -3446,7 +3495,7 @@
|
|||||||
const host = ($("firmwareHost").value || "").trim();
|
const host = ($("firmwareHost").value || "").trim();
|
||||||
const template = ($("firmwareTemplate").value || "").trim();
|
const template = ($("firmwareTemplate").value || "").trim();
|
||||||
if (!template) {
|
if (!template) {
|
||||||
alert("Choose a firmware template first.");
|
alert("Choose a firmware family first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!host) {
|
if (!host) {
|
||||||
@@ -3456,7 +3505,7 @@
|
|||||||
try {
|
try {
|
||||||
setPill($("firmwareStatus"), "Saving device settings...", "warn");
|
setPill($("firmwareStatus"), "Saving device settings...", "warn");
|
||||||
await saveFirmwareProfileNow({ quiet: true });
|
await saveFirmwareProfileNow({ quiet: true });
|
||||||
setPill($("firmwareStatus"), "Device settings saved", "ok");
|
setPill($("firmwareStatus"), "Target saved", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPill($("firmwareStatus"), "Settings save failed", "err");
|
setPill($("firmwareStatus"), "Settings save failed", "err");
|
||||||
alert("Settings save failed: " + error.message);
|
alert("Settings save failed: " + error.message);
|
||||||
@@ -3466,9 +3515,9 @@
|
|||||||
});
|
});
|
||||||
$("cleanFirmwareBtn").addEventListener("click", async () => {
|
$("cleanFirmwareBtn").addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
setPill($("firmwareStatus"), "Cleaning build files...", "warn");
|
setPill($("firmwareStatus"), "Clearing downloaded images...", "warn");
|
||||||
const result = await api("/api/firmware/clean", { method: "POST" });
|
const result = await api("/api/firmware/clean", { method: "POST" });
|
||||||
setPill($("firmwareStatus"), result.message || "Build files cleaned", "ok");
|
setPill($("firmwareStatus"), result.message || "Downloaded images cleared", "ok");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPill($("firmwareStatus"), "Clean failed", "err");
|
setPill($("firmwareStatus"), "Clean failed", "err");
|
||||||
alert("Clean failed: " + error.message);
|
alert("Clean failed: " + error.message);
|
||||||
@@ -3477,7 +3526,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$("openFirmwareConsoleBtn").addEventListener("click", () => {
|
$("openFirmwareConsoleBtn").addEventListener("click", () => {
|
||||||
openFirmwareConsole(true, (uiState.firmware.logLines || []).join("\n") || "(no firmware flash started)", uiState.firmwareBusy ? "Firmware build + flash running." : "No active firmware flash.");
|
openFirmwareConsole(true, (uiState.firmware.logLines || []).join("\n") || "(no firmware flash started)", uiState.firmwareBusy ? "Firmware flash running." : "No active firmware flash.");
|
||||||
});
|
});
|
||||||
|
|
||||||
$("openConsoleBtn").addEventListener("click", () => {
|
$("openConsoleBtn").addEventListener("click", () => {
|
||||||
@@ -3714,8 +3763,8 @@
|
|||||||
try {
|
try {
|
||||||
await refreshFirmwareTemplates();
|
await refreshFirmwareTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPill($("firmwareStatus"), "Templates failed", "err");
|
setPill($("firmwareStatus"), "Firmware list failed", "err");
|
||||||
uiState.firmware.logLines = [`Template load failed: ${error.message}`];
|
uiState.firmware.logLines = [`Firmware list load failed: ${error.message}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# trainer_server.py
|
# trainer_server.py
|
||||||
import contextlib
|
import contextlib
|
||||||
import copy
|
import copy
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -24,7 +26,6 @@ from typing import Dict, Any, List, Callable, Optional, Tuple
|
|||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
from urllib.request import Request as URLRequest, urlopen
|
from urllib.request import Request as URLRequest, urlopen
|
||||||
|
|
||||||
import yaml
|
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, Header, Request
|
from fastapi import FastAPI, UploadFile, File, Form, Header, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -79,7 +80,6 @@ CAPTURE_GAIN_PROFILE = "capture_rms_v1"
|
|||||||
|
|
||||||
# Firmware build/flash cache lives inside /data so Docker runs can reuse downloads.
|
# Firmware build/flash cache lives inside /data so Docker runs can reuse downloads.
|
||||||
FIRMWARE_CACHE_DIR = Path(os.environ.get("FIRMWARE_CACHE_DIR", str(DATA_DIR / ".cache" / "firmware_flasher"))).resolve()
|
FIRMWARE_CACHE_DIR = Path(os.environ.get("FIRMWARE_CACHE_DIR", str(DATA_DIR / ".cache" / "firmware_flasher"))).resolve()
|
||||||
FIRMWARE_HELPER = ROOT_DIR / "cli" / "flash_esphome_ota.py"
|
|
||||||
FIRMWARE_DEFAULT_OTA_PORT = int(os.environ.get("ESPHOME_OTA_PORT", "3232"))
|
FIRMWARE_DEFAULT_OTA_PORT = int(os.environ.get("ESPHOME_OTA_PORT", "3232"))
|
||||||
FIRMWARE_DISCOVERY_SECONDS = float(os.environ.get("ESPHOME_DISCOVERY_SECONDS", "2.5"))
|
FIRMWARE_DISCOVERY_SECONDS = float(os.environ.get("ESPHOME_DISCOVERY_SECONDS", "2.5"))
|
||||||
FIRMWARE_MAX_LOG_LINES = int(os.environ.get("FIRMWARE_MAX_LOG_LINES", "500"))
|
FIRMWARE_MAX_LOG_LINES = int(os.environ.get("FIRMWARE_MAX_LOG_LINES", "500"))
|
||||||
@@ -87,66 +87,52 @@ FIRMWARE_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson"
|
|||||||
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
|
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
|
||||||
FIRMWARE_GITHUB_REF = os.environ.get("FIRMWARE_GITHUB_REF", "main")
|
FIRMWARE_GITHUB_REF = os.environ.get("FIRMWARE_GITHUB_REF", "main")
|
||||||
WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS = int(os.environ.get("WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS", "600"))
|
WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS = int(os.environ.get("WAKE_SOUND_CATALOG_CACHE_TTL_SECONDS", "600"))
|
||||||
FIRMWARE_PLATFORMIO_DIR = FIRMWARE_CACHE_DIR / "platformio"
|
FIRMWARE_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
|
||||||
FIRMWARE_HOME_DIR = FIRMWARE_CACHE_DIR / "home"
|
FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS = float(os.environ.get("FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS", "120"))
|
||||||
FIRMWARE_XDG_CACHE_DIR = FIRMWARE_CACHE_DIR / "cache"
|
FIRMWARE_JSON_CACHE_TTL_SECONDS = float(os.environ.get("FIRMWARE_JSON_CACHE_TTL_SECONDS", "900"))
|
||||||
FIRMWARE_ESPHOME_DATA_DIR = FIRMWARE_CACHE_DIR / "esphome_data"
|
FIRMWARE_OTA_BLOCK_SIZE = int(os.environ.get("FIRMWARE_OTA_BLOCK_SIZE", "8192"))
|
||||||
FIRMWARE_PROFILE_FILE = Path(
|
FIRMWARE_PROFILE_FILE = Path(
|
||||||
os.environ.get("FIRMWARE_PROFILE_FILE", str(FIRMWARE_CACHE_DIR / "profiles.json"))
|
os.environ.get("FIRMWARE_PROFILE_FILE", str(FIRMWARE_CACHE_DIR / "profiles.json"))
|
||||||
).resolve()
|
).resolve()
|
||||||
WAKE_SOUND_MANIFEST_PATHS = ("wake_sound_manifest.json", "wake-sound-manifest.json")
|
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_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": {}}
|
||||||
WAKE_SOUND_CATALOG_LOCK = threading.Lock()
|
WAKE_SOUND_CATALOG_LOCK = threading.Lock()
|
||||||
|
FIRMWARE_JSON_CACHE: Dict[str, Dict[str, Any]] = {}
|
||||||
|
FIRMWARE_JSON_CACHE_LOCK = threading.Lock()
|
||||||
TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400"))
|
TRAIN_LOG_TAIL_LINES = int(os.environ.get("REC_TRAIN_LOG_TAIL_LINES", "400"))
|
||||||
TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024)))
|
TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 1024)))
|
||||||
|
|
||||||
FIRMWARE_TEMPLATE_SPECS = (
|
FIRMWARE_TEMPLATE_SPECS = (
|
||||||
{
|
{
|
||||||
"key": "voicepe",
|
"key": "voicepe",
|
||||||
"label": "VoicePE (voicePE-TaterTimer.yaml)",
|
"label": "VoicePE",
|
||||||
"path": "voicePE-TaterTimer.yaml",
|
"description": "VoicePE satellite prebuilt firmware",
|
||||||
"identity_key": "device_name",
|
|
||||||
"friendly_key": "friendly_name",
|
|
||||||
"fixed_keys": {"device_name"},
|
|
||||||
"auto_keys": {"ha_voice_ip"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "satellite1",
|
"key": "satellite1",
|
||||||
"label": "Sat1 (satellite1-TaterTimer.yaml)",
|
"label": "Sat1",
|
||||||
"path": "satellite1-TaterTimer.yaml",
|
"description": "Satellite1 prebuilt firmware",
|
||||||
"identity_key": "node_name",
|
|
||||||
"friendly_key": "friendly_name",
|
|
||||||
"fixed_keys": {"node_name"},
|
|
||||||
"auto_keys": {"ha_voice_ip"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "respeaker_lite",
|
"key": "respeaker_lite",
|
||||||
"label": "ReSpeaker Lite (respeakerLite-TaterTimer.yaml)",
|
"label": "ReSpeaker Lite",
|
||||||
"path": "respeakerLite-TaterTimer.yaml",
|
"description": "ReSpeaker Lite prebuilt firmware",
|
||||||
"identity_key": "device_name",
|
|
||||||
"friendly_key": "friendly_name",
|
|
||||||
"fixed_keys": {"device_name"},
|
|
||||||
"auto_keys": {"ha_voice_ip"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "koala",
|
"key": "koala",
|
||||||
"label": "Koala Satellite (koala-TaterTimer.yaml)",
|
"label": "Koala Satellite",
|
||||||
"path": "koala-TaterTimer.yaml",
|
"description": "Koala satellite prebuilt firmware",
|
||||||
"identity_key": "device_name",
|
|
||||||
"friendly_key": "friendly_name",
|
|
||||||
"fixed_keys": {"device_name"},
|
|
||||||
"auto_keys": {"ha_voice_ip"},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "respeaker_xvf3800",
|
"key": "respeaker_xvf3800",
|
||||||
"label": "ReSpeaker XVF3800 (respeakerXVF3800-TaterTimer.yaml)",
|
"label": "ReSpeaker XVF3800",
|
||||||
"path": "respeakerXVF3800-TaterTimer.yaml",
|
"description": "ReSpeaker XVF3800 prebuilt firmware",
|
||||||
"identity_key": "device_name",
|
|
||||||
"friendly_key": "friendly_name",
|
|
||||||
"fixed_keys": {"device_name"},
|
|
||||||
"auto_keys": {"ha_voice_ip"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
FIRMWARE_PREBUILT_LATEST_URL = (
|
||||||
|
f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/prebuilt_firmware/latest.json"
|
||||||
|
)
|
||||||
|
FIRMWARE_PREBUILT_TEMPLATE_KEYS = {str(spec.get("key") or "").lower() for spec in FIRMWARE_TEMPLATE_SPECS}
|
||||||
|
|
||||||
app = FastAPI(title="microWakeWord Personal Samples")
|
app = FastAPI(title="microWakeWord Personal Samples")
|
||||||
|
|
||||||
@@ -246,51 +232,6 @@ def _detect_speech_segments(wav_bytes: bytes) -> List[Dict[str, float]]:
|
|||||||
return [{"start": round(ts["start"], 3), "end": round(ts["end"], 3)} for ts in timestamps]
|
return [{"start": round(ts["start"], 3), "end": round(ts["end"], 3)} for ts in timestamps]
|
||||||
|
|
||||||
|
|
||||||
class _FirmwareYamlLoader(yaml.SafeLoader):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class _FirmwareYamlDumper(yaml.SafeDumper):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _TaggedYamlValue:
|
|
||||||
__slots__ = ("tag", "value")
|
|
||||||
|
|
||||||
def __init__(self, tag: str, value: Any) -> None:
|
|
||||||
self.tag = str(tag or "")
|
|
||||||
self.value = value
|
|
||||||
|
|
||||||
|
|
||||||
def _construct_secret(loader: yaml.SafeLoader, node: yaml.Node) -> Dict[str, str]:
|
|
||||||
return {"__secret__": loader.construct_scalar(node)}
|
|
||||||
|
|
||||||
|
|
||||||
def _construct_tagged_yaml(loader: yaml.SafeLoader, tag_suffix: str, node: yaml.Node) -> _TaggedYamlValue:
|
|
||||||
tag = f"!{tag_suffix}"
|
|
||||||
if isinstance(node, yaml.ScalarNode):
|
|
||||||
value = loader.construct_scalar(node)
|
|
||||||
elif isinstance(node, yaml.SequenceNode):
|
|
||||||
value = loader.construct_sequence(node, deep=True)
|
|
||||||
elif isinstance(node, yaml.MappingNode):
|
|
||||||
value = loader.construct_mapping(node, deep=True)
|
|
||||||
else:
|
|
||||||
value = loader.construct_object(node, deep=True)
|
|
||||||
return _TaggedYamlValue(tag, value)
|
|
||||||
|
|
||||||
|
|
||||||
def _represent_tagged_yaml(dumper: yaml.SafeDumper, value: _TaggedYamlValue) -> yaml.Node:
|
|
||||||
payload = value.value
|
|
||||||
if isinstance(payload, dict):
|
|
||||||
return dumper.represent_mapping(value.tag, payload)
|
|
||||||
if isinstance(payload, list):
|
|
||||||
return dumper.represent_sequence(value.tag, payload)
|
|
||||||
return dumper.represent_scalar(value.tag, "" if payload is None else str(payload))
|
|
||||||
|
|
||||||
|
|
||||||
_FirmwareYamlLoader.add_constructor("!secret", _construct_secret)
|
|
||||||
_FirmwareYamlLoader.add_multi_constructor("!", _construct_tagged_yaml)
|
|
||||||
_FirmwareYamlDumper.add_representer(_TaggedYamlValue, _represent_tagged_yaml)
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_personal_samples_dir():
|
def _reset_personal_samples_dir():
|
||||||
_reset_audio_dir(PERSONAL_DIR)
|
_reset_audio_dir(PERSONAL_DIR)
|
||||||
@@ -1523,6 +1464,375 @@ def _fetch_text_url(url: str, timeout: float = 20) -> str:
|
|||||||
return response.read().decode(charset, errors="replace")
|
return response.read().decode(charset, errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lower(value: Any) -> str:
|
||||||
|
return _text(value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: Any, default: int = 0, *, minimum: int | None = None) -> int:
|
||||||
|
try:
|
||||||
|
parsed = int(value)
|
||||||
|
except Exception:
|
||||||
|
parsed = default
|
||||||
|
if minimum is not None:
|
||||||
|
parsed = max(minimum, parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_token(value: Any) -> str:
|
||||||
|
token = re.sub(r"[^A-Za-z0-9_.-]+", "_", _text(value)).strip("._-")
|
||||||
|
return (token[:96] or "default").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_firmware_raw_url(path_or_url: Any) -> str:
|
||||||
|
token = _text(path_or_url)
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
parsed = urlparse(token)
|
||||||
|
if parsed.scheme and parsed.netloc:
|
||||||
|
return token
|
||||||
|
clean = token.lstrip("/")
|
||||||
|
quoted = "/".join(quote(part) for part in clean.split("/") if part)
|
||||||
|
return f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/{quoted}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json_url(url: str, *, timeout: float = 20, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
|
now = time.time()
|
||||||
|
with FIRMWARE_JSON_CACHE_LOCK:
|
||||||
|
cached = FIRMWARE_JSON_CACHE.get(url)
|
||||||
|
if (
|
||||||
|
not force_refresh
|
||||||
|
and isinstance(cached, dict)
|
||||||
|
and isinstance(cached.get("payload"), dict)
|
||||||
|
and (now - float(cached.get("ts") or 0.0)) < FIRMWARE_JSON_CACHE_TTL_SECONDS
|
||||||
|
):
|
||||||
|
return copy.deepcopy(cached["payload"])
|
||||||
|
|
||||||
|
req = URLRequest(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "microWakeWord-Trainer/1.0",
|
||||||
|
"Accept": "application/json, */*",
|
||||||
|
"Cache-Control": "no-cache" if force_refresh else "max-age=60",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urlopen(req, timeout=timeout) as response:
|
||||||
|
charset = response.headers.get_content_charset() or "utf-8"
|
||||||
|
payload = json.loads(response.read().decode(charset, errors="replace"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError(f"Remote JSON did not parse into an object: {url}")
|
||||||
|
with FIRMWARE_JSON_CACHE_LOCK:
|
||||||
|
FIRMWARE_JSON_CACHE[url] = {"ts": now, "payload": copy.deepcopy(payload)}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _load_prebuilt_firmware_manifest(*, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
|
latest_payload = _fetch_json_url(
|
||||||
|
FIRMWARE_PREBUILT_LATEST_URL,
|
||||||
|
timeout=20,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
)
|
||||||
|
manifest_ref = _text(latest_payload.get("manifest"))
|
||||||
|
if not manifest_ref:
|
||||||
|
raise RuntimeError("Prebuilt firmware latest.json is missing a manifest path.")
|
||||||
|
|
||||||
|
manifest_url = _prebuilt_firmware_raw_url(manifest_ref)
|
||||||
|
manifest_payload = _fetch_json_url(manifest_url, timeout=20, force_refresh=force_refresh)
|
||||||
|
devices = manifest_payload.get("devices")
|
||||||
|
if not isinstance(devices, list):
|
||||||
|
raise RuntimeError("Prebuilt firmware manifest is missing its devices list.")
|
||||||
|
|
||||||
|
payload = copy.deepcopy(manifest_payload)
|
||||||
|
payload["version"] = _text(manifest_payload.get("version")) or _text(latest_payload.get("version"))
|
||||||
|
payload["latest_url"] = FIRMWARE_PREBUILT_LATEST_URL
|
||||||
|
payload["manifest_url"] = manifest_url
|
||||||
|
payload["manifest_path"] = manifest_ref
|
||||||
|
payload["devices_by_key"] = {
|
||||||
|
_lower(row.get("key")): dict(row)
|
||||||
|
for row in devices
|
||||||
|
if isinstance(row, dict) and _text(row.get("key"))
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_firmware_info(template_key: Any, *, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
|
key = _lower(template_key)
|
||||||
|
if key not in FIRMWARE_PREBUILT_TEMPLATE_KEYS:
|
||||||
|
return {"available": False, "template_key": key, "reason": "not_prebuilt"}
|
||||||
|
try:
|
||||||
|
manifest = _load_prebuilt_firmware_manifest(force_refresh=force_refresh)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"template_key": key,
|
||||||
|
"reason": "manifest_unavailable",
|
||||||
|
"error": _text(exc) or exc.__class__.__name__,
|
||||||
|
}
|
||||||
|
|
||||||
|
devices_by_key = manifest.get("devices_by_key") if isinstance(manifest.get("devices_by_key"), dict) else {}
|
||||||
|
device = devices_by_key.get(key) if isinstance(devices_by_key.get(key), dict) else None
|
||||||
|
if not isinstance(device, dict):
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"template_key": key,
|
||||||
|
"reason": "missing_device",
|
||||||
|
"version": _text(manifest.get("version")),
|
||||||
|
"manifest_url": _text(manifest.get("manifest_url")),
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts = device.get("artifacts") if isinstance(device.get("artifacts"), dict) else {}
|
||||||
|
return {
|
||||||
|
"available": bool(artifacts.get("ota") or artifacts.get("factory")),
|
||||||
|
"template_key": key,
|
||||||
|
"version": _text(manifest.get("version")),
|
||||||
|
"manifest_url": _text(manifest.get("manifest_url")),
|
||||||
|
"latest_url": _text(manifest.get("latest_url")),
|
||||||
|
"device": copy.deepcopy(device),
|
||||||
|
"artifacts": copy.deepcopy(artifacts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_artifact_ui_summary(prebuilt: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
artifacts = prebuilt.get("artifacts") if isinstance(prebuilt.get("artifacts"), dict) else {}
|
||||||
|
ota_artifact = artifacts.get("ota") if isinstance(artifacts.get("ota"), dict) else None
|
||||||
|
return {
|
||||||
|
"available": bool(isinstance(ota_artifact, dict) and _text(ota_artifact.get("path"))),
|
||||||
|
"version": _text(prebuilt.get("version")),
|
||||||
|
"manifest_url": _text(prebuilt.get("manifest_url")),
|
||||||
|
"latest_url": _text(prebuilt.get("latest_url")),
|
||||||
|
"error": _text(prebuilt.get("error")),
|
||||||
|
"artifacts": {
|
||||||
|
kind: {
|
||||||
|
"kind": _text(row.get("kind") or kind),
|
||||||
|
"path": _text(row.get("path")),
|
||||||
|
"size_bytes": _as_int(row.get("size_bytes"), 0, minimum=0),
|
||||||
|
"sha256": _text(row.get("sha256")),
|
||||||
|
}
|
||||||
|
for kind, row in artifacts.items()
|
||||||
|
if isinstance(row, dict)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_artifact_meta(prebuilt: Dict[str, Any], kind: str) -> Dict[str, Any]:
|
||||||
|
artifacts = prebuilt.get("artifacts") if isinstance(prebuilt.get("artifacts"), dict) else {}
|
||||||
|
artifact = artifacts.get(_lower(kind)) if isinstance(artifacts.get(_lower(kind)), dict) else None
|
||||||
|
if not isinstance(artifact, dict) or not _text(artifact.get("path")):
|
||||||
|
raise RuntimeError(f"No prebuilt {kind} firmware artifact is available for this target.")
|
||||||
|
return dict(artifact)
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_cache_path(template_key: Any, version: Any, artifact: Dict[str, Any]) -> Path:
|
||||||
|
name = Path(_text(artifact.get("path"))).name or f"{_sanitize_token(template_key)}-{_text(artifact.get('kind')) or 'firmware'}.bin"
|
||||||
|
return FIRMWARE_PREBUILT_DIR / _sanitize_token(version or "latest") / _sanitize_token(template_key) / name
|
||||||
|
|
||||||
|
|
||||||
|
def _prebuilt_binary_is_valid(path: Path, artifact: Dict[str, Any]) -> bool:
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
expected_size = _as_int(artifact.get("size_bytes"), 0, minimum=0)
|
||||||
|
if expected_size and int(path.stat().st_size) != expected_size:
|
||||||
|
return False
|
||||||
|
expected_sha = _lower(artifact.get("sha256"))
|
||||||
|
if expected_sha and hashlib.sha256(path.read_bytes()).hexdigest().lower() != expected_sha:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _download_prebuilt_firmware_binary(
|
||||||
|
template_key: Any,
|
||||||
|
prebuilt: Dict[str, Any],
|
||||||
|
kind: str,
|
||||||
|
*,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
artifact = _prebuilt_artifact_meta(prebuilt, kind)
|
||||||
|
target_path = _prebuilt_cache_path(template_key, prebuilt.get("version"), artifact)
|
||||||
|
url = _prebuilt_firmware_raw_url(artifact.get("path"))
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("Prebuilt firmware URL is missing.")
|
||||||
|
if not force_refresh and _prebuilt_binary_is_valid(target_path, artifact):
|
||||||
|
return {"path": target_path, "artifact": artifact, "url": url, "cached": True}
|
||||||
|
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = target_path.with_name(f".{target_path.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
req = URLRequest(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "microWakeWord-Trainer/1.0",
|
||||||
|
"Accept": "application/octet-stream, */*",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS) as response:
|
||||||
|
tmp_path.write_bytes(response.read())
|
||||||
|
if not _prebuilt_binary_is_valid(tmp_path, artifact):
|
||||||
|
raise RuntimeError(f"Downloaded prebuilt firmware failed verification: {target_path.name}.")
|
||||||
|
tmp_path.replace(target_path)
|
||||||
|
except Exception:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
tmp_path.unlink()
|
||||||
|
raise
|
||||||
|
return {"path": target_path, "artifact": artifact, "url": url, "cached": False}
|
||||||
|
|
||||||
|
|
||||||
|
class _NativeOTAError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _native_ota_check(data: bytes, expected: set[int] | None = None) -> None:
|
||||||
|
error_messages = {
|
||||||
|
0x80: "Invalid magic byte.",
|
||||||
|
0x81: "Device could not prepare flash memory for update.",
|
||||||
|
0x82: "OTA authentication failed.",
|
||||||
|
0x83: "Writing OTA data to flash failed.",
|
||||||
|
0x84: "Finishing OTA update failed.",
|
||||||
|
0x85: "Manual reset is required before this OTA update.",
|
||||||
|
0x86: "Current flash configuration does not match this firmware.",
|
||||||
|
0x87: "New firmware flash configuration does not match this device.",
|
||||||
|
0x89: "The OTA partition is too small for this firmware.",
|
||||||
|
0x8A: "The OTA partition could not be found. Recover with USB flashing.",
|
||||||
|
0x8B: "OTA MD5 mismatch. Retry or recover with USB flashing.",
|
||||||
|
0x8D: "Firmware signature verification failed.",
|
||||||
|
0x8E: "This OTA type is not supported by the device.",
|
||||||
|
0xFF: "Unknown OTA error from device.",
|
||||||
|
}
|
||||||
|
if not data:
|
||||||
|
raise _NativeOTAError("Device closed the OTA connection without responding.")
|
||||||
|
code = int(data[0])
|
||||||
|
if code in error_messages:
|
||||||
|
raise _NativeOTAError(error_messages[code])
|
||||||
|
if expected is not None and code not in expected:
|
||||||
|
expected_text = ", ".join(f"0x{item:02X}" for item in sorted(expected))
|
||||||
|
raise _NativeOTAError(f"Unexpected OTA response 0x{code:02X}; expected {expected_text}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _native_ota_receive(sock: socket.socket, amount: int, label: str, expected: set[int] | None = None) -> bytes:
|
||||||
|
data = b""
|
||||||
|
while len(data) < amount:
|
||||||
|
try:
|
||||||
|
chunk = sock.recv(amount - len(data))
|
||||||
|
except OSError as exc:
|
||||||
|
raise _NativeOTAError(f"OTA receive failed while reading {label}: {exc}") from exc
|
||||||
|
if not chunk:
|
||||||
|
raise _NativeOTAError(f"OTA connection closed while reading {label}.")
|
||||||
|
data += chunk
|
||||||
|
if len(data) == 1:
|
||||||
|
_native_ota_check(data, expected)
|
||||||
|
if len(data) > 1 and expected is not None:
|
||||||
|
_native_ota_check(data[:1], expected)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _native_ota_send(sock: socket.socket, data: bytes | str | int | List[int], label: str) -> None:
|
||||||
|
if isinstance(data, str):
|
||||||
|
payload = data.encode("utf-8")
|
||||||
|
elif isinstance(data, int):
|
||||||
|
payload = bytes([data])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
payload = bytes(data)
|
||||||
|
else:
|
||||||
|
payload = data
|
||||||
|
try:
|
||||||
|
sock.sendall(payload)
|
||||||
|
except OSError as exc:
|
||||||
|
raise _NativeOTAError(f"OTA send failed while writing {label}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _native_ota_upload(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
binary_path: Path,
|
||||||
|
*,
|
||||||
|
progress_callback: Callable[[int, int, int], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not host:
|
||||||
|
raise _NativeOTAError("OTA target host is missing.")
|
||||||
|
if not binary_path.is_file():
|
||||||
|
raise _NativeOTAError(f"OTA firmware file was not found: {binary_path}.")
|
||||||
|
|
||||||
|
upload_contents = binary_path.read_bytes()
|
||||||
|
sock: socket.socket | None = None
|
||||||
|
try:
|
||||||
|
sock = socket.create_connection((host, int(port or FIRMWARE_DEFAULT_OTA_PORT)), timeout=20.0)
|
||||||
|
sock.settimeout(20.0)
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
_native_ota_send(sock, bytes([0x6C, 0x26, 0xF7, 0x5C, 0x45]), "magic bytes")
|
||||||
|
version_response = _native_ota_receive(sock, 2, "OTA version", {0x00})
|
||||||
|
version = int(version_response[1])
|
||||||
|
if version not in {1, 2}:
|
||||||
|
raise _NativeOTAError(f"Device uses unsupported OTA protocol version {version}.")
|
||||||
|
|
||||||
|
_native_ota_send(sock, 0x01 | 0x04, "client features")
|
||||||
|
feature_response = _native_ota_receive(sock, 1, "server features")
|
||||||
|
extended_proto = False
|
||||||
|
server_features = 0
|
||||||
|
first_feature = int(feature_response[0])
|
||||||
|
if first_feature == 0x48:
|
||||||
|
extended_proto = True
|
||||||
|
server_features = int(_native_ota_receive(sock, 1, "server feature flags")[0])
|
||||||
|
elif first_feature == 0x46:
|
||||||
|
server_features = 0x01
|
||||||
|
|
||||||
|
auth_response = int(_native_ota_receive(sock, 1, "OTA auth", {0x01, 0x02, 0x41})[0])
|
||||||
|
if auth_response != 0x41:
|
||||||
|
raise _NativeOTAError("Device requested OTA authentication, but Tater prebuilt OTA has no password configured.")
|
||||||
|
|
||||||
|
sock.settimeout(90.0)
|
||||||
|
if extended_proto:
|
||||||
|
_native_ota_send(sock, 0x00, "OTA app update type")
|
||||||
|
if server_features & 0x01:
|
||||||
|
upload_contents = gzip.compress(upload_contents, compresslevel=9)
|
||||||
|
|
||||||
|
upload_size = len(upload_contents)
|
||||||
|
_native_ota_send(
|
||||||
|
sock,
|
||||||
|
[
|
||||||
|
(upload_size >> 24) & 0xFF,
|
||||||
|
(upload_size >> 16) & 0xFF,
|
||||||
|
(upload_size >> 8) & 0xFF,
|
||||||
|
upload_size & 0xFF,
|
||||||
|
],
|
||||||
|
"binary size",
|
||||||
|
)
|
||||||
|
_native_ota_receive(sock, 1, "update prepare", {0x42})
|
||||||
|
_native_ota_send(sock, hashlib.md5(upload_contents).hexdigest(), "binary md5")
|
||||||
|
_native_ota_receive(sock, 1, "md5 check", {0x43})
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
last_percent = -1
|
||||||
|
while sent < upload_size:
|
||||||
|
chunk = upload_contents[sent : sent + FIRMWARE_OTA_BLOCK_SIZE]
|
||||||
|
_native_ota_send(sock, chunk, "firmware chunk")
|
||||||
|
sent += len(chunk)
|
||||||
|
if version >= 2:
|
||||||
|
_native_ota_receive(sock, 1, "chunk acknowledgement", {0x47})
|
||||||
|
percent = int((sent / upload_size) * 100) if upload_size else 100
|
||||||
|
if callable(progress_callback) and (percent >= last_percent + 5 or percent == 100):
|
||||||
|
last_percent = percent
|
||||||
|
progress_callback(percent, sent, upload_size)
|
||||||
|
|
||||||
|
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
_native_ota_receive(sock, 1, "receive result", {0x44})
|
||||||
|
_native_ota_receive(sock, 1, "update end", {0x45})
|
||||||
|
_native_ota_send(sock, 0x00, "end acknowledgement")
|
||||||
|
return host
|
||||||
|
except OSError as exc:
|
||||||
|
raise _NativeOTAError(f"OTA connection to {host}:{int(port or FIRMWARE_DEFAULT_OTA_PORT)} failed: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if sock is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
def _load_firmware_template_text(spec: Dict[str, Any]) -> tuple[str, str]:
|
def _load_firmware_template_text(spec: Dict[str, Any]) -> tuple[str, str]:
|
||||||
rel_path = str(spec.get("path") or "").strip()
|
rel_path = str(spec.get("path") or "").strip()
|
||||||
url = _firmware_raw_url(rel_path)
|
url = _firmware_raw_url(rel_path)
|
||||||
@@ -2202,20 +2512,6 @@ def _parse_flash_target(raw_host: str, raw_port: Any = None) -> tuple[str, int]:
|
|||||||
return host_text, port
|
return host_text, port
|
||||||
|
|
||||||
|
|
||||||
def _firmware_display_command(command: List[str]) -> str:
|
|
||||||
parts = []
|
|
||||||
skip_next = False
|
|
||||||
for token in command:
|
|
||||||
if skip_next:
|
|
||||||
parts.append("***")
|
|
||||||
skip_next = False
|
|
||||||
continue
|
|
||||||
parts.append(token)
|
|
||||||
if token == "--password":
|
|
||||||
skip_next = True
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_firmware_flash_background(session_id: str):
|
def _run_firmware_flash_background(session_id: str):
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
session = FIRMWARE_SESSIONS.get(session_id)
|
session = FIRMWARE_SESSIONS.get(session_id)
|
||||||
@@ -2223,60 +2519,30 @@ def _run_firmware_flash_background(session_id: str):
|
|||||||
return
|
return
|
||||||
host = str(session.get("host") or "")
|
host = str(session.get("host") or "")
|
||||||
port = int(session.get("port") or FIRMWARE_DEFAULT_OTA_PORT)
|
port = int(session.get("port") or FIRMWARE_DEFAULT_OTA_PORT)
|
||||||
password = str(session.get("password") or "")
|
|
||||||
firmware_path = str(session.get("firmware_path") or "")
|
firmware_path = str(session.get("firmware_path") or "")
|
||||||
|
|
||||||
command = [
|
|
||||||
sys.executable,
|
|
||||||
"-u",
|
|
||||||
str(FIRMWARE_HELPER),
|
|
||||||
"--host",
|
|
||||||
host,
|
|
||||||
"--port",
|
|
||||||
str(port),
|
|
||||||
]
|
|
||||||
if password:
|
|
||||||
command.extend(["--password", password])
|
|
||||||
command.append(firmware_path)
|
|
||||||
|
|
||||||
_append_firmware_log(session_id, "===== Firmware Flash Console =====")
|
_append_firmware_log(session_id, "===== Firmware Flash Console =====")
|
||||||
_append_firmware_log(session_id, f"→ Device: {host}:{port}")
|
_append_firmware_log(session_id, f"→ Device: {host}:{port}")
|
||||||
_append_firmware_log(session_id, f"→ Running: {_firmware_display_command(command)}")
|
_append_firmware_log(session_id, f"→ OTA image: {Path(firmware_path).name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
env = _firmware_runner_env()
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
command,
|
|
||||||
cwd=str(ROOT_DIR),
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
live = FIRMWARE_SESSIONS.get(session_id)
|
||||||
if isinstance(live, dict):
|
if isinstance(live, dict):
|
||||||
live["pid"] = int(proc.pid or 0)
|
|
||||||
live["message"] = "Firmware upload running."
|
live["message"] = "Firmware upload running."
|
||||||
|
|
||||||
assert proc.stdout is not None
|
def progress(percent: int, sent: int, total: int) -> None:
|
||||||
for line in proc.stdout:
|
_append_firmware_log(session_id, f"→ OTA upload progress: {percent}% ({sent}/{total} bytes)")
|
||||||
for part in line.replace("\r", "\n").splitlines():
|
|
||||||
_append_firmware_log(session_id, part)
|
|
||||||
rc = proc.wait()
|
|
||||||
|
|
||||||
if rc == 0:
|
uploaded_host = _native_ota_upload(host, port, Path(firmware_path), progress_callback=progress)
|
||||||
_append_firmware_log(session_id, f"✓ Firmware flash finished (exit_code={rc})")
|
_append_firmware_log(session_id, f"✓ Firmware flash finished to {uploaded_host or host}")
|
||||||
else:
|
|
||||||
_append_firmware_log(session_id, f"✗ Firmware flash failed (exit_code={rc})")
|
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
live = FIRMWARE_SESSIONS.get(session_id)
|
||||||
if isinstance(live, dict):
|
if isinstance(live, dict):
|
||||||
live["running"] = False
|
live["running"] = False
|
||||||
live["exit_code"] = int(rc)
|
live["exit_code"] = 0
|
||||||
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
live["message"] = "Firmware upload completed." if rc == 0 else f"Firmware upload failed with exit code {rc}."
|
live["message"] = "Firmware uploaded successfully."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_append_firmware_log(session_id, f"✗ Firmware flash crashed: {exc!r}")
|
_append_firmware_log(session_id, f"✗ Firmware flash crashed: {exc!r}")
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
@@ -2296,115 +2562,51 @@ def _run_firmware_build_flash_background(session_id: str):
|
|||||||
host = str(session.get("host") or "")
|
host = str(session.get("host") or "")
|
||||||
port = int(session.get("port") or FIRMWARE_DEFAULT_OTA_PORT)
|
port = int(session.get("port") or FIRMWARE_DEFAULT_OTA_PORT)
|
||||||
template_key = str(session.get("template_key") or "")
|
template_key = str(session.get("template_key") or "")
|
||||||
values = session.get("values") if isinstance(session.get("values"), dict) else {}
|
template_label = str(session.get("template_label") or template_key)
|
||||||
|
|
||||||
if shutil.which("patch") is None:
|
_append_firmware_log(session_id, "===== Prebuilt Firmware Flash Console =====")
|
||||||
_append_firmware_log(session_id, "✗ Firmware build cannot start: required system command 'patch' was not found.")
|
_append_firmware_log(session_id, f"→ Firmware: {template_label}")
|
||||||
_append_firmware_log(
|
|
||||||
session_id,
|
|
||||||
"Tip: rebuild the Nvidia Docker image so it includes the patch utility required by ESP-IDF micro-opus.",
|
|
||||||
)
|
|
||||||
with FIRMWARE_LOCK:
|
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
|
||||||
if isinstance(live, dict):
|
|
||||||
live["running"] = False
|
|
||||||
live["exit_code"] = 997
|
|
||||||
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
live["message"] = "Firmware build dependency missing: patch."
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
config_path, normalized, build_path = _render_firmware_config(template_key, values, host, session_id, port)
|
|
||||||
except Exception as exc:
|
|
||||||
_append_firmware_log(session_id, f"✗ Failed to prepare firmware config: {exc}")
|
|
||||||
with FIRMWARE_LOCK:
|
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
|
||||||
if isinstance(live, dict):
|
|
||||||
live["running"] = False
|
|
||||||
live["exit_code"] = 998
|
|
||||||
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
||||||
live["message"] = f"Firmware config failed: {exc}"
|
|
||||||
return
|
|
||||||
|
|
||||||
command = [
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"esphome",
|
|
||||||
"run",
|
|
||||||
str(config_path),
|
|
||||||
"--no-logs",
|
|
||||||
"--device",
|
|
||||||
host,
|
|
||||||
]
|
|
||||||
|
|
||||||
_append_firmware_log(session_id, "===== Firmware Build + Flash Console =====")
|
|
||||||
_append_firmware_log(session_id, f"→ Template: {template_key}")
|
|
||||||
_append_firmware_log(session_id, f"→ Device: {host}:{port}")
|
_append_firmware_log(session_id, f"→ Device: {host}:{port}")
|
||||||
_append_firmware_log(session_id, f"→ Config: {config_path}")
|
_append_firmware_log(session_id, "→ Loading latest prebuilt firmware manifest...")
|
||||||
_append_firmware_log(session_id, f"→ Build cache: {build_path}")
|
|
||||||
if normalized.get("wake_word_triggered_sound_file"):
|
|
||||||
_append_firmware_log(session_id, f"→ Wake sound: {normalized['wake_word_triggered_sound_file']}")
|
|
||||||
_append_firmware_log(session_id, "→ Running: " + " ".join(command))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
env = _firmware_runner_env(include_esphome_pythonpath=True)
|
prebuilt = _prebuilt_firmware_info(template_key, force_refresh=True)
|
||||||
proc = subprocess.Popen(
|
if not bool(prebuilt.get("available")):
|
||||||
command,
|
raise RuntimeError(_text(prebuilt.get("error")) or "No prebuilt OTA image is available for this firmware target.")
|
||||||
cwd=str(ROOT_DIR),
|
firmware_version = _text(prebuilt.get("version")) or "latest"
|
||||||
stdout=subprocess.PIPE,
|
_append_firmware_log(session_id, f"→ Latest firmware: {firmware_version}")
|
||||||
stderr=subprocess.STDOUT,
|
_append_firmware_log(session_id, "→ Downloading or reusing verified OTA image...")
|
||||||
text=True,
|
binary = _download_prebuilt_firmware_binary(template_key, prebuilt, "ota")
|
||||||
bufsize=1,
|
cached_text = "cached" if bool(binary.get("cached")) else "downloaded"
|
||||||
env=env,
|
_append_firmware_log(session_id, f"→ OTA image {cached_text}: {Path(binary['path']).name}")
|
||||||
)
|
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
live = FIRMWARE_SESSIONS.get(session_id)
|
||||||
if isinstance(live, dict):
|
if isinstance(live, dict):
|
||||||
live["pid"] = int(proc.pid or 0)
|
live["message"] = "Firmware upload running."
|
||||||
live["message"] = "Firmware build + flash running."
|
live["filename"] = Path(binary["path"]).name
|
||||||
live["config_path"] = str(config_path)
|
live["firmware_version"] = firmware_version
|
||||||
|
|
||||||
assert proc.stdout is not None
|
def progress(percent: int, sent: int, total: int) -> None:
|
||||||
for line in proc.stdout:
|
_append_firmware_log(session_id, f"→ OTA upload progress: {percent}% ({sent}/{total} bytes)")
|
||||||
for part in line.replace("\r", "\n").splitlines():
|
|
||||||
_append_firmware_log(session_id, part)
|
|
||||||
rc = proc.wait()
|
|
||||||
|
|
||||||
if rc == 0:
|
uploaded_host = _native_ota_upload(host, port, Path(binary["path"]), progress_callback=progress)
|
||||||
_append_firmware_log(session_id, f"✓ Firmware build + flash finished (exit_code={rc})")
|
_append_firmware_log(session_id, f"✓ Prebuilt firmware uploaded successfully to {uploaded_host or host}")
|
||||||
else:
|
|
||||||
with FIRMWARE_LOCK:
|
|
||||||
live_lines = list((FIRMWARE_SESSIONS.get(session_id) or {}).get("log_lines") or [])
|
|
||||||
joined_lines = "\n".join(live_lines)
|
|
||||||
if "uv installation via pip failed" in joined_lines or "Failed to install Python dependencies into penv" in joined_lines:
|
|
||||||
_append_firmware_log(
|
|
||||||
session_id,
|
|
||||||
"Tip: PlatformIO's ESP-IDF Python environment crashed while installing dependencies. "
|
|
||||||
"Run Clean Build Files once, then retry the flash.",
|
|
||||||
)
|
|
||||||
if "pioarduino/registry" in joined_lines and "ninja-" in joined_lines and "status code '502'" in joined_lines:
|
|
||||||
_append_firmware_log(
|
|
||||||
session_id,
|
|
||||||
"Tip: GitHub returned a 502 while PlatformIO was downloading Ninja. "
|
|
||||||
"This is an upstream package download failure; retry the build in a few minutes.",
|
|
||||||
)
|
|
||||||
_append_firmware_log(session_id, f"✗ Firmware build + flash failed (exit_code={rc})")
|
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
live = FIRMWARE_SESSIONS.get(session_id)
|
||||||
if isinstance(live, dict):
|
if isinstance(live, dict):
|
||||||
live["running"] = False
|
live["running"] = False
|
||||||
live["exit_code"] = int(rc)
|
live["exit_code"] = 0
|
||||||
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
live["message"] = "Firmware flashed successfully." if rc == 0 else f"Firmware build + flash failed with exit code {rc}."
|
live["message"] = "Firmware uploaded successfully."
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_append_firmware_log(session_id, f"✗ Firmware build + flash crashed: {exc!r}")
|
_append_firmware_log(session_id, f"✗ Prebuilt firmware flash failed: {exc!r}")
|
||||||
with FIRMWARE_LOCK:
|
with FIRMWARE_LOCK:
|
||||||
live = FIRMWARE_SESSIONS.get(session_id)
|
live = FIRMWARE_SESSIONS.get(session_id)
|
||||||
if isinstance(live, dict):
|
if isinstance(live, dict):
|
||||||
live["running"] = False
|
live["running"] = False
|
||||||
live["exit_code"] = 999
|
live["exit_code"] = 999
|
||||||
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
live["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
live["message"] = f"Firmware build + flash crashed: {exc}"
|
live["message"] = f"Firmware flash failed: {exc}"
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_discovered_devices(devices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def _dedupe_discovered_devices(devices: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
@@ -3026,7 +3228,6 @@ def firmware_devices():
|
|||||||
@app.get("/api/firmware/templates")
|
@app.get("/api/firmware/templates")
|
||||||
def firmware_templates(request: Request, target_host: str = "", target_port: str = ""):
|
def firmware_templates(request: Request, target_host: str = "", target_port: str = ""):
|
||||||
templates = []
|
templates = []
|
||||||
warnings = []
|
|
||||||
base_url = _request_base_url(request)
|
base_url = _request_base_url(request)
|
||||||
wake_words = _list_trained_wake_words(base_url)
|
wake_words = _list_trained_wake_words(base_url)
|
||||||
selected_host, selected_port = _firmware_profile_target(target_host, target_port)
|
selected_host, selected_port = _firmware_profile_target(target_host, target_port)
|
||||||
@@ -3038,25 +3239,28 @@ def firmware_templates(request: Request, target_host: str = "", target_port: str
|
|||||||
row_target_port = selected_port or str(profile.get("__target_port") or "")
|
row_target_port = selected_port or str(profile.get("__target_port") or "")
|
||||||
if row_target_port == "6053":
|
if row_target_port == "6053":
|
||||||
row_target_port = str(FIRMWARE_DEFAULT_OTA_PORT)
|
row_target_port = str(FIRMWARE_DEFAULT_OTA_PORT)
|
||||||
|
prebuilt = _prebuilt_firmware_info(key)
|
||||||
|
prebuilt_summary = _prebuilt_artifact_ui_summary(prebuilt)
|
||||||
row = {
|
row = {
|
||||||
"value": key,
|
"value": key,
|
||||||
"label": str(spec.get("label") or key),
|
"label": str(spec.get("label") or key),
|
||||||
"source_url": _firmware_raw_url(str(spec.get("path") or "")),
|
"description": str(spec.get("description") or ""),
|
||||||
|
"source_url": _text(prebuilt.get("manifest_url")),
|
||||||
"target_host": row_target_host,
|
"target_host": row_target_host,
|
||||||
"target_port": row_target_port,
|
"target_port": row_target_port,
|
||||||
"fields": [],
|
"fields": [],
|
||||||
|
"prebuilt_firmware_available": bool(prebuilt_summary.get("available")),
|
||||||
|
"prebuilt_firmware": prebuilt_summary,
|
||||||
|
"firmware_version": _text(prebuilt.get("version")),
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
row["fields"] = _firmware_template_fields(key, base_url, profile_key)
|
|
||||||
except Exception as exc:
|
|
||||||
warnings.append(f"{row['label']}: {exc}")
|
|
||||||
templates.append(row)
|
templates.append(row)
|
||||||
|
active = next((row["value"] for row in templates if row.get("prebuilt_firmware_available")), "")
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"templates": templates,
|
"templates": templates,
|
||||||
"active_template_key": templates[0]["value"] if templates else "",
|
"active_template_key": active or (templates[0]["value"] if templates else ""),
|
||||||
"wake_words": wake_words,
|
"wake_words": wake_words,
|
||||||
"warnings": warnings,
|
"warnings": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3068,7 +3272,12 @@ def firmware_profile(payload: Dict[str, Any]):
|
|||||||
_firmware_template_spec(template_key)
|
_firmware_template_spec(template_key)
|
||||||
values = body.get("values") if isinstance(body.get("values"), dict) else {}
|
values = body.get("values") if isinstance(body.get("values"), dict) else {}
|
||||||
profile_key = _firmware_profile_key(template_key, values.get("__target_host"), values.get("__target_port"))
|
profile_key = _firmware_profile_key(template_key, values.get("__target_host"), values.get("__target_port"))
|
||||||
saved = _normalize_firmware_profile_update(template_key, values, profile_key)
|
host, port = _firmware_profile_target(values.get("__target_host"), values.get("__target_port"))
|
||||||
|
saved = {}
|
||||||
|
if host:
|
||||||
|
saved["__target_host"] = host
|
||||||
|
if port:
|
||||||
|
saved["__target_port"] = port
|
||||||
_save_firmware_profile(profile_key or template_key, saved)
|
_save_firmware_profile(profile_key or template_key, saved)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
||||||
@@ -3099,23 +3308,31 @@ def firmware_build_flash(payload: Dict[str, Any]):
|
|||||||
try:
|
try:
|
||||||
target_host, target_port = _parse_flash_target(str(body.get("host") or ""), body.get("port"))
|
target_host, target_port = _parse_flash_target(str(body.get("host") or ""), body.get("port"))
|
||||||
template_key = str(body.get("template_key") or "").strip()
|
template_key = str(body.get("template_key") or "").strip()
|
||||||
_firmware_template_spec(template_key)
|
template_spec = _firmware_template_spec(template_key)
|
||||||
|
prebuilt = _prebuilt_firmware_info(template_key)
|
||||||
|
if not bool(prebuilt.get("available")):
|
||||||
|
raise RuntimeError(_text(prebuilt.get("error")) or "No prebuilt OTA image is available for this firmware target.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
|
||||||
|
|
||||||
values = body.get("values") if isinstance(body.get("values"), dict) else {}
|
values = body.get("values") if isinstance(body.get("values"), dict) else {}
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
profile_key = _firmware_profile_key(template_key, target_host, target_port)
|
||||||
|
_save_firmware_profile(profile_key or template_key, {"__target_host": target_host, "__target_port": str(target_port)})
|
||||||
session_id = f"fw_{uuid.uuid4().hex}"
|
session_id = f"fw_{uuid.uuid4().hex}"
|
||||||
session = {
|
session = {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"mode": "build_flash",
|
"mode": "prebuilt_ota_flash",
|
||||||
"running": True,
|
"running": True,
|
||||||
"exit_code": None,
|
"exit_code": None,
|
||||||
"host": target_host,
|
"host": target_host,
|
||||||
"port": target_port,
|
"port": target_port,
|
||||||
"template_key": template_key,
|
"template_key": template_key,
|
||||||
|
"template_label": str(template_spec.get("label") or template_key),
|
||||||
|
"firmware_version": _text(prebuilt.get("version")),
|
||||||
"filename": "",
|
"filename": "",
|
||||||
"values": values,
|
"values": values,
|
||||||
"message": "Preparing firmware build + flash.",
|
"message": "Preparing prebuilt firmware flash.",
|
||||||
"log_lines": [],
|
"log_lines": [],
|
||||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"finished_at": None,
|
"finished_at": None,
|
||||||
@@ -3140,7 +3357,7 @@ def firmware_clean():
|
|||||||
return JSONResponse({"ok": False, "error": f"Wait for active firmware session(s) to finish: {', '.join(active[:3])}."}, status_code=400)
|
return JSONResponse({"ok": False, "error": f"Wait for active firmware session(s) to finish: {', '.join(active[:3])}."}, status_code=400)
|
||||||
|
|
||||||
removed = []
|
removed = []
|
||||||
for child in ("configs", "builds", "platformio", "home", "cache", "esphome_data"):
|
for child in ("prebuilt_firmware", "uploads"):
|
||||||
path = FIRMWARE_CACHE_DIR / child
|
path = FIRMWARE_CACHE_DIR / child
|
||||||
if path.exists():
|
if path.exists():
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
@@ -3148,7 +3365,7 @@ def firmware_clean():
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"message": "Cleaned firmware build files." if removed else "No firmware build files needed cleaning.",
|
"message": "Cleaned downloaded firmware images." if removed else "No downloaded firmware images needed cleaning.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3172,11 +3389,9 @@ async def firmware_flash(
|
|||||||
data = await file.read()
|
data = await file.read()
|
||||||
if not data:
|
if not data:
|
||||||
return JSONResponse({"ok": False, "error": "Firmware file is empty."}, status_code=400)
|
return JSONResponse({"ok": False, "error": "Firmware file is empty."}, status_code=400)
|
||||||
if not FIRMWARE_HELPER.exists():
|
|
||||||
return JSONResponse({"ok": False, "error": f"Firmware helper not found: {FIRMWARE_HELPER}"}, status_code=500)
|
|
||||||
|
|
||||||
session_id = f"fw_{uuid.uuid4().hex}"
|
session_id = f"fw_{uuid.uuid4().hex}"
|
||||||
session_dir = FIRMWARE_CACHE_DIR / session_id
|
session_dir = FIRMWARE_CACHE_DIR / "uploads" / session_id
|
||||||
session_dir.mkdir(parents=True, exist_ok=True)
|
session_dir.mkdir(parents=True, exist_ok=True)
|
||||||
firmware_path = session_dir / filename
|
firmware_path = session_dir / filename
|
||||||
firmware_path.write_bytes(data)
|
firmware_path.write_bytes(data)
|
||||||
|
|||||||
Reference in New Issue
Block a user