diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 1cbf9dd6..145f7cd3 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -373,8 +373,8 @@ { "id": "pine", "title": "Pine", - "version": "1.2.0", - "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.", + "version": "1.3.0", + "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.", "icon": "/assets/img/app-icons/pine.svg", "author": "Archipelago", "category": "home", diff --git a/apps/pine-openwakeword/manifest.yml b/apps/pine-openwakeword/manifest.yml new file mode 100644 index 00000000..03910469 --- /dev/null +++ b/apps/pine-openwakeword/manifest.yml @@ -0,0 +1,70 @@ +app: + id: pine-openwakeword + name: Pine Wake Word (openWakeWord) + version: "2.1.0" + description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image). + category: home + + # Hyphen name matches the runtime references (stack member table / startup + # order) so the orchestrator adopts a matching running container instead of + # recreating it. + container_name: pine-openwakeword + + container: + image: docker.io/rhasspy/wyoming-openwakeword:2.1.0 + pull_policy: if-not-present + network: archy-net + network_aliases: [pine-openwakeword] + # The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock + # "ok nabu" model; /custom is where a trained custom model (yo_archy) + # drops in later — the engine picks up new .tflite files on restart. + custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"] + + dependencies: + - storage: 512Mi + + resources: + memory_limit: 512Mi + + security: + # cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server + # on an unprivileged port needs no added capabilities. + capabilities: [] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + # Published so Home Assistant (on the pasta net) can reach the engine via + # host.containers.internal:10400 (the Wyoming integration endpoint). + - host: 10400 + container: 10400 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/pine-openwakeword + target: /custom + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:10400 + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + metadata: + author: Rhasspy / Home Assistant + icon: /assets/img/app-icons/pine.svg + website: https://github.com/rhasspy/wyoming-openwakeword + repo: https://github.com/rhasspy/wyoming-openwakeword + license: MIT + tags: + - home + - voice + - wake-word + - wyoming diff --git a/apps/pine/manifest.yml b/apps/pine/manifest.yml index 4f690234..3984cd3f 100644 --- a/apps/pine/manifest.yml +++ b/apps/pine/manifest.yml @@ -1,8 +1,8 @@ app: id: pine name: Pine - version: "1.2.0" - description: A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. + version: "1.3.0" + description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else. category: home # The user-facing launcher (app_id + container both "pine", matching the @@ -30,6 +30,7 @@ app: dependencies: - app_id: pine-whisper - app_id: pine-piper + - app_id: pine-openwakeword - storage: 128Mi resources: @@ -92,6 +93,15 @@ app: ssl_certificate_key /etc/nginx/tls.key; root /usr/share/nginx/html; index index.html; + # Live node facts for the status card — proxied to the node's + # public status tier so the (https) page can fetch same-origin. + location = /node-status { + proxy_pass http://host.containers.internal:80/api/pine/status; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 10s; + } location / { try_files $uri $uri/ /index.html; } } - path: /var/lib/archipelago/pine/index.html @@ -156,9 +166,16 @@ app:
Whisperspeech-to-text ready on :10300
Pipertext-to-speech ready on :10200
+
Wake word“Hey Jarvis” on the speaker (openWakeWord on :10400)
Speakerput it in pairing mode — ring LED blinking yellow
+
+
Nodechecking…
+
Bitcoin
+
Peers
+
+
This page isn’t running over HTTPS, so the browser blocks Bluetooth. Open it via its https://…:10380 address (accept the self-signed @@ -177,9 +194,13 @@ app:

After WiFi joins, one manual step remains — pair the speaker in Home Assistant: Settings → Devices & services → Add Wyoming Protocol, host = the speaker’s IP, port - 10700. Whisper, Piper and the Assist pipeline are wired up - automatically when Pine installs. Wake word: “Hey Jarvis.” - You can also ask node things like “what’s the block height?”

+ 10700. Whisper, Piper, openWakeWord and the Assist pipeline + are wired up automatically when Pine installs. Wake word: + “Hey Jarvis.” Ask node things like “what’s the block + height?”, “how many peers?”, “is the node + synced?” or “what’s my lightning balance?” — and when a + Claude API key is set on the node, anything else gets answered by + Claude. New mesh messages are announced on the speaker too.

