Point Docker trainer at native Tater firmware

This commit is contained in:
MasterPhooey
2026-07-11 08:35:38 -05:00
parent 89260f1f14
commit a1b22200e0
4 changed files with 243 additions and 519 deletions

View File

@@ -22,7 +22,7 @@ docker pull ghcr.io/tatertotterson/microwakeword:latest
Tagged releases also publish matching immutable image tags:
```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
@@ -30,7 +30,7 @@ Python 3.13 TensorFlow build for `sm_120`:
```bash
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
@@ -51,15 +51,15 @@ docker run -d \
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`
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.
The flags:
- `--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.
- `-v $(pwd):/data` persists models, downloaded voices, datasets, samples, and firmware caches.
@@ -71,14 +71,14 @@ Open:
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
- `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.
- `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.
@@ -87,15 +87,21 @@ If you change `REC_PORT`, open that port instead and use the same port in the ES
## 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 Close Misses` toggles upload of near misses.
- `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
/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
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.
- 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.
- 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.
- 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.
- 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 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
@@ -261,7 +306,7 @@ That removes:
- Negative samples are optional but useful for reducing false wakes.
- The UI server is `trainer_server.py`.
- 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.
---

View File

@@ -497,6 +497,7 @@ calibration_path = Path(os.environ.get("CALIBRATION_PATH", ""))
language = (os.environ.get("LANGUAGE", "en") or "en").strip().lower()
probability_cutoff = 0.97
sliding_window_size = 5
calibration = {}
if calibration_path.exists():
try:
@@ -510,21 +511,60 @@ if calibration_path.exists():
except Exception as exc:
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 = {
"type": "micro",
"wake_word": os.environ["WAKE_WORD_TITLE"],
"label": os.environ["WAKE_WORD_TITLE"].replace("_", " ").title(),
"author": "Tater Totterson",
"website": "https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git",
"model": os.environ["TFLITE_FILENAME"],
"trained_languages": [language],
"version": 2,
"model_format": "tflite_stream_state_internal_quant",
"quantization": "int8",
"sample_rate": 16000,
"micro": {
"probability_cutoff": round(probability_cutoff, 2),
"probability_cutoff": probability_cutoff,
"sliding_window_size": sliding_window_size,
"feature_step_size": 10,
"tensor_arena_size": 30000,
"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")
PY

View File

@@ -262,6 +262,23 @@
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 {
display: flex;
flex-wrap: wrap;
@@ -362,6 +379,17 @@
white-space: nowrap;
}
.runtimeWakeWordMeta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.runtimeWakeWordMeta .pill {
font-size: 11px;
}
.runtimeWakeWordItem button {
white-space: nowrap;
}
@@ -509,34 +537,6 @@
max-width: 420px;
}
.firmwareUsbWifiPanel {
grid-column: 1 / -1;
display: grid;
gap: 12px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(57,212,160,0.16);
background: rgba(57,212,160,0.055);
}
.firmwareUsbWifiToggle {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 700;
}
.firmwareUsbWifiToggle input {
width: auto;
}
.firmwareUsbWifiGrid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px;
}
.studioHero {
position: relative;
overflow: hidden;
@@ -1214,7 +1214,6 @@
.firmwareGrid { grid-template-columns: 1fr; }
.firmwareLayout,
.firmwareTargetGrid,
.firmwareUsbWifiGrid,
.firmwareActionsPanel {
grid-template-columns: 1fr;
}
@@ -1312,7 +1311,7 @@
<div class="logo"></div>
<div>
<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>
@@ -1555,7 +1554,7 @@
<div>
<div class="firmwareKicker">Firmware Studio</div>
<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">
<span class="firmwareStepChip"><b>1</b> Pick firmware</span>
<span class="firmwareStepChip"><b>2</b> Select target</span>
@@ -1566,14 +1565,19 @@
</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="runtimeWakeWordHeader">
<div class="runtimeWakeWordTitle">
<span class="runtimeWakeWordBadge">3.0.3+</span>
<span class="runtimeWakeWordBadge">v1</span>
<div>
<div class="firmwareKicker">Live Model Switching</div>
<h3>Tater firmware 3.0.3 or higher 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>
<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 the native satellite settings in Tater.</p>
</div>
</div>
<span class="pill ok">No reflash needed</span>
@@ -1590,7 +1594,7 @@
<span class="firmwareStepBadge">1</span>
<div>
<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>
@@ -1614,7 +1618,7 @@
<span id="firmwareDetectStatus" class="pill">Not scanned</span>
</div>
<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 class="firmwareTargetGrid">
<label class="field">
@@ -1653,23 +1657,7 @@
<button id="cleanFirmwareBtn" type="button">Clear downloaded images</button>
<button id="openFirmwareConsoleBtn" type="button">Open firmware console</button>
</div>
<section class="firmwareUsbWifiPanel" aria-label="Browser USB Wi-Fi setup">
<label class="firmwareUsbWifiToggle">
<input id="firmwareUsbWifiEnabled" type="checkbox" checked />
<span>Set up Wi-Fi after Browser USB Flash</span>
</label>
<div class="firmwareUsbWifiGrid">
<label class="field">
<strong>Wi-Fi SSID</strong>
<input id="firmwareUsbSsid" type="text" placeholder="Network name" autocomplete="off" />
</label>
<label class="field">
<strong>Wi-Fi Password</strong>
<input id="firmwareUsbPassword" type="password" placeholder="Network password" autocomplete="current-password" />
</label>
</div>
</section>
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices older than Tater firmware 3.0.3. Chrome or Edge must be used on localhost or HTTPS. In the macOS app, open <code>http://127.0.0.1:8789</code> in Chrome or Edge; port <code>3232</code> is only for OTA updates after firmware is installed.</div>
<div class="usbFlashHint">Use Browser USB Flash for new satellites, recovery, or devices 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>
</section>
</div>
</div>
@@ -2505,7 +2493,7 @@
async function browserUsbSelectPort(selector = "") {
if (!window.isSecureContext || !navigator.serial) {
throw new Error("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
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 key = String(selector || "default").trim() || "default";
@@ -2513,13 +2501,6 @@
return port;
}
async function browserUsbAuthorizedPorts() {
if (!window.isSecureContext || !navigator.serial || typeof navigator.serial.getPorts !== "function") {
return [];
}
return navigator.serial.getPorts();
}
function browserUsbStoredPort(selector = "") {
const key = String(selector || "default").trim() || "default";
return uiState.browserUsb.ports?.[key] || null;
@@ -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) {
const module = await ensureEsptoolJsLoaded();
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() {
$("consoleOverlay").classList.remove("open");
$("consoleOverlay").setAttribute("aria-hidden", "true");
@@ -3112,13 +2719,6 @@
return $("firmwareDeviceSelect").value === FIRMWARE_USB_DEVICE_VALUE;
}
function syncUsbFirmwareControls() {
const enabled = $("firmwareUsbWifiEnabled").checked;
$("firmwareUsbSsid").disabled = !enabled;
$("firmwareUsbPassword").disabled = !enabled;
$("usbFirmwareBtn").textContent = enabled ? "Flash + Set Up Wi-Fi" : "Browser USB Flash";
}
function renderFirmwareDevices(devices, message) {
const list = Array.isArray(devices) ? devices : [];
uiState.firmware.devices = list;
@@ -3236,6 +2836,13 @@
.map((item) => ({
label: String(item.label || item.wake_word || item.wake_word_name || item.key || "Trained wake word"),
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);
@@ -3244,15 +2851,27 @@
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>
<strong>${escapeHtml(item.label)}</strong>
<a class="runtimeWakeWordUrl" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">${escapeHtml(item.url)}</a>
${meta.length ? `<div class="runtimeWakeWordMeta">${meta.join("")}</div>` : ""}
</div>
<button type="button" data-runtime-wake-url="${escapeAttr(item.url)}">Copy URL</button>
</div>
`).join("");
`;
}).join("");
}
async function copyTextToClipboard(text) {
@@ -3633,7 +3252,7 @@
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;
uiState.firmwareBusy = true;
@@ -3687,18 +3306,10 @@
return;
}
if (!window.isSecureContext || !navigator.serial) {
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. In the macOS app, use Open in Browser and choose Chrome or Edge.");
alert("Browser USB flash needs Chrome or Edge on HTTPS or localhost. Open the trainer URL in Chrome or Edge.");
return;
}
const wifiEnabled = $("firmwareUsbWifiEnabled").checked;
const wifiSsid = ($("firmwareUsbSsid").value || "").trim();
const wifiPassword = $("firmwareUsbPassword").value || "";
if (wifiEnabled && !wifiSsid) {
alert("Enter a Wi-Fi SSID or turn off Wi-Fi setup before Browser USB Flash.");
return;
}
const actionLabel = wifiEnabled ? "USB flash and set up Wi-Fi" : "USB flash";
const ok = confirm(`${actionLabel} for the prebuilt ${template.label || template.value} factory firmware?\n\nThis is for new satellites, recovery, or devices older than Tater firmware 3.0.3. It erases flash and writes the factory image over USB.`);
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.`);
if (!ok) return;
let port;
@@ -3731,16 +3342,9 @@
});
appendFirmwareLogs(artifact.entries || [], "Factory image ready. Starting USB flash...");
await flashBrowserUsbPort(port, artifact);
if (wifiEnabled) {
port = await browserUsbWaitForReconnect(port, template.value);
await setupImprovWifi(port, { ssid: wifiSsid, password: wifiPassword });
appendFirmwareLog("Browser USB flash and Wi-Fi setup finished.", "Browser USB flash and Wi-Fi setup finished.");
setPill($("firmwareStatus"), "USB flash + Wi-Fi finished", "ok");
} else {
appendFirmwareLog("Wi-Fi setup skipped by user.");
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
setPill($("firmwareStatus"), "USB flash finished", "ok");
}
appendFirmwareLog("Browser USB flash finished.", "Browser USB flash finished.");
appendFirmwareLog("Use the satellite setup hotspot to configure Wi-Fi, Tater server, and pairing.");
setPill($("firmwareStatus"), "USB flash finished", "ok");
} catch (error) {
appendFirmwareLog(`Browser USB flash failed: ${String(error.message || error)}`, "Browser USB flash failed.");
setPill($("firmwareStatus"), "USB flash failed", "err");
@@ -3827,10 +3431,6 @@
const firmwareSelection = selectedFirmwareTemplate();
const firmwareAvailable = !firmwareSelection?.prebuilt_firmware || Boolean(firmwareSelection.prebuilt_firmware.available);
const usbSelected = firmwareUsbSelected();
const usbWifiEnabled = $("firmwareUsbWifiEnabled").checked;
const usbWifiSsid = ($("firmwareUsbSsid").value || "").trim();
const usbWifiReady = !usbWifiEnabled || Boolean(usbWifiSsid);
syncUsbFirmwareControls();
$("ttsBtn").disabled = !hasPhrase || uiState.uploadBusy;
$("uploadBtn").disabled = !hasSession || !hasSelected || uiState.uploadBusy;
@@ -3846,7 +3446,7 @@
$("cleanFirmwareBtn").disabled = uiState.firmwareBusy;
$("openFirmwareConsoleBtn").disabled = false;
$("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) {
@@ -4206,10 +3806,6 @@
alert("Device settings failed: " + error.message);
});
});
["firmwareUsbWifiEnabled", "firmwareUsbSsid", "firmwareUsbPassword"].forEach((id) => {
$(id).addEventListener("input", syncButtons);
$(id).addEventListener("change", syncButtons);
});
$("refreshFirmwareBtn").addEventListener("click", async () => {
try {
await refreshFirmwareDevices();

View File

@@ -20,7 +20,7 @@ import uuid
import wave
from array import array
from datetime import datetime, timezone
from math import log10
from math import isfinite, log10
from pathlib import Path
from typing import Dict, Any, List, Callable, Optional, Tuple
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_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_DISCOVERY_SECONDS = float(os.environ.get("ESPHOME_DISCOVERY_SECONDS", "2.5"))
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("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_GITHUB_OWNER = os.environ.get("FIRMWARE_GITHUB_OWNER", "TaterTotterson")
FIRMWARE_GITHUB_REPO = os.environ.get("FIRMWARE_GITHUB_REPO", "microWakeWords")
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"))
FIRMWARE_PREBUILT_DIR = FIRMWARE_CACHE_DIR / "prebuilt_firmware"
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 = (
{
"key": "voicepe",
"label": "VoicePE",
"description": "VoicePE satellite prebuilt firmware",
"label": "Voice PE",
"description": "Tater Native firmware for Voice PE satellites",
"flash_size": "16MB",
},
{
"key": "satellite1",
"label": "Sat1",
"description": "Satellite1 prebuilt firmware",
"flash_size": "16MB",
},
{
"key": "respeaker_lite",
"label": "ReSpeaker Lite",
"description": "ReSpeaker Lite prebuilt firmware",
"flash_size": "8MB",
},
{
"key": "koala",
"label": "Koala Satellite",
"description": "Koala satellite prebuilt firmware",
"label": "Satellite1",
"description": "Tater Native firmware for Satellite1 devices",
"flash_size": "16MB",
},
{
"key": "respeaker_xvf3800",
"label": "ReSpeaker XVF3800",
"description": "ReSpeaker XVF3800 prebuilt firmware",
"description": "Tater Native firmware for ReSpeaker XVF3800 devices",
"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 = (
f"https://raw.githubusercontent.com/{FIRMWARE_GITHUB_OWNER}/{FIRMWARE_GITHUB_REPO}/{FIRMWARE_GITHUB_REF}/prebuilt_firmware/latest.json"
FIRMWARE_PREBUILT_LATEST_URL = os.environ.get(
"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}
@@ -313,10 +313,27 @@ def _sync_trained_wake_word_artifacts() -> None:
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()
base = str(base_url or "").rstrip("/")
rows: List[Dict[str, str]] = []
rows: List[Dict[str, Any]] = []
seen: set[str] = set()
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)
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)}"
model_url = f"/api/trained_wake_words/{quote(model_path.name)}"
if base:
@@ -354,6 +383,17 @@ def _list_trained_wake_words(base_url: str = "") -> List[Dict[str, str]]:
"model_url": model_url,
"json_file": json_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
@@ -1546,7 +1586,10 @@ def _prebuilt_firmware_raw_url(path_or_url: Any) -> str:
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}"
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]:
@@ -2713,7 +2756,7 @@ def _dedupe_discovered_devices(devices: List[Dict[str, Any]]) -> List[Dict[str,
clean_devices: List[Dict[str, Any]] = []
for item in devices:
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:
continue
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]:
devices = _discover_with_zeroconf(FIRMWARE_DISCOVERY_SECONDS)
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)
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 --------------------