mirror of
https://github.com/TaterTotterson/microWakeWord-Trainer-Nvidia-Docker.git
synced 2026-08-12 16:05:34 -06:00
Release NVIDIA WakeWord Trainer v15
This commit is contained in:
12
README.md
12
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:v14
|
docker pull ghcr.io/tatertotterson/microwakeword:v15
|
||||||
```
|
```
|
||||||
|
|
||||||
The release tag must match `VERSION`. Update `WHATS_NEW.md` before tagging; the Docker workflow prepends it to GitHub's automatically generated release notes.
|
The release tag must match `VERSION`. Update `WHATS_NEW.md` before tagging; the Docker workflow prepends it to GitHub's automatically generated release notes.
|
||||||
@@ -32,7 +32,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:v14-blackwell
|
docker pull ghcr.io/tatertotterson/microwakeword:v15-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
|
||||||
@@ -53,9 +53,9 @@ docker run -d \
|
|||||||
ghcr.io/tatertotterson/microwakeword:latest
|
ghcr.io/tatertotterson/microwakeword:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v14` when you want to pin a known release instead of tracking `latest`.
|
Use a version tag such as `ghcr.io/tatertotterson/microwakeword:v15` 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:v14-blackwell`
|
or a pinned tag such as `ghcr.io/tatertotterson/microwakeword:v15-blackwell`
|
||||||
in the same `docker run` command.
|
in the same `docker run` command.
|
||||||
|
|
||||||
The flags:
|
The flags:
|
||||||
@@ -185,11 +185,11 @@ A close miss with an empty transcript or without the configured phrase stays in
|
|||||||
|
|
||||||
The default `small.en` model uses CUDA with `float16` when CTranslate2 can see an NVIDIA GPU, and falls back to CPU with `int8`. Choose a multilingual Faster Whisper model such as `small` when the wake phrase is not English. Downloaded STT models are cached in `/data/auto_train_models/`.
|
The default `small.en` model uses CUDA with `float16` when CTranslate2 can see an NVIDIA GPU, and falls back to CPU with `int8`. Choose a multilingual Faster Whisper model such as `small` when the wake phrase is not English. Downloaded STT models are cached in `/data/auto_train_models/`.
|
||||||
|
|
||||||
Scheduled training runs only after the configured number of new automatic negatives has accumulated. A successful run publishes the replacement model at the same wake-word URL, asks Tater for its connected native satellites, and re-saves each satellite's current wake profile so its JSON tuning and model are fetched again. This refresh uses the existing Tater Native update path, so no satellite firmware change is required.
|
Scheduled training runs only after the configured number of new automatic negatives has accumulated. A successful run securely publishes the trained wake-word name and JSON URL to the linked Tater instance. Tater saves it as the global satellite wake word and pushes the updated setting to every connected satellite, so no satellite firmware change is required.
|
||||||
|
|
||||||
The `Trainer public URL` must be reachable from the satellites. With the documented `--network host` command, the trainer can normally use the LAN address from the browser request or host network. If you open the UI as `http://localhost:8789`, enter a value such as `http://192.168.1.50:8789`, or start the container with `REC_PUBLIC_BASE_URL` set to that value. When using Docker bridge networking, always set this URL to the published host address; a container bridge address is not satellite-reachable.
|
The `Trainer public URL` must be reachable from the satellites. With the documented `--network host` command, the trainer can normally use the LAN address from the browser request or host network. If you open the UI as `http://localhost:8789`, enter a value such as `http://192.168.1.50:8789`, or start the container with `REC_PUBLIC_BASE_URL` set to that value. When using Docker bridge networking, always set this URL to the published host address; a container bridge address is not satellite-reachable.
|
||||||
|
|
||||||
The default Tater URL, `http://127.0.0.1:8501`, assumes the documented host networking. Change it to a container-reachable Tater address if you use another Docker network. The optional API token is stored in `/data/auto_train_config.json` with owner-only permissions.
|
The default Tater URL, `http://127.0.0.1:8501`, assumes the documented host networking. Change it to a container-reachable Tater address if you use another Docker network. Click `Link Tater` and enter the short-lived code shown in Tater Voice Settings; the resulting trainer-specific link credential is stored in `/data/auto_train_config.json` with owner-only permissions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
- Fixed automatic and manual satellite refresh so every connected satellite re-fetches its current custom wake JSON profile before reloading the model.
|
- Added secure Tater linking: enter the short-lived code from Tater Voice Settings instead of giving the trainer a general API token.
|
||||||
- The large WHAM augmentation dataset download now reports visible progress in the training log.
|
- Automatic and manual publishing now tell Tater which trained wake word is active, and Tater applies it globally to every connected satellite.
|
||||||
|
- Added clear linked, unlinked, and pairing-success states to the Auto Training interface.
|
||||||
|
|||||||
@@ -1084,6 +1084,43 @@
|
|||||||
.trimActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
.trimActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
.trimActions button { flex: 1; min-width: 120px; }
|
.trimActions button { flex: 1; min-width: 120px; }
|
||||||
|
|
||||||
|
.taterLinkOverlay {
|
||||||
|
position: fixed; inset: 0; padding: 22px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(4,5,10,0.62); backdrop-filter: blur(12px);
|
||||||
|
opacity: 0; visibility: hidden; pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, visibility 0.18s ease;
|
||||||
|
z-index: 12000;
|
||||||
|
}
|
||||||
|
.taterLinkOverlay.open { opacity: 1; visibility: visible; pointer-events: auto; }
|
||||||
|
.taterLinkDialog {
|
||||||
|
width: min(560px, calc(100vw - 36px));
|
||||||
|
display: grid; gap: 18px; padding: 22px; border-radius: 24px;
|
||||||
|
border: 1px solid rgba(255,138,42,0.28);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255,138,42,0.16), transparent 46%),
|
||||||
|
linear-gradient(180deg, rgba(17,20,28,0.96), rgba(8,10,16,0.98));
|
||||||
|
box-shadow: 0 30px 90px rgba(0,0,0,0.64);
|
||||||
|
}
|
||||||
|
.taterLinkCodePanel {
|
||||||
|
display: grid; gap: 10px; text-align: center; padding: 24px;
|
||||||
|
border-radius: 18px; border: 1px solid rgba(255,138,42,0.28);
|
||||||
|
background: rgba(255,138,42,0.09);
|
||||||
|
}
|
||||||
|
.taterLinkCode {
|
||||||
|
color: var(--orange2); font: 800 clamp(30px, 8vw, 46px)/1 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
.taterLinkSuccess {
|
||||||
|
display: grid; justify-items: center; gap: 12px; padding: 28px; text-align: center;
|
||||||
|
}
|
||||||
|
.taterLinkSuccessMark {
|
||||||
|
display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%;
|
||||||
|
background: rgba(57,212,160,0.15); border: 1px solid rgba(57,212,160,0.42);
|
||||||
|
color: #6ee0af; font-size: 24px; font-weight: 900;
|
||||||
|
}
|
||||||
|
.taterLinkActions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||||
|
|
||||||
.pill.trimBadge {
|
.pill.trimBadge {
|
||||||
color: #89d4ff;
|
color: #89d4ff;
|
||||||
border-color: rgba(137,212,255,0.25);
|
border-color: rgba(137,212,255,0.25);
|
||||||
@@ -1399,8 +1436,8 @@
|
|||||||
<div class="studioPanelTitle">
|
<div class="studioPanelTitle">
|
||||||
<span class="studioStepBadge">3</span>
|
<span class="studioStepBadge">3</span>
|
||||||
<div>
|
<div>
|
||||||
<h3>Publish + Satellite Refresh</h3>
|
<h3>Publish to Tater</h3>
|
||||||
<p>The trainer publishes a LAN-reachable model URL, then asks Tater to re-push live settings so the firmware downloads the updated model at the same URL.</p>
|
<p>The trainer publishes a LAN-reachable model URL, then tells Tater to make the newly trained wake word active on every satellite.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1414,22 +1451,15 @@
|
|||||||
<strong>Tater URL</strong>
|
<strong>Tater URL</strong>
|
||||||
<input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" />
|
<input id="autoTaterUrl" type="text" value="http://127.0.0.1:8501" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
</div>
|
||||||
<strong>Satellite selector (optional)</strong>
|
<div class="taterLinkActions">
|
||||||
<input id="autoTaterSelector" type="text" placeholder="Blank refreshes all connected sats" />
|
<span id="autoTaterLinkStatus" class="pill">Not linked</span>
|
||||||
</label>
|
<button id="autoLinkTaterBtn" class="primary" type="button">Link Tater</button>
|
||||||
<label class="field">
|
<button id="autoUnlinkTaterBtn" class="danger" type="button" hidden>Unlink</button>
|
||||||
<strong>Tater API token (if enabled)</strong>
|
|
||||||
<input id="autoTaterToken" type="password" placeholder="Not configured" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="checkField">
|
<label class="checkField">
|
||||||
<input id="autoNotifySatellites" type="checkbox" checked />
|
<input id="autoNotifySatellites" type="checkbox" checked />
|
||||||
<span><strong>Refresh satellites after successful training</strong>Uses Tater's existing native satellite settings API.</span>
|
<span><strong>Activate the new word after successful training</strong>Tater applies it globally and updates every connected satellite.</span>
|
||||||
</label>
|
|
||||||
<label id="autoClearTokenRow" class="checkField" hidden>
|
|
||||||
<input id="autoClearTaterToken" type="checkbox" />
|
|
||||||
<span><strong>Clear the saved Tater token</strong>The token is otherwise preserved when the password field is blank.</span>
|
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1438,7 +1468,7 @@
|
|||||||
<button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button>
|
<button id="autoSaveBtn" class="primary" type="button">Save Auto Training</button>
|
||||||
<button id="autoReviewNowBtn" type="button">Review inbox now</button>
|
<button id="autoReviewNowBtn" type="button">Review inbox now</button>
|
||||||
<button id="autoTrainNowBtn" type="button">Train now</button>
|
<button id="autoTrainNowBtn" type="button">Train now</button>
|
||||||
<button id="autoNotifyNowBtn" type="button">Refresh satellites now</button>
|
<button id="autoNotifyNowBtn" type="button">Publish current wake word now</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div>
|
<div id="autoAudit" class="autoAudit muted">No automatic review has run yet.</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1681,6 +1711,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="taterLinkOverlay" class="taterLinkOverlay" aria-hidden="true">
|
||||||
|
<div class="taterLinkDialog" role="dialog" aria-modal="true" aria-labelledby="taterLinkTitle">
|
||||||
|
<div class="trimHeader">
|
||||||
|
<div>
|
||||||
|
<h3 id="taterLinkTitle" class="trimTitle">Link Tater</h3>
|
||||||
|
<p id="taterLinkHint" class="trimHint">Enter the short-lived code shown in Tater Voice Settings.</p>
|
||||||
|
</div>
|
||||||
|
<button id="closeTaterLinkBtn" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div id="taterLinkBody">
|
||||||
|
<div class="emptyState">Enter the secure pairing code from Tater.</div>
|
||||||
|
</div>
|
||||||
|
<div id="taterLinkModalStatus" class="muted">Waiting for the Tater code.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
@@ -2038,6 +2084,7 @@
|
|||||||
const config = data.config || {};
|
const config = data.config || {};
|
||||||
const state = data.state || {};
|
const state = data.state || {};
|
||||||
const runtime = data.runtime || {};
|
const runtime = data.runtime || {};
|
||||||
|
const trainerLink = data.trainer_link || {};
|
||||||
uiState.autoTrain = data;
|
uiState.autoTrain = data;
|
||||||
|
|
||||||
if (populateForm) {
|
if (populateForm) {
|
||||||
@@ -2054,14 +2101,18 @@
|
|||||||
$("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3);
|
$("autoMinimumNegatives").value = String(config.minimum_new_negatives ?? 3);
|
||||||
$("autoAdvertisedUrl").value = config.advertised_base_url || "";
|
$("autoAdvertisedUrl").value = config.advertised_base_url || "";
|
||||||
$("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501";
|
$("autoTaterUrl").value = config.tater_url || "http://127.0.0.1:8501";
|
||||||
$("autoTaterSelector").value = config.tater_selector || "";
|
|
||||||
$("autoTaterToken").value = "";
|
|
||||||
$("autoTaterToken").placeholder = config.tater_api_token_configured ? "Saved token (leave blank to keep)" : "Not configured";
|
|
||||||
$("autoNotifySatellites").checked = config.notify_satellites !== false;
|
$("autoNotifySatellites").checked = config.notify_satellites !== false;
|
||||||
$("autoClearTokenRow").hidden = !config.tater_api_token_configured;
|
|
||||||
$("autoClearTaterToken").checked = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taterLinked = Boolean(trainerLink.linked);
|
||||||
|
setPill(
|
||||||
|
$("autoTaterLinkStatus"),
|
||||||
|
taterLinked ? `Linked${trainerLink.tater_name ? ` to ${trainerLink.tater_name}` : ""}` : "Not linked",
|
||||||
|
taterLinked ? "ok" : "warn"
|
||||||
|
);
|
||||||
|
$("autoLinkTaterBtn").textContent = taterLinked ? "Relink Tater" : "Link Tater";
|
||||||
|
$("autoUnlinkTaterBtn").hidden = !taterLinked;
|
||||||
|
|
||||||
$("autoDetectedUrl").textContent = config.advertised_base_url
|
$("autoDetectedUrl").textContent = config.advertised_base_url
|
||||||
? `Using configured URL: ${config.advertised_base_url}`
|
? `Using configured URL: ${config.advertised_base_url}`
|
||||||
: `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`;
|
: `Auto-detected URL: ${data.advertised_base_url || "unavailable"}`;
|
||||||
@@ -2090,8 +2141,8 @@
|
|||||||
if (state.last_stt_device) audit.push(`STT runtime: ${state.last_stt_device} / ${state.last_stt_compute_type || "default"}`);
|
if (state.last_stt_device) audit.push(`STT runtime: ${state.last_stt_device} / ${state.last_stt_compute_type || "default"}`);
|
||||||
if (state.last_notify_at) {
|
if (state.last_notify_at) {
|
||||||
audit.push(state.last_notify_error
|
audit.push(state.last_notify_error
|
||||||
? `Satellite refresh failed: ${state.last_notify_error}`
|
? `Wake-word publish failed: ${state.last_notify_error}`
|
||||||
: `Satellite refresh: ${state.last_notify_count ?? "requested"} connected at ${formatTimestamp(state.last_notify_at)}`);
|
: `Wake word published to ${state.last_notify_count ?? "all"} connected satellite(s) at ${formatTimestamp(state.last_notify_at)}`);
|
||||||
}
|
}
|
||||||
$("autoAudit").textContent = audit.join(" · ") || "No automatic review has run yet.";
|
$("autoAudit").textContent = audit.join(" · ") || "No automatic review has run yet.";
|
||||||
syncButtons();
|
syncButtons();
|
||||||
@@ -2118,12 +2169,8 @@
|
|||||||
minimum_new_negatives: Number($("autoMinimumNegatives").value || 3),
|
minimum_new_negatives: Number($("autoMinimumNegatives").value || 3),
|
||||||
advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(),
|
advertised_base_url: ($("autoAdvertisedUrl").value || "").trim(),
|
||||||
tater_url: ($("autoTaterUrl").value || "").trim(),
|
tater_url: ($("autoTaterUrl").value || "").trim(),
|
||||||
tater_selector: ($("autoTaterSelector").value || "").trim(),
|
|
||||||
notify_satellites: $("autoNotifySatellites").checked,
|
notify_satellites: $("autoNotifySatellites").checked,
|
||||||
clear_tater_api_token: $("autoClearTaterToken").checked,
|
|
||||||
};
|
};
|
||||||
const token = ($("autoTaterToken").value || "").trim();
|
|
||||||
if (token) payload.tater_api_token = token;
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2165,7 +2212,7 @@
|
|||||||
await refreshSession();
|
await refreshSession();
|
||||||
pollTraining();
|
pollTraining();
|
||||||
} else {
|
} else {
|
||||||
setPill($("autoStatus"), `Satellite refresh requested${data.count === null || data.count === undefined ? "" : ` for ${data.count}`}`, "ok");
|
setPill($("autoStatus"), `Wake word published${data.count === null || data.count === undefined ? "" : ` to ${data.count} satellite(s)`}`, "ok");
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2174,6 +2221,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeTaterLinkModal() {
|
||||||
|
$("taterLinkOverlay").classList.remove("open");
|
||||||
|
$("taterLinkOverlay").setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTaterLinkSuccess(status) {
|
||||||
|
const taterName = status?.tater_name ? ` to ${escapeHtml(status.tater_name)}` : "";
|
||||||
|
$("taterLinkTitle").textContent = "Tater linked";
|
||||||
|
$("taterLinkHint").textContent = "This trainer can now securely publish wake-word updates.";
|
||||||
|
$("taterLinkBody").innerHTML = `
|
||||||
|
<div class="taterLinkSuccess">
|
||||||
|
<div class="taterLinkSuccessMark" aria-hidden="true">✓</div>
|
||||||
|
<strong>Successfully linked${taterName}</strong>
|
||||||
|
<span class="muted">The private link key is stored locally and is never shown again.</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$("taterLinkModalStatus").textContent = "You can close this popup.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTaterLinkModal() {
|
||||||
|
$("taterLinkTitle").textContent = "Link Tater";
|
||||||
|
$("taterLinkHint").textContent = "Enter the short-lived code shown in Tater Voice Settings.";
|
||||||
|
const taterUrl = ($("autoTaterUrl").value || "http://127.0.0.1:8501").trim();
|
||||||
|
$("taterLinkBody").innerHTML = `
|
||||||
|
<div class="stack">
|
||||||
|
<label class="field">
|
||||||
|
<strong>Tater address</strong>
|
||||||
|
<input id="taterLinkUrl" type="text" value="${escapeAttr(taterUrl)}" placeholder="http://127.0.0.1:8501" />
|
||||||
|
</label>
|
||||||
|
<div class="taterLinkCodePanel">
|
||||||
|
<span class="muted">Tater pairing code</span>
|
||||||
|
<input id="taterLinkCode" class="taterLinkCode" type="text" inputmode="text" autocomplete="off"
|
||||||
|
maxlength="9" placeholder="ABCD-EFGH" />
|
||||||
|
<span class="muted">In Tater, open Voice Settings → Wake Word Trainer → Link Trainer.</span>
|
||||||
|
</div>
|
||||||
|
<button id="claimTaterLinkBtn" class="primary" type="button">Link Tater</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$("taterLinkModalStatus").textContent = "Waiting for the Tater code.";
|
||||||
|
$("taterLinkOverlay").classList.add("open");
|
||||||
|
$("taterLinkOverlay").setAttribute("aria-hidden", "false");
|
||||||
|
const codeInput = $("taterLinkCode");
|
||||||
|
const urlInput = $("taterLinkUrl");
|
||||||
|
const submit = $("claimTaterLinkBtn");
|
||||||
|
codeInput.addEventListener("input", () => {
|
||||||
|
const raw = String(codeInput.value || "").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8);
|
||||||
|
codeInput.value = raw.length > 4 ? `${raw.slice(0, 4)}-${raw.slice(4)}` : raw;
|
||||||
|
});
|
||||||
|
submit.addEventListener("click", async () => {
|
||||||
|
const pairingCode = String(codeInput.value || "").trim();
|
||||||
|
const targetUrl = String(urlInput.value || "").trim();
|
||||||
|
if (!pairingCode || !targetUrl) {
|
||||||
|
$("taterLinkModalStatus").textContent = "Tater address and pairing code are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submit.disabled = true;
|
||||||
|
codeInput.disabled = true;
|
||||||
|
urlInput.disabled = true;
|
||||||
|
$("taterLinkModalStatus").textContent = "Linking securely with Tater...";
|
||||||
|
try {
|
||||||
|
const result = await api("/api/tater_link/claim", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tater_url: targetUrl, pairing_code: pairingCode }),
|
||||||
|
});
|
||||||
|
$("autoTaterUrl").value = targetUrl;
|
||||||
|
showTaterLinkSuccess(result);
|
||||||
|
await refreshAutoTrain(false);
|
||||||
|
} catch (error) {
|
||||||
|
submit.disabled = false;
|
||||||
|
codeInput.disabled = false;
|
||||||
|
urlInput.disabled = false;
|
||||||
|
$("taterLinkModalStatus").textContent = `Link failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.setTimeout(() => codeInput.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkTater() {
|
||||||
|
if (!window.confirm("Unlink this trainer from Tater? Wake-word publishing will stop until it is linked again.")) return;
|
||||||
|
uiState.autoBusy = true;
|
||||||
|
syncButtons();
|
||||||
|
try {
|
||||||
|
await api("/api/tater_link/unlink", { method: "POST" });
|
||||||
|
await refreshAutoTrain(false);
|
||||||
|
setPill($("autoTaterLinkStatus"), "Not linked", "warn");
|
||||||
|
} finally {
|
||||||
|
uiState.autoBusy = false;
|
||||||
|
syncButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function captureBadge(item) {
|
function captureBadge(item) {
|
||||||
if (item.blocked_by_vad) return { label: "Blocked by VAD", cls: "warn" };
|
if (item.blocked_by_vad) return { label: "Blocked by VAD", cls: "warn" };
|
||||||
const eventType = String(item?.event_type || "").toLowerCase();
|
const eventType = String(item?.event_type || "").toLowerCase();
|
||||||
@@ -2595,9 +2734,14 @@
|
|||||||
if (refreshWakeWordsBtn) {
|
if (refreshWakeWordsBtn) {
|
||||||
refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
|
refreshWakeWordsBtn.disabled = uiState.firmwareBusy;
|
||||||
}
|
}
|
||||||
for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn"]) {
|
for (const id of ["autoSaveBtn", "autoReviewNowBtn", "autoTrainNowBtn", "autoNotifyNowBtn", "autoLinkTaterBtn", "autoUnlinkTaterBtn"]) {
|
||||||
const button = $(id);
|
const button = $(id);
|
||||||
if (button) button.disabled = uiState.autoBusy || (id === "autoTrainNowBtn" && Boolean(training.running));
|
if (button) {
|
||||||
|
button.disabled =
|
||||||
|
uiState.autoBusy ||
|
||||||
|
(id === "autoTrainNowBtn" && Boolean(training.running)) ||
|
||||||
|
(id === "autoNotifyNowBtn" && !Boolean(uiState.autoTrain?.trainer_link?.linked));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2940,10 +3084,26 @@
|
|||||||
try {
|
try {
|
||||||
await runAutoTrainAction("notify_now");
|
await runAutoTrainAction("notify_now");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPill($("autoStatus"), "Refresh failed", "err");
|
setPill($("autoStatus"), "Publish failed", "err");
|
||||||
alert("Satellite refresh failed: " + error.message);
|
alert("Wake-word publish failed: " + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
$("autoLinkTaterBtn").addEventListener("click", () => {
|
||||||
|
openTaterLinkModal().catch((error) => {
|
||||||
|
setPill($("autoTaterLinkStatus"), "Link failed", "err");
|
||||||
|
alert("Tater link failed: " + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$("autoUnlinkTaterBtn").addEventListener("click", () => {
|
||||||
|
unlinkTater().catch((error) => {
|
||||||
|
setPill($("autoTaterLinkStatus"), "Unlink failed", "err");
|
||||||
|
alert("Tater unlink failed: " + error.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$("closeTaterLinkBtn").addEventListener("click", closeTaterLinkModal);
|
||||||
|
$("taterLinkOverlay").addEventListener("click", (event) => {
|
||||||
|
if (event.target === $("taterLinkOverlay")) closeTaterLinkModal();
|
||||||
|
});
|
||||||
|
|
||||||
$("openConsoleBtn").addEventListener("click", () => {
|
$("openConsoleBtn").addEventListener("click", () => {
|
||||||
setConsoleLogAutoScroll($("trainLog"), (uiState.training?.log_lines || []).join("\n") || "(no training started)");
|
setConsoleLogAutoScroll($("trainLog"), (uiState.training?.log_lines || []).join("\n") || "(no training started)");
|
||||||
@@ -2962,6 +3122,7 @@
|
|||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
|
closeTaterLinkModal();
|
||||||
closeConsole();
|
closeConsole();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,13 +250,12 @@ class AutoTrainTests(unittest.TestCase):
|
|||||||
start.assert_called_once_with()
|
start.assert_called_once_with()
|
||||||
self.assertTrue(trainer.AUTO_TRAIN_STATE["next_run_at"])
|
self.assertTrue(trainer.AUTO_TRAIN_STATE["next_run_at"])
|
||||||
|
|
||||||
def test_tater_refresh_repushes_settings_with_selector_and_token(self):
|
def test_tater_notification_sets_new_word_globally_with_token(self):
|
||||||
trainer.AUTO_TRAIN_CONFIG.update(
|
trainer.AUTO_TRAIN_CONFIG.update(
|
||||||
{
|
{
|
||||||
"notify_satellites": True,
|
"notify_satellites": True,
|
||||||
"tater_url": "http://127.0.0.1:8501",
|
"tater_url": "http://127.0.0.1:8501",
|
||||||
"tater_selector": "kitchen-sat",
|
"tater_link_token": "secret-token",
|
||||||
"tater_api_token": "secret-token",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -268,74 +267,96 @@ class AutoTrainTests(unittest.TestCase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def read(self):
|
def read(self):
|
||||||
return b'{"push":{"count":2}}'
|
return b'{"push":{"count":4}}'
|
||||||
|
|
||||||
with patch.object(trainer, "urlopen", return_value=Response()) as open_url:
|
trained_word = {
|
||||||
result = trainer._notify_tater_satellites()
|
"key": "hey_tater",
|
||||||
|
"wake_word": "Hey Tater",
|
||||||
|
"json_url": "http://10.4.20.210:8789/api/trained_wake_words/hey_tater.json",
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
|
||||||
|
patch.object(trainer, "_list_trained_wake_words", return_value=[trained_word]) as catalog,
|
||||||
|
patch.object(trainer, "urlopen", return_value=Response()) as open_url,
|
||||||
|
):
|
||||||
|
result = trainer._notify_tater_satellites("hey_tater")
|
||||||
|
|
||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
self.assertEqual(result["count"], 2)
|
self.assertEqual(result["count"], 4)
|
||||||
|
self.assertEqual(result["wake_word"], "Hey Tater")
|
||||||
|
self.assertEqual(result["wake_word_url"], trained_word["json_url"])
|
||||||
|
catalog.assert_called_once_with("http://10.4.20.210:8789")
|
||||||
|
self.assertEqual(open_url.call_count, 1)
|
||||||
request = open_url.call_args.args[0]
|
request = open_url.call_args.args[0]
|
||||||
self.assertEqual(request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/settings")
|
self.assertEqual(request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/trainer/wake-word")
|
||||||
self.assertEqual(request.get_header("X-tater-token"), "secret-token")
|
self.assertEqual(request.get_method(), "POST")
|
||||||
self.assertEqual(json.loads(request.data), {"selector": "kitchen-sat", "settings": {}})
|
self.assertEqual(request.get_header("X-tater-trainer-token"), "secret-token")
|
||||||
|
self.assertEqual(
|
||||||
def test_tater_refresh_updates_each_connected_satellite_profile(self):
|
json.loads(request.data),
|
||||||
trainer.AUTO_TRAIN_CONFIG.update(
|
|
||||||
{
|
{
|
||||||
"notify_satellites": True,
|
"wake_word_name": "hey_tater",
|
||||||
"tater_url": "http://127.0.0.1:8501",
|
"wake_word_url": trained_word["json_url"],
|
||||||
"tater_selector": "",
|
},
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Response:
|
def test_tater_notification_fails_when_trained_word_is_missing(self):
|
||||||
def __init__(self, payload):
|
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = "secret-token"
|
||||||
self.payload = payload
|
with (
|
||||||
|
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
|
||||||
|
patch.object(trainer, "_list_trained_wake_words", return_value=[]),
|
||||||
|
patch.object(trainer, "urlopen") as open_url,
|
||||||
|
):
|
||||||
|
result = trainer._notify_tater_satellites("missing_word")
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("missing_word", result["error"])
|
||||||
|
open_url.assert_not_called()
|
||||||
|
|
||||||
|
def test_tater_notification_requires_secure_link(self):
|
||||||
|
trainer.AUTO_TRAIN_CONFIG["tater_link_token"] = ""
|
||||||
|
with patch.object(trainer, "urlopen") as open_url:
|
||||||
|
result = trainer._notify_tater_satellites("hey_tater")
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("not linked", result["error"])
|
||||||
|
open_url.assert_not_called()
|
||||||
|
|
||||||
|
def test_claim_tater_link_uses_tater_code_and_keeps_token_private(self):
|
||||||
|
class Response:
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, *_args):
|
def __exit__(self, *_args):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def read(self):
|
def read(self, *_args):
|
||||||
return json.dumps(self.payload).encode("utf-8")
|
return json.dumps(
|
||||||
|
{
|
||||||
responses = [
|
"ok": True,
|
||||||
Response(
|
"token": "a" * 43,
|
||||||
{
|
"tater_name": "Tater",
|
||||||
"clients": {
|
"linked_at": "2026-07-24T12:00:00+00:00",
|
||||||
"native:office": {"selector": "native:office", "connected": True},
|
|
||||||
"native:kitchen": {"connected": True},
|
|
||||||
"native:garage": {"selector": "native:garage", "connected": False},
|
|
||||||
}
|
}
|
||||||
}
|
).encode("utf-8")
|
||||||
),
|
|
||||||
Response({"push": {"count": 1}}),
|
|
||||||
Response({"push": {"count": 1}}),
|
|
||||||
]
|
|
||||||
with patch.object(trainer, "urlopen", side_effect=responses) as open_url:
|
|
||||||
result = trainer._notify_tater_satellites()
|
|
||||||
|
|
||||||
self.assertTrue(result["ok"])
|
with (
|
||||||
self.assertEqual(result["count"], 2)
|
patch.object(trainer, "_advertised_base_url", return_value="http://10.4.20.210:8789"),
|
||||||
self.assertEqual(result["selectors"], ["native:office", "native:kitchen"])
|
patch.object(trainer, "urlopen", return_value=Response()) as open_url,
|
||||||
self.assertEqual(open_url.call_count, 3)
|
):
|
||||||
|
result = trainer._claim_tater_link("http://127.0.0.1:8501", "ABCD-EFGH")
|
||||||
|
|
||||||
status_request = open_url.call_args_list[0].args[0]
|
self.assertTrue(result["linked"])
|
||||||
self.assertEqual(status_request.get_method(), "GET")
|
self.assertEqual(trainer.AUTO_TRAIN_CONFIG["tater_link_token"], "a" * 43)
|
||||||
self.assertEqual(status_request.full_url, "http://127.0.0.1:8501/api/tater/satellite/v1/status")
|
self.assertNotIn("tater_link_token", trainer._public_auto_train_config())
|
||||||
|
request = open_url.call_args.args[0]
|
||||||
refresh_requests = [call.args[0] for call in open_url.call_args_list[1:]]
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[json.loads(request.data) for request in refresh_requests],
|
request.full_url,
|
||||||
[
|
"http://127.0.0.1:8501/api/tater/satellite/v1/trainer/link/claim",
|
||||||
{"selector": "native:office", "settings": {}},
|
|
||||||
{"selector": "native:kitchen", "settings": {}},
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
self.assertTrue(all(request.get_method() == "POST" for request in refresh_requests))
|
payload = json.loads(request.data)
|
||||||
|
self.assertEqual(payload["pairing_code"], "ABCDEFGH")
|
||||||
|
self.assertEqual(payload["publish_base_url"], "http://10.4.20.210:8789")
|
||||||
|
self.assertTrue(payload["trainer_id"])
|
||||||
|
|
||||||
def test_advertised_url_uses_non_loopback_browser_host(self):
|
def test_advertised_url_uses_non_loopback_browser_host(self):
|
||||||
request = SimpleNamespace(
|
request = SimpleNamespace(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -22,6 +23,7 @@ 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
|
from urllib.parse import quote
|
||||||
|
from urllib.error import HTTPError
|
||||||
from urllib.request import Request as URLRequest, urlopen
|
from urllib.request import Request as URLRequest, urlopen
|
||||||
|
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, Header, Request
|
from fastapi import FastAPI, UploadFile, File, Form, Header, Request
|
||||||
@@ -100,8 +102,10 @@ AUTO_TRAIN_DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
"minimum_new_negatives": 3,
|
"minimum_new_negatives": 3,
|
||||||
"advertised_base_url": "",
|
"advertised_base_url": "",
|
||||||
"tater_url": "http://127.0.0.1:8501",
|
"tater_url": "http://127.0.0.1:8501",
|
||||||
"tater_selector": "",
|
"tater_link_token": "",
|
||||||
"tater_api_token": "",
|
"tater_link_id": "",
|
||||||
|
"tater_linked_at": "",
|
||||||
|
"tater_link_tater_name": "",
|
||||||
"notify_satellites": True,
|
"notify_satellites": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,8 +485,10 @@ def _normalize_auto_train_config(values: Dict[str, Any] | None, *, base: Dict[st
|
|||||||
"minimum_new_negatives": _bounded_int(source.get("minimum_new_negatives"), 3, 1, 10000),
|
"minimum_new_negatives": _bounded_int(source.get("minimum_new_negatives"), 3, 1, 10000),
|
||||||
"advertised_base_url": _normalize_http_base_url(source.get("advertised_base_url")),
|
"advertised_base_url": _normalize_http_base_url(source.get("advertised_base_url")),
|
||||||
"tater_url": _normalize_http_base_url(source.get("tater_url"), allow_empty=False),
|
"tater_url": _normalize_http_base_url(source.get("tater_url"), allow_empty=False),
|
||||||
"tater_selector": str(source.get("tater_selector") or "").strip(),
|
"tater_link_token": str(source.get("tater_link_token") or "").strip(),
|
||||||
"tater_api_token": str(source.get("tater_api_token") or "").strip(),
|
"tater_link_id": str(source.get("tater_link_id") or "").strip(),
|
||||||
|
"tater_linked_at": str(source.get("tater_linked_at") or "").strip(),
|
||||||
|
"tater_link_tater_name": str(source.get("tater_link_tater_name") or "").strip(),
|
||||||
"notify_satellites": _config_bool(source.get("notify_satellites"), True),
|
"notify_satellites": _config_bool(source.get("notify_satellites"), True),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,8 +539,15 @@ def _schedule_next_auto_run_locked(*, from_time: datetime | None = None) -> None
|
|||||||
|
|
||||||
def _public_auto_train_config() -> Dict[str, Any]:
|
def _public_auto_train_config() -> Dict[str, Any]:
|
||||||
with AUTO_TRAIN_LOCK:
|
with AUTO_TRAIN_LOCK:
|
||||||
config = {key: value for key, value in AUTO_TRAIN_CONFIG.items() if key != "tater_api_token"}
|
config = {
|
||||||
config["tater_api_token_configured"] = bool(AUTO_TRAIN_CONFIG.get("tater_api_token"))
|
key: value
|
||||||
|
for key, value in AUTO_TRAIN_CONFIG.items()
|
||||||
|
if key != "tater_link_token"
|
||||||
|
}
|
||||||
|
config["tater_linked"] = bool(
|
||||||
|
AUTO_TRAIN_CONFIG.get("tater_link_token")
|
||||||
|
and AUTO_TRAIN_CONFIG.get("tater_link_id")
|
||||||
|
)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -545,6 +558,7 @@ def _auto_train_status_payload() -> Dict[str, Any]:
|
|||||||
"state": dict(AUTO_TRAIN_STATE),
|
"state": dict(AUTO_TRAIN_STATE),
|
||||||
"runtime": dict(AUTO_TRAIN_RUNTIME),
|
"runtime": dict(AUTO_TRAIN_RUNTIME),
|
||||||
"advertised_base_url": _advertised_base_url(),
|
"advertised_base_url": _advertised_base_url(),
|
||||||
|
"trainer_link": _tater_link_public_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -626,6 +640,115 @@ def _advertised_base_url(request: Request | None = None) -> str:
|
|||||||
return f"{scheme}://{host}{'' if default_port else f':{port}'}"
|
return f"{scheme}://{host}{'' if default_port else f':{port}'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tater_link_public_status() -> Dict[str, Any]:
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
return {
|
||||||
|
"linked": bool(
|
||||||
|
AUTO_TRAIN_CONFIG.get("tater_link_token")
|
||||||
|
and AUTO_TRAIN_CONFIG.get("tater_link_id")
|
||||||
|
),
|
||||||
|
"trainer_id": str(AUTO_TRAIN_CONFIG.get("tater_link_id") or "").strip(),
|
||||||
|
"linked_at": str(AUTO_TRAIN_CONFIG.get("tater_linked_at") or "").strip(),
|
||||||
|
"tater_name": str(AUTO_TRAIN_CONFIG.get("tater_link_tater_name") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_tater_link(tater_url: Any, pairing_code: Any) -> Dict[str, Any]:
|
||||||
|
base_url = _normalize_http_base_url(tater_url, allow_empty=False)
|
||||||
|
code = "".join(ch for ch in str(pairing_code or "").upper() if ch.isalnum())
|
||||||
|
if len(code) != 8:
|
||||||
|
raise ValueError("Enter the complete pairing code shown by Tater.")
|
||||||
|
publish_base_url = _normalize_http_base_url(_advertised_base_url(), allow_empty=False)
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
trainer_id = str(AUTO_TRAIN_CONFIG.get("tater_link_id") or "").strip() or secrets.token_hex(12)
|
||||||
|
|
||||||
|
request = URLRequest(
|
||||||
|
f"{base_url}/api/tater/satellite/v1/trainer/link/claim",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"pairing_code": code,
|
||||||
|
"trainer_id": trainer_id,
|
||||||
|
"trainer_name": "Wake Word Trainer",
|
||||||
|
"trainer_url": publish_base_url,
|
||||||
|
"publish_base_url": publish_base_url,
|
||||||
|
}
|
||||||
|
).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "microWakeWord-Trainer/tater-link",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=10) as response:
|
||||||
|
payload = json.loads(response.read(64 * 1024).decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
error_payload = json.loads(exc.read(64 * 1024).decode("utf-8"))
|
||||||
|
if isinstance(error_payload, dict):
|
||||||
|
detail = str(error_payload.get("detail") or error_payload.get("error") or "").strip()
|
||||||
|
raise ValueError(detail or f"Tater rejected the pairing code (HTTP {exc.code}).") from exc
|
||||||
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError(f"Could not reach Tater: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(payload, dict) or not bool(payload.get("ok")):
|
||||||
|
raise ValueError(str((payload or {}).get("error") or "Tater pairing failed."))
|
||||||
|
link_token = str(payload.get("token") or "").strip()
|
||||||
|
if len(link_token) < 32:
|
||||||
|
raise ValueError("Tater pairing response did not contain valid link credentials.")
|
||||||
|
|
||||||
|
linked_at = str(payload.get("linked_at") or _iso_now()).strip()
|
||||||
|
tater_name = str(payload.get("tater_name") or "Tater").strip() or "Tater"
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
AUTO_TRAIN_CONFIG["tater_url"] = base_url
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_token"] = link_token
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_id"] = trainer_id
|
||||||
|
AUTO_TRAIN_CONFIG["tater_linked_at"] = linked_at
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_tater_name"] = tater_name
|
||||||
|
_save_auto_train_config_locked()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Tater linked successfully.",
|
||||||
|
**_tater_link_public_status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_tater() -> Dict[str, Any]:
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
base_url = str(AUTO_TRAIN_CONFIG.get("tater_url") or "").strip().rstrip("/")
|
||||||
|
link_token = str(AUTO_TRAIN_CONFIG.get("tater_link_token") or "").strip()
|
||||||
|
remote_error = ""
|
||||||
|
if base_url and link_token:
|
||||||
|
request = URLRequest(
|
||||||
|
f"{base_url}/api/tater/satellite/v1/trainer/link/unlink",
|
||||||
|
data=b"{}",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Tater-Trainer-Token": link_token,
|
||||||
|
"User-Agent": "microWakeWord-Trainer/tater-link",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=10):
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
remote_error = str(exc)
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_token"] = ""
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_id"] = ""
|
||||||
|
AUTO_TRAIN_CONFIG["tater_linked_at"] = ""
|
||||||
|
AUTO_TRAIN_CONFIG["tater_link_tater_name"] = ""
|
||||||
|
_save_auto_train_config_locked()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "Tater link removed." if not remote_error else "Local Tater link removed; Tater could not be reached.",
|
||||||
|
"remote_error": remote_error,
|
||||||
|
**_tater_link_public_status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_transcript_text(value: Any) -> str:
|
def _normalize_transcript_text(value: Any) -> str:
|
||||||
text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ")
|
text = unicodedata.normalize("NFKC", str(value or "")).casefold().replace("_", " ")
|
||||||
text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE)
|
text = re.sub(r"[^\w]+", " ", text, flags=re.UNICODE)
|
||||||
@@ -932,74 +1055,49 @@ def _auto_review_capture(file_name: str) -> None:
|
|||||||
AUTO_TRAIN_RUNTIME["review_file"] = ""
|
AUTO_TRAIN_RUNTIME["review_file"] = ""
|
||||||
|
|
||||||
|
|
||||||
def _connected_tater_satellite_selectors(payload: Any) -> List[str]:
|
def _notify_tater_satellites(wake_word_name: str = "") -> Dict[str, Any]:
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
clients: Any = None
|
|
||||||
for key in ("clients", "satellites", "devices"):
|
|
||||||
if isinstance(payload.get(key), (dict, list)):
|
|
||||||
clients = payload.get(key)
|
|
||||||
break
|
|
||||||
if isinstance(clients, dict):
|
|
||||||
rows = [
|
|
||||||
(str(key or "").strip(), value)
|
|
||||||
for key, value in clients.items()
|
|
||||||
]
|
|
||||||
elif isinstance(clients, list):
|
|
||||||
rows = [("", value) for value in clients]
|
|
||||||
else:
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
selectors: List[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for fallback_selector, row in rows:
|
|
||||||
if not isinstance(row, dict) or not _config_bool(row.get("connected"), False):
|
|
||||||
continue
|
|
||||||
selector = str(row.get("selector") or fallback_selector).strip()
|
|
||||||
if not selector or selector in seen:
|
|
||||||
continue
|
|
||||||
seen.add(selector)
|
|
||||||
selectors.append(selector)
|
|
||||||
return selectors
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_tater_satellites() -> Dict[str, Any]:
|
|
||||||
with AUTO_TRAIN_LOCK:
|
with AUTO_TRAIN_LOCK:
|
||||||
config = dict(AUTO_TRAIN_CONFIG)
|
config = dict(AUTO_TRAIN_CONFIG)
|
||||||
if not config.get("notify_satellites"):
|
if not config.get("notify_satellites"):
|
||||||
return {"ok": True, "skipped": True, "message": "Satellite notification is disabled."}
|
return {"ok": True, "skipped": True, "message": "Satellite notification is disabled."}
|
||||||
|
|
||||||
base_url = str(config.get("tater_url") or "").rstrip("/")
|
base_url = str(config.get("tater_url") or "").rstrip("/")
|
||||||
settings_endpoint = f"{base_url}/api/tater/satellite/v1/settings"
|
settings_endpoint = f"{base_url}/api/tater/satellite/v1/trainer/wake-word"
|
||||||
status_endpoint = f"{base_url}/api/tater/satellite/v1/status"
|
|
||||||
headers = {"Content-Type": "application/json", "User-Agent": "microWakeWord-Trainer/auto-train"}
|
headers = {"Content-Type": "application/json", "User-Agent": "microWakeWord-Trainer/auto-train"}
|
||||||
token = str(config.get("tater_api_token") or "").strip()
|
token = str(config.get("tater_link_token") or "").strip()
|
||||||
if token:
|
if not token:
|
||||||
headers["X-Tater-Token"] = token
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Wake Word Trainer is not linked to Tater. Use Link Tater first.",
|
||||||
|
}
|
||||||
|
headers["X-Tater-Trainer-Token"] = token
|
||||||
|
|
||||||
try:
|
try:
|
||||||
configured_selector = str(config.get("tater_selector") or "").strip()
|
target_key = safe_name(wake_word_name or config.get("wake_phrase") or "")
|
||||||
if configured_selector:
|
public_base_url = _advertised_base_url()
|
||||||
selectors = [configured_selector]
|
wake_words = _list_trained_wake_words(public_base_url)
|
||||||
else:
|
target = next(
|
||||||
status_request = URLRequest(status_endpoint, headers=headers, method="GET")
|
(row for row in wake_words if str(row.get("key") or "").strip() == target_key),
|
||||||
with urlopen(status_request, timeout=15) as response:
|
None,
|
||||||
status_payload = json.loads(response.read().decode("utf-8"))
|
)
|
||||||
selectors = _connected_tater_satellite_selectors(status_payload)
|
if not isinstance(target, dict):
|
||||||
|
raise FileNotFoundError(f"Trained wake word is not available: {target_key}")
|
||||||
|
wake_word_url = str(target.get("json_url") or "").strip()
|
||||||
|
if not wake_word_url.startswith(("http://", "https://")):
|
||||||
|
raise ValueError("The trained wake-word JSON needs an advertised http(s) URL.")
|
||||||
|
|
||||||
count = 0
|
body = json.dumps(
|
||||||
refreshes: List[Dict[str, Any]] = []
|
{
|
||||||
for selector in selectors:
|
"wake_word_name": target_key,
|
||||||
body = json.dumps({"selector": selector, "settings": {}}).encode("utf-8")
|
"wake_word_url": wake_word_url,
|
||||||
request = URLRequest(settings_endpoint, data=body, headers=headers, method="POST")
|
}
|
||||||
with urlopen(request, timeout=15) as response:
|
).encode("utf-8")
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
request = URLRequest(settings_endpoint, data=body, headers=headers, method="POST")
|
||||||
push = payload.get("push") if isinstance(payload, dict) and isinstance(payload.get("push"), dict) else {}
|
with urlopen(request, timeout=15) as response:
|
||||||
pushed_count = push.get("count")
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
if isinstance(pushed_count, (int, float)):
|
push = payload.get("push") if isinstance(payload, dict) and isinstance(payload.get("push"), dict) else {}
|
||||||
count += max(0, int(pushed_count))
|
pushed_count = push.get("count")
|
||||||
refreshes.append({"selector": selector, "count": pushed_count})
|
count = max(0, int(pushed_count)) if isinstance(pushed_count, (int, float)) else 0
|
||||||
|
|
||||||
with AUTO_TRAIN_LOCK:
|
with AUTO_TRAIN_LOCK:
|
||||||
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
|
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
|
||||||
@@ -1009,9 +1107,23 @@ def _notify_tater_satellites() -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"count": count,
|
"count": count,
|
||||||
"selectors": selectors,
|
"wake_word": str(target.get("wake_word") or target_key),
|
||||||
"refreshes": refreshes,
|
"wake_word_name": target_key,
|
||||||
|
"wake_word_url": wake_word_url,
|
||||||
}
|
}
|
||||||
|
except HTTPError as exc:
|
||||||
|
detail = ""
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
error_payload = json.loads(exc.read().decode("utf-8"))
|
||||||
|
if isinstance(error_payload, dict):
|
||||||
|
detail = str(error_payload.get("detail") or error_payload.get("error") or "").strip()
|
||||||
|
error = detail or f"Tater rejected the wake word (HTTP {exc.code})."
|
||||||
|
with AUTO_TRAIN_LOCK:
|
||||||
|
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
|
||||||
|
AUTO_TRAIN_STATE["last_notify_count"] = None
|
||||||
|
AUTO_TRAIN_STATE["last_notify_error"] = error
|
||||||
|
_save_auto_train_state_locked()
|
||||||
|
return {"ok": False, "error": error}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with AUTO_TRAIN_LOCK:
|
with AUTO_TRAIN_LOCK:
|
||||||
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
|
AUTO_TRAIN_STATE["last_notify_at"] = _iso_now()
|
||||||
@@ -2243,17 +2355,17 @@ def _run_training_background(
|
|||||||
AUTO_TRAIN_RUNTIME["training_pending_consumed"] = 0
|
AUTO_TRAIN_RUNTIME["training_pending_consumed"] = 0
|
||||||
_save_auto_train_state_locked()
|
_save_auto_train_state_locked()
|
||||||
if rc == 0:
|
if rc == 0:
|
||||||
_append_train_log("→ Asking Tater to refresh the active wake model on connected satellites")
|
_append_train_log("→ Publishing the newly trained wake word to Tater and all satellites")
|
||||||
notify_result = _notify_tater_satellites()
|
notify_result = _notify_tater_satellites(safe_word)
|
||||||
if notify_result.get("ok"):
|
if notify_result.get("ok"):
|
||||||
if notify_result.get("skipped"):
|
if notify_result.get("skipped"):
|
||||||
_append_train_log("→ Satellite refresh skipped (disabled in Auto Training)")
|
_append_train_log("→ Wake-word publish skipped (disabled in Auto Training)")
|
||||||
else:
|
else:
|
||||||
count = notify_result.get("count")
|
count = notify_result.get("count")
|
||||||
suffix = f" ({count} connected)" if count is not None else ""
|
suffix = f" ({count} connected)" if count is not None else ""
|
||||||
_append_train_log(f"✓ Tater satellite refresh requested{suffix}")
|
_append_train_log(f"✓ New wake word activated through Tater{suffix}")
|
||||||
else:
|
else:
|
||||||
_append_train_log(f"✗ Tater satellite refresh failed: {notify_result.get('error')}")
|
_append_train_log(f"✗ Tater wake-word activation failed: {notify_result.get('error')}")
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Routes --------------------
|
# -------------------- Routes --------------------
|
||||||
@@ -2279,12 +2391,15 @@ def auto_train_status(request: Request):
|
|||||||
@app.put("/api/auto_train")
|
@app.put("/api/auto_train")
|
||||||
def update_auto_train(payload: Dict[str, Any] = None):
|
def update_auto_train(payload: Dict[str, Any] = None):
|
||||||
incoming = dict(payload or {})
|
incoming = dict(payload or {})
|
||||||
|
for protected_key in (
|
||||||
|
"tater_link_token",
|
||||||
|
"tater_link_id",
|
||||||
|
"tater_linked_at",
|
||||||
|
"tater_link_tater_name",
|
||||||
|
):
|
||||||
|
incoming.pop(protected_key, None)
|
||||||
with AUTO_TRAIN_LOCK:
|
with AUTO_TRAIN_LOCK:
|
||||||
previous = dict(AUTO_TRAIN_CONFIG)
|
previous = dict(AUTO_TRAIN_CONFIG)
|
||||||
if incoming.pop("clear_tater_api_token", False):
|
|
||||||
incoming["tater_api_token"] = ""
|
|
||||||
elif not str(incoming.get("tater_api_token") or "").strip():
|
|
||||||
incoming.pop("tater_api_token", None)
|
|
||||||
try:
|
try:
|
||||||
normalized = _normalize_auto_train_config(incoming, base=previous)
|
normalized = _normalize_auto_train_config(incoming, base=previous)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -2311,6 +2426,25 @@ def update_auto_train(payload: Dict[str, Any] = None):
|
|||||||
return {"ok": True, "queued": queued, **_auto_train_status_payload()}
|
return {"ok": True, "queued": queued, **_auto_train_status_payload()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/tater_link/claim")
|
||||||
|
def tater_link_claim(payload: Dict[str, Any] = None):
|
||||||
|
body = payload if isinstance(payload, dict) else {}
|
||||||
|
try:
|
||||||
|
return _claim_tater_link(
|
||||||
|
body.get("tater_url"),
|
||||||
|
body.get("pairing_code"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/tater_link/unlink")
|
||||||
|
def tater_link_unlink():
|
||||||
|
return _unlink_tater()
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auto_train/action")
|
@app.post("/api/auto_train/action")
|
||||||
def auto_train_action(payload: Dict[str, Any] = None):
|
def auto_train_action(payload: Dict[str, Any] = None):
|
||||||
action = str((payload or {}).get("action") or "").strip().lower()
|
action = str((payload or {}).get("action") or "").strip().lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user