Troubleshooting: if it hears you (LED reacts) but answers are silent, unplug and replug the speaker — an interrupted answer can wedge its audio output until it reboots.

@@ -304,6 +325,39 @@ app: try { device?.gatt?.disconnect(); } catch {} } finally { btn.disabled = false; } }); + + // Live node status card — public tier of /api/pine/status via the + // same-origin /node-status proxy. Best-effort: failures just show + // "unavailable" and retry on the next tick. + const nsNode = document.getElementById("ns-node"); + const nsBtc = document.getElementById("ns-btc"); + const nsPeers = document.getElementById("ns-peers"); + async function refreshNodeStatus() { + try { + const r = await fetch("/node-status", { cache: "no-store" }); + if (!r.ok) throw new Error(String(r.status)); + const s = await r.json(); + const up = Math.floor((s.uptime_seconds || 0) / 3600); + nsNode.textContent = `Archipelago ${s.version || "?"} — up ${up}h`; + if (s.bitcoin && s.bitcoin.height != null) { + const pct = s.bitcoin.sync_percent; + nsBtc.textContent = `block ${s.bitcoin.height}` + + (pct != null ? (pct >= 99.99 ? " — synced" : ` — ${pct}% synced`) : ""); + } else { + nsBtc.textContent = "not running"; + } + const btcPeers = s.bitcoin && s.bitcoin.peers != null ? s.bitcoin.peers : "?"; + const meshPeers = s.mesh ? s.mesh.peers : 0; + nsPeers.textContent = `${btcPeers} bitcoin` + + (s.mesh && s.mesh.enabled ? `, ${meshPeers} mesh` : ""); + } catch { + nsNode.textContent = "node status unavailable"; + nsBtc.textContent = "—"; + nsPeers.textContent = "—"; + } + } + refreshNodeStatus(); + setInterval(refreshNodeStatus, 30000); diff --git a/docs/pine-voice-release-test-plan.md b/docs/pine-voice-release-test-plan.md new file mode 100644 index 00000000..6b7e024a --- /dev/null +++ b/docs/pine-voice-release-test-plan.md @@ -0,0 +1,60 @@ +# Framework PT test plan — Pine voice epic (pre-release gate) + +Target node: **framework-pt** (`100.65.115.109`, LAN 192.168.1.249). Run after +BOTH agents' work is merged, with the dev binary sideloaded and the signed +catalog (pine 1.3.0 + pine-openwakeword) published. Every ❑ must pass before +the release ritual starts. Items marked **(user)** need a human in the room. + +## A. Deploy / prerequisites +- ❑ A1 Dev binary sideloaded, `archipelago` service active, no crash-loop in journal. +- ❑ A2 nginx self-heal added `location /api/pine/status` to every server block; `nginx -t` passes; nginx reloaded. +- ❑ A3 Signed catalog with pine 1.3.0 + pine-openwakeword live at the raw URL; node refreshed it (hourly sweep or "Check for updates"). + +## B. `/api/pine/status` endpoint +- ❑ B1 Public tier through nginx (`curl http://127.0.0.1/api/pine/status`): version, uptime, bitcoin height/sync_percent/peers, mesh peers. `lightning` null, `mesh_message` absent. +- ❑ B2 Wrong bearer token → still public-only (no balances). Correct token (from `/var/lib/archipelago/secrets/pine-status-token`) → lightning balances + latest mesh message present. +- ❑ B3 Reachable from inside the HA container via `host.containers.internal:80`. +- ❑ B4 Token file is 0600, owned by the service user. + +## C. Stack / openwakeword container +- ❑ C1 Reconcile installs `pine-openwakeword` (wyoming-openwakeword 2.1.0), healthy on :10400. +- ❑ C2 Existing pine-whisper / pine-piper / pine were ADOPTED, not recreated — model data dirs untouched. +- ❑ C3 `archipelago` service restart → all four pine containers come back (crash-recovery stack spec). +- ❑ C4 UI: openwakeword listed under Services (no extra store card); Pine card shows 1.3.0. + +## D. Home Assistant seeding +- ❑ D1 configuration.yaml: legacy hand-staged block (bitcoind :18332 + plaintext RPC creds) fully replaced by the bounded token-based block. +- ❑ D2 `custom_sentences/en/archy.yaml` carries all four intents. +- ❑ D3 `.storage/core.config_entries`: wyoming entry for openwakeword (:10400) + `anthropic` entry (Claude, conversation + ai_task subentries). +- ❑ D4 Pipeline: `conversation_engine = conversation.claude_conversation`, `prefer_local_intents: true`. +- ❑ D5 automations.yaml: `archy_mesh_announce` seeded. +- ❑ D6 HA restarts clean — no setup errors for anthropic / wyoming / rest / intent_script in `podman logs homeassistant`. +- ❑ D7 Sensors report real values: archy_block_height, archy_bitcoin_sync, archy_bitcoin_peers, archy_mesh_peers, archy_lightning_balance (or clean unavailable if LND absent), archy_mesh_message. + +## E. Voice / intents (API level first, then live speaker) +- ❑ E1 Exact phrase "what's the block height" → answered by the LOCAL intent (correct height, no Anthropic API call in HA logs). +- ❑ E2 Fuzzy phrase (e.g. "how tall is the chain right now") → Claude routes to the ArchyBlockHeight tool; answer contains the real height. +- ❑ E3 "how many peers", "is the node synced", "what's my lightning balance" → correct spoken-length answers. +- ❑ E4 Off-topic question → Claude answers, 1–2 sentences, no markdown. +- ❑ E5 **(user)** Live speaker: "Hey Jarvis, what's the block height" → audible correct answer. +- ❑ E6 Mesh announce: new received mesh text (or manual `assist_satellite.announce` if no radio) → speaker announces sender + text; no announce storm on HA restart. + +## F. Pine launcher page (1.3.0) +- ❑ F1 Page on :10380→:10381 shows the live node card (version, uptime, block, sync, peers) within ~5s. +- ❑ F2 `/node-status` proxy works (pine nginx resolves host.containers.internal at startup — container must not crash-loop). +- ❑ F3 "Connect Pine to WiFi" provisioner still intact (no JS errors on load). + +## G. Cleanup / regression sweep +- ❑ G1 Both stray socat 18332 forwarders killed; sensors still work via the endpoint. +- ❑ G2 No bitcoind RPC credentials anywhere in HA config. +- ❑ G3 Pre-existing HA function intact: whisper/piper entities, PineVoice satellite pairing, other integrations. +- ❑ G4 nginx regressions: `/health`, `/bitcoin-status`, `/api/app-catalog`, `/proxy/lnd/` all still proxied post-patch. +- ❑ G5 **(user)** Mobile Home: wallet card sits directly under My Apps; desktop layout unchanged. +- ❑ G6 Other agent's changes re-verified after merge (their own checklist). + +## H. Production-readiness (release ritual gate) +- ❑ H1 `cargo test` workspace green; frontend builds; drift check `--release --strict` green. +- ❑ H2 `tests/lifecycle/run-gate.sh` re-run ON .228 (stack membership changed → lifecycle gate rule applies). +- ❑ H3 Catalog regenerated → signed (ceremony) → published via gitea-ai; verified at the raw URL. +- ❑ H4 Changelog (layman-readable) + `scripts/sync-whats-new.py` + version bump; release ritual per v1.7.110 notes (push main via gitea-ai BEFORE publish; sign manifest AFTER create-release). +- ❑ H5 No secrets in any commit; frontend tarball flat + APK policy per release notes. diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index 1cbf9dd6..145f7cd3 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -373,8 +373,8 @@ { "id": "pine", "title": "Pine", - "version": "1.2.0", - "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.", + "version": "1.3.0", + "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.", "icon": "/assets/img/app-icons/pine.svg", "author": "Archipelago", "category": "home", diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 36c85be9..0bd89175 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -81,6 +81,7 @@ export const GENERATED_APP_TITLES: Record = { "nostr-rs-relay": "Nostr Relay (Rust)", "photoprism": "PhotoPrism", "pine": "Pine", + "pine-openwakeword": "Pine Wake Word (openWakeWord)", "pine-piper": "Pine Piper (TTS)", "pine-whisper": "Pine Whisper (STT)", "portainer": "Portainer", diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index 7903ed22..ef56d125 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -29,7 +29,7 @@ export const SERVICE_NAMES = new Set([ // Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS) // reached by Home Assistant over host.containers.internal — the user-facing // card is "pine" (the setup/status launcher), so the engines go to Services. - 'pine-whisper', 'pine-piper', + 'pine-whisper', 'pine-piper', 'pine-openwakeword', ]) const INTERNAL_TOOLING_NAMES = new Set([ diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 9f7ea046..a12513fc 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -1,6 +1,6 @@ { "schema": 1, - "updated": "2026-07-21", + "updated": "2026-07-22", "apps": { "adguardhome": { "version": "v0.107.55", @@ -3843,13 +3843,13 @@ } }, "pine": { - "version": "1.2.0", + "version": "1.3.0", "manifest": { "app": { "id": "pine", "name": "Pine", - "version": "1.2.0", - "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper) and text-to-speech (Piper) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud.", + "version": "1.3.0", + "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.", "category": "home", "container_name": "pine", "container": { @@ -3873,6 +3873,9 @@ { "app_id": "pine-piper" }, + { + "app_id": "pine-openwakeword" + }, { "storage": "128Mi" } @@ -3943,12 +3946,12 @@ { "path": "/var/lib/archipelago/pine/nginx.conf", "overwrite": true, - "content": "server {\n listen 80;\n server_name _;\n return 301 https://$host:10381$request_uri;\n}\nserver {\n listen 443 ssl;\n server_name _;\n ssl_certificate /etc/nginx/tls.crt;\n ssl_certificate_key /etc/nginx/tls.key;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n}\n" + "content": "server {\n listen 80;\n server_name _;\n return 301 https://$host:10381$request_uri;\n}\nserver {\n listen 443 ssl;\n server_name _;\n ssl_certificate /etc/nginx/tls.crt;\n ssl_certificate_key /etc/nginx/tls.key;\n root /usr/share/nginx/html;\n index index.html;\n # Live node facts for the status card \u2014 proxied to the node's\n # public status tier so the (https) page can fetch same-origin.\n location = /node-status {\n proxy_pass http://host.containers.internal:80/api/pine/status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 5s;\n proxy_read_timeout 10s;\n }\n location / { try_files $uri $uri/ /index.html; }\n}\n" }, { "path": "/var/lib/archipelago/pine/index.html", "overwrite": true, - "content": "\n\n\n \n \n Pine \u2014 connect your speaker\n \n\n\n
\n
\n \n \n \n \n \n \n

