mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 16:05:34 -06:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b22200e0 |
75
README.md
75
README.md
@@ -22,7 +22,7 @@ docker pull ghcr.io/tatertotterson/microwakeword:latest
|
|||||||
Tagged releases also publish matching immutable image tags:
|
Tagged releases also publish matching immutable image tags:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/tatertotterson/microwakeword:v5
|
docker pull ghcr.io/tatertotterson/microwakeword:v10
|
||||||
```
|
```
|
||||||
|
|
||||||
RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
|
RTX 50-series / Blackwell GPUs use a separate image with CUDA 12.8 and a
|
||||||
@@ -30,7 +30,7 @@ Python 3.13 TensorFlow build for `sm_120`:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/tatertotterson/microwakeword:blackwell
|
docker pull ghcr.io/tatertotterson/microwakeword:blackwell
|
||||||
docker pull ghcr.io/tatertotterson/microwakeword:v5-blackwell
|
docker pull ghcr.io/tatertotterson/microwakeword:v10-blackwell
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the Blackwell image only for RTX 50-series cards. It includes the
|
Use the Blackwell image only for RTX 50-series cards. It includes the
|
||||||
@@ -51,15 +51,15 @@ 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`.
|
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v10` when you want to pin a known release instead of tracking `latest`.
|
||||||
For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
|
For RTX 50-series cards, use `ghcr.io/tatertotterson/microwakeword:blackwell`
|
||||||
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v5-blackwell`
|
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v10-blackwell`
|
||||||
in the same `docker run` command.
|
in the same `docker run` command.
|
||||||
|
|
||||||
The flags:
|
The flags:
|
||||||
|
|
||||||
- `--gpus all` enables GPU acceleration.
|
- `--gpus all` enables GPU acceleration.
|
||||||
- `--network host` lets the container receive mDNS/zeroconf traffic for ESPHome auto-detect.
|
- `--network host` lets the container receive mDNS/zeroconf traffic for device auto-detect.
|
||||||
- `-e REC_PORT=8789` sets the trainer web UI and captured-audio port. Change this value if `8789` is already in use.
|
- `-e REC_PORT=8789` sets the trainer web UI and captured-audio port. Change this value if `8789` is already in use.
|
||||||
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
|
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
|
||||||
|
|
||||||
@@ -71,14 +71,14 @@ Open:
|
|||||||
http://localhost:8789
|
http://localhost:8789
|
||||||
```
|
```
|
||||||
|
|
||||||
If you change `REC_PORT`, open that port instead and use the same port in the ESPHome `Trainer App URL`.
|
If you change `REC_PORT`, open that port instead and use the same port in the satellite `Trainer App URL`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What The UI Does
|
## What The UI Does
|
||||||
|
|
||||||
- `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 Tater Native or 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` pulls verified prebuilt Tater firmware images from GitHub and flashes supported satellites 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.
|
||||||
@@ -87,15 +87,21 @@ If you change `REC_PORT`, open that port instead and use the same port in the ES
|
|||||||
|
|
||||||
## 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 pull verified prebuilt OTA images from that repo for fast firmware updates.
|
To collect samples from a sat, point its trainer feedback setting at this app. Tater Native satellites use the native settings popup in Tater. Older ESPHome satellites can still use their device entities.
|
||||||
|
|
||||||
After flashing, the device exposes ESPHome entities for capture setup:
|
For Tater Native satellites, enable trainer feedback in Tater:
|
||||||
|
|
||||||
|
- `Send Good Wakes To Trainer` toggles upload of confirmed wake-word triggers.
|
||||||
|
- `Send Close Misses To Trainer` toggles upload of near misses.
|
||||||
|
- `Trainer App URL` sets the trainer address, for example `http://trainer.local:8789` or `http://<trainer-ip>:8789`.
|
||||||
|
|
||||||
|
For older ESPHome firmware, the equivalent capture setup is exposed as device entities:
|
||||||
|
|
||||||
- `Capture Wake Audio` toggles upload of wake-word triggers.
|
- `Capture Wake Audio` toggles upload of wake-word triggers.
|
||||||
- `Capture Close Misses` toggles upload of near misses.
|
- `Capture Close Misses` toggles upload of near misses.
|
||||||
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`.
|
- `Trainer App URL` sets the trainer address, for example `http://<trainer-ip>:8789`.
|
||||||
|
|
||||||
ESPHome devices can send raw captured audio to:
|
Satellites send raw captured audio to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/api/upload_captured_audio_raw
|
/api/upload_captured_audio_raw
|
||||||
@@ -203,18 +209,21 @@ After those assets are prepared, later runs reuse the local copies unless the mo
|
|||||||
|
|
||||||
## Firmware Flashing
|
## Firmware Flashing
|
||||||
|
|
||||||
The `Firmware` tab flashes prebuilt Tater firmware for supported ESPHome satellites.
|
The `Firmware` tab flashes prebuilt Tater firmware for supported satellites.
|
||||||
|
|
||||||
- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from `TaterTotterson/microWakeWords`.
|
- Downloads the latest prebuilt firmware manifest plus OTA and USB factory images from [`TaterTotterson/Tater-Native-Firmware`](https://github.com/TaterTotterson/Tater-Native-Firmware).
|
||||||
- Verifies downloaded images by size and SHA before upload.
|
- 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 compatible 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 the selected OTA target for each firmware family.
|
- Saves the selected OTA target for each firmware family.
|
||||||
- Flashes the prebuilt factory image over Browser USB for first installs or recovery when opened in Chrome or Edge.
|
- Flashes the prebuilt factory image over Browser USB for first installs or recovery when opened in Chrome or Edge.
|
||||||
|
- Leaves Wi-Fi, Tater server, and pairing setup to the satellite setup portal after USB flash.
|
||||||
- Lists locally trained wake words from `/data/trained_wake_words/` for live model switching.
|
- Lists locally trained wake words from `/data/trained_wake_words/` for live model switching.
|
||||||
- Streams download, verification, and OTA upload progress in a colorized firmware console.
|
- Streams download, verification, and OTA upload progress in a colorized firmware console.
|
||||||
|
|
||||||
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.
|
> **Tater only:** these native firmware images connect to Tater. They are not Home Assistant or ESPHome satellite firmware.
|
||||||
|
|
||||||
|
You usually only flash for firmware updates. New satellites, or devices not already running Tater Native Firmware `v1`, need one USB flash first before OTA updates and live wake-word switching are available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -236,6 +245,42 @@ The trainer also syncs firmware-ready artifacts into:
|
|||||||
|
|
||||||
The firmware tab uses `/data/trained_wake_words/` to populate the wake-word dropdown.
|
The firmware tab uses `/data/trained_wake_words/` to populate the wake-word dropdown.
|
||||||
|
|
||||||
|
The JSON keeps the standard microWakeWord fields for compatibility:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"micro": {
|
||||||
|
"probability_cutoff": 0.97,
|
||||||
|
"sliding_window_size": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It also includes Tater Native metadata used by newer satellites and the Tater settings UI:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_format": "tflite_stream_state_internal_quant",
|
||||||
|
"quantization": "int8",
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"tater_native": {
|
||||||
|
"format_version": 1,
|
||||||
|
"wake_threshold": 0.97,
|
||||||
|
"wake_sliding_window": 5,
|
||||||
|
"close_miss_threshold": 0.78,
|
||||||
|
"frontend": {
|
||||||
|
"name": "tflm_microfrontend",
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"feature_duration_ms": 30,
|
||||||
|
"feature_step_ms": 10,
|
||||||
|
"feature_size": 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Calibration metrics are included under `calibration` so false accepts/hour and recall can be surfaced in the UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resetting Everything
|
## Resetting Everything
|
||||||
@@ -261,7 +306,7 @@ That removes:
|
|||||||
- Negative samples are optional but useful for reducing false wakes.
|
- Negative samples are optional but useful for reducing false wakes.
|
||||||
- The UI server is `trainer_server.py`.
|
- The UI server is `trainer_server.py`.
|
||||||
- The launcher is `run.sh`.
|
- The launcher is `run.sh`.
|
||||||
- Firmware capture settings live on the ESPHome device and can be toggled from the device entities after flashing.
|
- Firmware capture settings live in Tater for Tater Native satellites, and on device entities for older ESPHome satellites.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ calibration_path = Path(os.environ.get("CALIBRATION_PATH", ""))
|
|||||||
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
|
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
|
||||||
probability_cutoff = 0.97
|
probability_cutoff = 0.97
|
||||||
sliding_window_size = 5
|
sliding_window_size = 5
|
||||||
|
calibration = {}
|
||||||
|
|
||||||
if calibration_path.exists():
|
if calibration_path.exists():
|
||||||
try:
|
try:
|
||||||
@@ -510,21 +511,60 @@ if calibration_path.exists():
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.")
|
print(f"⚠️ Failed to read detector calibration ({exc}); using defaults.")
|
||||||
|
|
||||||
|
probability_cutoff = round(probability_cutoff, 3)
|
||||||
|
sliding_window_size = max(1, min(10, int(sliding_window_size)))
|
||||||
|
selected_metrics = calibration.get("selected_metrics") if isinstance(calibration.get("selected_metrics"), dict) else {}
|
||||||
|
evaluation = calibration.get("evaluation") if isinstance(calibration.get("evaluation"), dict) else {}
|
||||||
|
close_miss_threshold = max(0.01, min(0.99, round(max(0.01, probability_cutoff - 0.19), 3)))
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"type": "micro",
|
"type": "micro",
|
||||||
"wake_word": os.environ["WAKE_WORD_TITLE"],
|
"wake_word": os.environ["WAKE_WORD_TITLE"],
|
||||||
|
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
|
||||||
"author": "Tater Totterson",
|
"author": "Tater Totterson",
|
||||||
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
|
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
|
||||||
"model": os.environ["TFLITE_FILENAME"],
|
"model": os.environ["TFLITE_FILENAME"],
|
||||||
"trained_languages": [language],
|
"trained_languages": [language],
|
||||||
"version": 2,
|
"version": 2,
|
||||||
|
"model_format": "tflite_stream_state_internal_quant",
|
||||||
|
"quantization": "int8",
|
||||||
|
"sample_rate": 16000,
|
||||||
"micro": {
|
"micro": {
|
||||||
"probability_cutoff": round(probability_cutoff, 2),
|
"probability_cutoff": probability_cutoff,
|
||||||
"sliding_window_size": sliding_window_size,
|
"sliding_window_size": sliding_window_size,
|
||||||
"feature_step_size": 10,
|
"feature_step_size": 10,
|
||||||
"tensor_arena_size": 30000,
|
"tensor_arena_size": 30000,
|
||||||
"minimum_esphome_version": "2024.7.0",
|
"minimum_esphome_version": "2024.7.0",
|
||||||
},
|
},
|
||||||
|
"tater_native": {
|
||||||
|
"format_version": 1,
|
||||||
|
"wake_threshold": probability_cutoff,
|
||||||
|
"wake_sliding_window": sliding_window_size,
|
||||||
|
"close_miss_threshold": close_miss_threshold,
|
||||||
|
"frontend": {
|
||||||
|
"name": "tflm_microfrontend",
|
||||||
|
"sample_rate": 16000,
|
||||||
|
"feature_duration_ms": 30,
|
||||||
|
"feature_step_ms": 10,
|
||||||
|
"feature_size": 40,
|
||||||
|
"input_feature_frames": 2,
|
||||||
|
"lower_band_limit": 125.0,
|
||||||
|
"upper_band_limit": 7500.0,
|
||||||
|
},
|
||||||
|
"recommended_for": ["tater-native-satellite", "voice-pe"],
|
||||||
|
},
|
||||||
|
"calibration": {
|
||||||
|
"target_false_accepts_per_hour": calibration.get("target_false_accepts_per_hour"),
|
||||||
|
"selected_false_accepts_per_hour_limit": calibration.get("selected_false_accepts_per_hour_limit"),
|
||||||
|
"recall": selected_metrics.get("recall"),
|
||||||
|
"false_accepts_per_hour": selected_metrics.get("false_accepts_per_hour"),
|
||||||
|
"ambient_hours": selected_metrics.get("ambient_hours"),
|
||||||
|
"positive_dataset": evaluation.get("positive_dataset"),
|
||||||
|
"ambient_dataset": evaluation.get("ambient_dataset"),
|
||||||
|
"positive_tracks": evaluation.get("positive_tracks"),
|
||||||
|
"ambient_tracks": evaluation.get("ambient_tracks"),
|
||||||
|
"generated_at": calibration.get("generated_at"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
|
json_path.write_text(json.dumps(meta, indent=4) + "\n", encoding="utf-8")
|
||||||
PY
|
PY
|
||||||
|
|||||||
@@ -262,6 +262,23 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.firmwareTaterOnlyNotice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-color: rgba(255,138,42,0.34);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255,138,42,0.15), rgba(255,255,255,0.035)),
|
||||||
|
var(--panel2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firmwareTaterOnlyNotice strong {
|
||||||
|
color: var(--orange2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.firmwareSteps {
|
.firmwareSteps {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -362,6 +379,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtimeWakeWordMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtimeWakeWordMeta .pill {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.runtimeWakeWordItem button {
|
.runtimeWakeWordItem button {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -509,34 +537,6 @@
|
|||||||
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;
|
||||||
@@ -1214,7 +1214,6 @@
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -1312,7 +1311,7 @@
|
|||||||
<div class="logo"></div>
|
<div class="logo"></div>
|
||||||
<div>
|
<div>
|
||||||
<h1>microWakeWord Trainer Studio</h1>
|
<h1>microWakeWord Trainer Studio</h1>
|
||||||
<p>Train wake words, review captured clips, and flash ESPHome firmware from one local workspace.</p>
|
<p>Train wake words, review captured clips, and flash Tater Native firmware from one local workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1555,7 +1554,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="firmwareKicker">Firmware Studio</div>
|
<div class="firmwareKicker">Firmware Studio</div>
|
||||||
<h3>Prebuilt Tater Firmware Flasher</h3>
|
<h3>Prebuilt Tater Firmware Flasher</h3>
|
||||||
<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>
|
<p>Flash only for firmware updates. If this is a new satellite or it is not already on Tater Native Firmware <code>v1</code>, 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>
|
||||||
@@ -1566,14 +1565,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card firmwareTaterOnlyNotice">
|
||||||
|
<strong>Tater only</strong>
|
||||||
|
<span>These native firmware images connect to Tater. They are not Home Assistant or ESPHome satellite firmware.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card runtimeWakeWordCard">
|
<div class="card runtimeWakeWordCard">
|
||||||
<div class="runtimeWakeWordHeader">
|
<div class="runtimeWakeWordHeader">
|
||||||
<div class="runtimeWakeWordTitle">
|
<div class="runtimeWakeWordTitle">
|
||||||
<span class="runtimeWakeWordBadge">3.0.3+</span>
|
<span class="runtimeWakeWordBadge">v1</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="firmwareKicker">Live Model Switching</div>
|
<div class="firmwareKicker">Live Model Switching</div>
|
||||||
<h3>Tater firmware 3.0.3 or higher can swap wake words live</h3>
|
<h3>Tater Native satellites can swap wake words live</h3>
|
||||||
<p>No reflash is needed after training. Copy a trained wake word JSON URL below and paste it into your satellite's Home Assistant <code>microWakeWord Model URL</code> entity.</p>
|
<p>No reflash is needed after training. Copy a trained wake word JSON URL below and paste it into the native satellite settings in Tater.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="pill ok">No reflash needed</span>
|
<span class="pill ok">No reflash needed</span>
|
||||||
@@ -1590,7 +1594,7 @@
|
|||||||
<span class="firmwareStepBadge">1</span>
|
<span class="firmwareStepBadge">1</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Firmware Image</h3>
|
<h3>Firmware Image</h3>
|
||||||
<p>Choose a prebuilt Tater firmware image from the shared firmware repo.</p>
|
<p>Choose a prebuilt Tater firmware image from the Tater Native Firmware releases.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1614,7 +1618,7 @@
|
|||||||
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
|
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="refreshFirmwareBtn" type="button">Auto-detect ESPHome devices</button>
|
<button id="refreshFirmwareBtn" type="button">Auto-detect Tater satellites</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="firmwareTargetGrid">
|
<div class="firmwareTargetGrid">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -1653,23 +1657,7 @@
|
|||||||
<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">
|
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices not already running Tater Native Firmware v1. After USB flash, join the satellite setup hotspot and finish Wi-Fi, Tater server, and pairing in the device setup page. Chrome or Edge must be used on localhost or HTTPS.</div>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2505,7 +2493,7 @@
|
|||||||
|
|
||||||
async function browserUsbSelectPort(selector = "") {
|
async function browserUsbSelectPort(selector = "") {
|
||||||
if (!window.isSecureContext || !navigator.serial) {
|
if (!window.isSecureContext || !navigator.serial) {
|
||||||
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
|
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. Open the trainer URL in Chrome or Edge.");
|
||||||
}
|
}
|
||||||
const port = await navigator.serial.requestPort();
|
const port = await navigator.serial.requestPort();
|
||||||
const key = String(selector || "default").trim() || "default";
|
const key = String(selector || "default").trim() || "default";
|
||||||
@@ -2513,13 +2501,6 @@
|
|||||||
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 = "") {
|
function browserUsbStoredPort(selector = "") {
|
||||||
const key = String(selector || "default").trim() || "default";
|
const key = String(selector || "default").trim() || "default";
|
||||||
return uiState.browserUsb.ports?.[key] || null;
|
return uiState.browserUsb.ports?.[key] || null;
|
||||||
@@ -2631,42 +2612,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -2756,344 +2701,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
||||||
@@ -3112,13 +2719,6 @@
|
|||||||
return $("firmwareDeviceSelect").value === FIRMWARE_USB_DEVICE_VALUE;
|
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;
|
||||||
@@ -3236,6 +2836,13 @@
|
|||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
|
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
|
||||||
url: String(item.json_url || "").trim(),
|
url: String(item.json_url || "").trim(),
|
||||||
|
threshold: item.threshold,
|
||||||
|
slidingWindow: item.sliding_window,
|
||||||
|
closeMissThreshold: item.close_miss_threshold,
|
||||||
|
quantization: String(item.quantization || "").trim(),
|
||||||
|
modelFormat: String(item.model_format || "").trim(),
|
||||||
|
recall: item.calibration_recall,
|
||||||
|
falseAcceptsPerHour: item.calibration_false_accepts_per_hour,
|
||||||
}))
|
}))
|
||||||
.filter((item) => item.url);
|
.filter((item) => item.url);
|
||||||
|
|
||||||
@@ -3244,15 +2851,27 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = rows.map((item) => `
|
container.innerHTML = rows.map((item) => {
|
||||||
|
const meta = [];
|
||||||
|
if (item.threshold !== null && item.threshold !== undefined) meta.push(`<span class="pill">threshold ${escapeHtml(item.threshold)}</span>`);
|
||||||
|
if (item.slidingWindow !== null && item.slidingWindow !== undefined) meta.push(`<span class="pill">window ${escapeHtml(item.slidingWindow)}</span>`);
|
||||||
|
if (item.closeMissThreshold !== null && item.closeMissThreshold !== undefined) meta.push(`<span class="pill">close miss ${escapeHtml(item.closeMissThreshold)}</span>`);
|
||||||
|
if (item.quantization) meta.push(`<span class="pill">${escapeHtml(item.quantization)}</span>`);
|
||||||
|
const recall = Number(item.recall);
|
||||||
|
const faph = Number(item.falseAcceptsPerHour);
|
||||||
|
if (Number.isFinite(recall)) meta.push(`<span class="pill">recall ${(recall * 100).toFixed(1)}%</span>`);
|
||||||
|
if (Number.isFinite(faph)) meta.push(`<span class="pill">FA/h ${escapeHtml(faph)}</span>`);
|
||||||
|
return `
|
||||||
<div class="runtimeWakeWordItem">
|
<div class="runtimeWakeWordItem">
|
||||||
<div>
|
<div>
|
||||||
<strong>${escapeHtml(item.label)}</strong>
|
<strong>${escapeHtml(item.label)}</strong>
|
||||||
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
|
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
|
||||||
|
${meta.length ? `<div class="runtimeWakeWordMeta">${meta.join("")}</div>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
|
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
`;
|
||||||
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyTextToClipboard(text) {
|
async function copyTextToClipboard(text) {
|
||||||
@@ -3633,7 +3252,7 @@
|
|||||||
syncRenderedWakeSoundSelection({ fromPicker: true });
|
syncRenderedWakeSoundSelection({ fromPicker: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
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.`);
|
const ok = confirm(`Flash prebuilt ${template.label || template.value} firmware to ${host}:${port || "3232"}?\n\nOnly continue if this device is already running Tater Native Firmware v1. New devices need one USB flash first.`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
uiState.firmwareBusy = true;
|
uiState.firmwareBusy = true;
|
||||||
@@ -3687,18 +3306,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.isSecureContext || !navigator.serial) {
|
if (!window.isSecureContext || !navigator.serial) {
|
||||||
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
|
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. Open the trainer URL in Chrome or Edge.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wifiEnabled = $("firmwareUsbWifiEnabled").checked;
|
const ok = confirm(`USB flash the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices not already running Tater Native Firmware v1. It erases flash and writes the factory image over USB. After flashing, finish Wi-Fi and pairing from the satellite setup page.`);
|
||||||
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;
|
||||||
@@ -3731,16 +3342,9 @@
|
|||||||
});
|
});
|
||||||
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) {
|
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
|
||||||
port = await browserUsbWaitForReconnect(port, template.value);
|
appendFirmwareLog("Use the satellite setup hotspot to configure Wi-Fi, Tater server, and pairing.");
|
||||||
await setupImprovWifi(port, { ssid: wifiSsid, password: wifiPassword });
|
setPill($("firmwareStatus"), "USB flash finished", "ok");
|
||||||
appendFirmwareLog("Browser USB flash and Wi-Fi setup finished.", "Browser USB flash and Wi-Fi setup finished.");
|
|
||||||
setPill($("firmwareStatus"), "USB flash + Wi-Fi finished", "ok");
|
|
||||||
} else {
|
|
||||||
appendFirmwareLog("Wi-Fi setup skipped by user.");
|
|
||||||
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
|
|
||||||
setPill($("firmwareStatus"), "USB flash finished", "ok");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} 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");
|
||||||
@@ -3827,10 +3431,6 @@
|
|||||||
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 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;
|
||||||
@@ -3846,7 +3446,7 @@
|
|||||||
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
|
||||||
$("openFirmwareConsoleBtn").disabled = false;
|
$("openFirmwareConsoleBtn").disabled = false;
|
||||||
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
|
$("flashFirmwareBtn").disabled = uiState.firmwareBusy || usbSelected || !firmwareHost || !firmwareTemplate || !firmwareAvailable;
|
||||||
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path || !usbWifiReady;
|
$("usbFirmwareBtn").disabled = uiState.firmwareBusy || !firmwareTemplate || !firmwareSelection?.prebuilt_firmware?.artifacts?.factory?.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSessionUI(session) {
|
function refreshSessionUI(session) {
|
||||||
@@ -4206,10 +3806,6 @@
|
|||||||
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();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import uuid
|
|||||||
import wave
|
import wave
|
||||||
from array import array
|
from array import array
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from math import log10
|
from math import isfinite, log10
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Callable, Optional, Tuple
|
from typing import Dict, Any, List, Callable, Optional, Tuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
@@ -80,12 +80,17 @@ 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_DEFAULT_OTA_PORT = int(os.environ.get("ESPHOME_OTA_PORT", "3232"))
|
FIRMWARE_DEFAULT_OTA_PORT = int(os.environ.get("TATER_NATIVE_OTA_PORT", 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("TATER_NATIVE_DISCOVERY_SECONDS", 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"))
|
||||||
FIRMWARE_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson")
|
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")
|
||||||
|
FIRMWARE_PREBUILT_GITHUB_OWNER = os.environ.get("FIRMWARE_PREBUILT_GITHUB_OWNER", "TaterTotterson")
|
||||||
|
FIRMWARE_PREBUILT_GITHUB_REPO = os.environ.get("FIRMWARE_PREBUILT_GITHUB_REPO", "Tater-Native-Firmware")
|
||||||
|
FIRMWARE_PREBUILT_GITHUB_REF = os.environ.get("FIRMWARE_PREBUILT_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_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
|
FIRMWARE_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
|
||||||
FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS = float(os.environ.get("FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS", "120"))
|
FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS = float(os.environ.get("FIRMWARE_DOWNLOAD_TIMEOUT_SECONDS", "120"))
|
||||||
@@ -106,37 +111,32 @@ TRAIN_LOG_MAX_BYTES = int(os.environ.get("REC_TRAIN_LOG_MAX_BYTES", str(512 * 10
|
|||||||
FIRMWARE_TEMPLATE_SPECS = (
|
FIRMWARE_TEMPLATE_SPECS = (
|
||||||
{
|
{
|
||||||
"key": "voicepe",
|
"key": "voicepe",
|
||||||
"label": "VoicePE",
|
"label": "Voice PE",
|
||||||
"description": "VoicePE satellite prebuilt firmware",
|
"description": "Tater Native firmware for Voice PE satellites",
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "satellite1",
|
"key": "satellite1",
|
||||||
"label": "Sat1",
|
"label": "Satellite1",
|
||||||
"description": "Satellite1 prebuilt firmware",
|
"description": "Tater Native firmware for Satellite1 devices",
|
||||||
"flash_size": "16MB",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "respeaker_lite",
|
|
||||||
"label": "ReSpeaker Lite",
|
|
||||||
"description": "ReSpeaker Lite prebuilt firmware",
|
|
||||||
"flash_size": "8MB",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "koala",
|
|
||||||
"label": "Koala Satellite",
|
|
||||||
"description": "Koala satellite prebuilt firmware",
|
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "respeaker_xvf3800",
|
"key": "respeaker_xvf3800",
|
||||||
"label": "ReSpeaker XVF3800",
|
"label": "ReSpeaker XVF3800",
|
||||||
"description": "ReSpeaker XVF3800 prebuilt firmware",
|
"description": "Tater Native firmware for ReSpeaker XVF3800 devices",
|
||||||
"flash_size": "8MB",
|
"flash_size": "8MB",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "s3_box",
|
||||||
|
"label": "ESP32-S3-BOX-3 Display",
|
||||||
|
"description": "Tater Native firmware for ESP32-S3-BOX-3 display satellites",
|
||||||
|
"flash_size": "16MB",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
FIRMWARE_PREBUILT_LATEST_URL = (
|
FIRMWARE_PREBUILT_LATEST_URL = os.environ.get(
|
||||||
f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/prebuilt_firmware/latest.json"
|
"FIRMWARE_PREBUILT_LATEST_URL",
|
||||||
|
f"https://github.com/{FIRMWARE_PREBUILT_GITHUB_OWNER}/{FIRMWARE_PREBUILT_GITHUB_REPO}/releases/latest/download/latest.json",
|
||||||
)
|
)
|
||||||
FIRMWARE_PREBUILT_TEMPLATE_KEYS = {str(spec.get("key") or "").lower() for spec in FIRMWARE_TEMPLATE_SPECS}
|
FIRMWARE_PREBUILT_TEMPLATE_KEYS = {str(spec.get("key") or "").lower() for spec in FIRMWARE_TEMPLATE_SPECS}
|
||||||
|
|
||||||
@@ -313,10 +313,27 @@ def _sync_trained_wake_word_artifacts() -> None:
|
|||||||
tflite_path.unlink()
|
tflite_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
|
def _metadata_float(value: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
out = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isfinite(out):
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, Any]]:
|
||||||
_sync_trained_wake_word_artifacts()
|
_sync_trained_wake_word_artifacts()
|
||||||
base = str(base_url or "").rstrip("/")
|
base = str(base_url or "").rstrip("/")
|
||||||
rows: List[Dict[str, str]] = []
|
rows: List[Dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
for json_path in sorted(TRAINED_WAKE_WORDS_DIR.glob("*.json")):
|
for json_path in sorted(TRAINED_WAKE_WORDS_DIR.glob("*.json")):
|
||||||
@@ -338,6 +355,18 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
|
|||||||
seen.add(safe)
|
seen.add(safe)
|
||||||
|
|
||||||
wake_word = str(meta.get("wake_word") or safe.replace("_", " ")).strip()
|
wake_word = str(meta.get("wake_word") or safe.replace("_", " ")).strip()
|
||||||
|
micro = meta.get("micro") if isinstance(meta.get("micro"), dict) else {}
|
||||||
|
native = meta.get("tater_native") if isinstance(meta.get("tater_native"), dict) else {}
|
||||||
|
calibration = meta.get("calibration") if isinstance(meta.get("calibration"), dict) else {}
|
||||||
|
threshold = _metadata_float(native.get("wake_threshold"))
|
||||||
|
if threshold is None:
|
||||||
|
threshold = _metadata_float(micro.get("probability_cutoff"))
|
||||||
|
sliding_window = _metadata_int(native.get("wake_sliding_window"))
|
||||||
|
if sliding_window is None:
|
||||||
|
sliding_window = _metadata_int(micro.get("sliding_window_size"))
|
||||||
|
close_miss_threshold = _metadata_float(native.get("close_miss_threshold"))
|
||||||
|
recall = _metadata_float(calibration.get("recall"))
|
||||||
|
false_accepts_per_hour = _metadata_float(calibration.get("false_accepts_per_hour"))
|
||||||
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
|
json_url = f"/api/trained_wake_words/{quote(json_path.name)}"
|
||||||
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
|
||||||
if base:
|
if base:
|
||||||
@@ -354,6 +383,17 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
|
|||||||
"model_url": model_url,
|
"model_url": model_url,
|
||||||
"json_file": json_path.name,
|
"json_file": json_path.name,
|
||||||
"model_file": model_path.name,
|
"model_file": model_path.name,
|
||||||
|
"threshold": round(threshold, 3) if threshold is not None else None,
|
||||||
|
"sliding_window": sliding_window,
|
||||||
|
"close_miss_threshold": round(close_miss_threshold, 3) if close_miss_threshold is not None else None,
|
||||||
|
"quantization": str(meta.get("quantization") or "").strip(),
|
||||||
|
"model_format": str(meta.get("model_format") or "").strip(),
|
||||||
|
"sample_rate": _metadata_int(meta.get("sample_rate")),
|
||||||
|
"calibration_recall": round(recall, 4) if recall is not None else None,
|
||||||
|
"calibration_false_accepts_per_hour": (
|
||||||
|
round(false_accepts_per_hour, 6) if false_accepts_per_hour is not None else None
|
||||||
|
),
|
||||||
|
"calibration_generated_at": str(calibration.get("generated_at") or "").strip(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
@@ -1546,7 +1586,10 @@ def _prebuilt_firmware_raw_url(path_or_url: Any) -> str:
|
|||||||
return token
|
return token
|
||||||
clean = token.lstrip("/")
|
clean = token.lstrip("/")
|
||||||
quoted = "/".join(quote(part) for part in clean.split("/") if part)
|
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}"
|
return (
|
||||||
|
f"https://raw.githubusercontent.com/"
|
||||||
|
f"{FIRMWARE_PREBUILT_GITHUB_OWNER}/{FIRMWARE_PREBUILT_GITHUB_REPO}/{FIRMWARE_PREBUILT_GITHUB_REF}/{quoted}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _fetch_json_url(url: str, *, timeout: float = 20, force_refresh: bool = False) -> Dict[str, Any]:
|
def _fetch_json_url(url: str, *, timeout: float = 20, force_refresh: bool = False) -> Dict[str, Any]:
|
||||||
@@ -2713,7 +2756,7 @@ def _dedupe_discovered_devices(devices: List[Dict[str, Any]]) -> List[Dict[str,
|
|||||||
clean_devices: List[Dict[str, Any]] = []
|
clean_devices: List[Dict[str, Any]] = []
|
||||||
for item in devices:
|
for item in devices:
|
||||||
host = str(item.get("host") or "").strip()
|
host = str(item.get("host") or "").strip()
|
||||||
name = str(item.get("name") or host or "ESPHome device").strip()
|
name = str(item.get("name") or host or "Tater satellite").strip()
|
||||||
if not host:
|
if not host:
|
||||||
continue
|
continue
|
||||||
key = (host.lower(), int(item.get("port") or FIRMWARE_DEFAULT_OTA_PORT))
|
key = (host.lower(), int(item.get("port") or FIRMWARE_DEFAULT_OTA_PORT))
|
||||||
@@ -2829,13 +2872,13 @@ def _discover_with_dns_sd(timeout_seconds: float) -> List[Dict[str, Any]]:
|
|||||||
def _discover_esphome_devices() -> tuple[List[Dict[str, Any]], str]:
|
def _discover_esphome_devices() -> tuple[List[Dict[str, Any]], str]:
|
||||||
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
|
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
|
||||||
if devices:
|
if devices:
|
||||||
return devices, f"Found {len(devices)} ESPHome device{'' if len(devices) == 1 else 's'} with mDNS."
|
return devices, f"Found {len(devices)} Tater native satellite{'' if len(devices) == 1 else 's'} with mDNS."
|
||||||
|
|
||||||
devices = _discover_with_dns_sd(FIRMWARE_DISCOVERY_SECONDS)
|
devices = _discover_with_dns_sd(FIRMWARE_DISCOVERY_SECONDS)
|
||||||
if devices:
|
if devices:
|
||||||
return devices, f"Found {len(devices)} ESPHome device{'' if len(devices) == 1 else 's'} with dns-sd."
|
return devices, f"Found {len(devices)} Tater native satellite{'' if len(devices) == 1 else 's'} with dns-sd."
|
||||||
|
|
||||||
return [], "No ESPHome devices were auto-detected. Enter the device IP or hostname manually."
|
return [], "No Tater native satellites were auto-detected. Enter the device IP or hostname manually."
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Routes --------------------
|
# -------------------- Routes --------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user