Pine

\n

Connect your speaker \u2014 everything stays on your node.

\n
\n\n
\n
Whisperspeech-to-text ready on :10300
\n
Pipertext-to-speech ready on :10200
\n
Speakerput it in pairing mode \u2014 ring LED blinking yellow
\n
\n\n
\n This page isn\u2019t running over HTTPS, so the browser blocks Bluetooth.\n Open it via its https://\u2026:10380 address (accept the self-signed\n certificate) and the button below will work.\n
\n\n
\n \n \n \n \n \n
Ready. Click the button, then pick \u201cPineVoice\u201d in the Bluetooth popup.
\n
\n\n

After WiFi joins, one manual step remains \u2014 pair the\n speaker in Home Assistant: Settings \u2192 Devices & services \u2192\n Add Wyoming Protocol, host = the speaker\u2019s IP, port\n 10700. Whisper, Piper and the Assist pipeline are wired up\n automatically when Pine installs. Wake word: \u201cHey Jarvis.\u201d\n You can also ask node things like \u201cwhat\u2019s the block height?\u201d

\n

Troubleshooting: if it hears you (LED reacts) but answers\n are silent, unplug and replug the speaker \u2014 an interrupted answer can\n wedge its audio output until it reboots.

\n
\n\n \n\n\n" + "content": "\n\n\n \n \n Pine \u2014 connect your speaker\n \n\n\n
\n
\n \n \n \n \n \n \n

Pine

\n

Connect your speaker \u2014 everything stays on your node.

\n
\n\n
\n
Whisperspeech-to-text ready on :10300
\n
Pipertext-to-speech ready on :10200
\n
Wake word\u201cHey Jarvis\u201d on the speaker (openWakeWord on :10400)
\n
Speakerput it in pairing mode \u2014 ring LED blinking yellow
\n
\n\n
\n
Nodechecking\u2026
\n
Bitcoin\u2014
\n
Peers\u2014
\n
\n\n
\n This page isn\u2019t running over HTTPS, so the browser blocks Bluetooth.\n Open it via its https://\u2026:10380 address (accept the self-signed\n certificate) and the button below will work.\n
\n\n
\n \n \n \n \n \n
Ready. Click the button, then pick \u201cPineVoice\u201d in the Bluetooth popup.
\n
\n\n

After WiFi joins, one manual step remains \u2014 pair the\n speaker in Home Assistant: Settings \u2192 Devices & services \u2192\n Add Wyoming Protocol, host = the speaker\u2019s IP, port\n 10700. Whisper, Piper, openWakeWord and the Assist pipeline\n are wired up automatically when Pine installs. Wake word:\n \u201cHey Jarvis.\u201d Ask node things like \u201cwhat\u2019s the block\n height?\u201d, \u201chow many peers?\u201d, \u201cis the node\n synced?\u201d or \u201cwhat\u2019s my lightning balance?\u201d \u2014 and when a\n Claude API key is set on the node, anything else gets answered by\n Claude. New mesh messages are announced on the speaker too.

\n

Troubleshooting: if it hears you (LED reacts) but answers\n are silent, unplug and replug the speaker \u2014 an interrupted answer can\n wedge its audio output until it reboots.

\n
\n\n \n\n\n" } ], "health_check": { @@ -3989,6 +3992,86 @@ } } }, + "pine-openwakeword": { + "version": "2.1.0", + "manifest": { + "app": { + "id": "pine-openwakeword", + "name": "Pine Wake Word (openWakeWord)", + "version": "2.1.0", + "description": "Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member \u2014 lets Assist pipelines run wake-word detection on the node (groundwork for the custom \"Yo Archy\" wake word; stock models like \"ok nabu\" ship with the image).", + "category": "home", + "container_name": "pine-openwakeword", + "container": { + "image": "docker.io/rhasspy/wyoming-openwakeword:2.1.0", + "pull_policy": "if-not-present", + "network": "archy-net", + "network_aliases": [ + "pine-openwakeword" + ], + "custom_args": [ + "--preload-model", + "ok_nabu", + "--custom-model-dir", + "/custom" + ] + }, + "dependencies": [ + { + "storage": "512Mi" + } + ], + "resources": { + "memory_limit": "512Mi" + }, + "security": { + "capabilities": [], + "readonly_root": false, + "no_new_privileges": true, + "network_policy": "isolated" + }, + "ports": [ + { + "host": 10400, + "container": 10400, + "protocol": "tcp" + } + ], + "volumes": [ + { + "type": "bind", + "source": "/var/lib/archipelago/pine-openwakeword", + "target": "/custom", + "options": [ + "rw" + ] + } + ], + "environment": [], + "health_check": { + "type": "tcp", + "endpoint": "localhost:10400", + "interval": "30s", + "timeout": "5s", + "retries": 5, + "start_period": "30s" + }, + "metadata": { + "author": "Rhasspy / Home Assistant", + "icon": "/assets/img/app-icons/pine.svg", + "website": "https://github.com/rhasspy/wyoming-openwakeword", + "repo": "https://github.com/rhasspy/wyoming-openwakeword", + "license": "MIT", + "tags": [ + "home", + "voice", + "wake-word", + "wyoming" + ] + } + } + } + }, "pine-piper": { "version": "2.2.2", "manifest": { diff --git a/scripts/check-app-catalog-drift.py b/scripts/check-app-catalog-drift.py index 6ab0e8bb..afe59b96 100644 --- a/scripts/check-app-catalog-drift.py +++ b/scripts/check-app-catalog-drift.py @@ -41,6 +41,7 @@ INTERNAL_MANIFEST_IDS = { "netbird-server", "pine-whisper", "pine-piper", + "pine-openwakeword", } LEGACY_STACK_CATALOG_IDS = {