2026-07-21 05:14:32 -04:00
app :
id : pine
name : Pine
2026-07-22 02:59:34 -04:00
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.
2026-07-21 05:14:32 -04:00
category : home
# The user-facing launcher (app_id + container both "pine", matching the
# runtime references + the live container so the orchestrator adopts it). A
2026-07-21 08:25:24 -04:00
# tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the
# voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal
# stack members.
2026-07-21 05:14:32 -04:00
container_name : pine
container :
image : docker.io/library/nginx:1.27-alpine
pull_policy : if-not-present
network : archy-net
network_aliases : [ pine]
2026-07-21 08:25:24 -04:00
# The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to
# the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT
# (https or localhost), so the launcher terminates TLS with a self-signed
# cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN.
# Idempotent: kept as-is when crt+key already exist. Mirrors the netbird
# secure-context fix (#15).
generated_certs :
- crt : /var/lib/archipelago/pine/tls.crt
key : /var/lib/archipelago/pine/tls.key
2026-07-21 05:14:32 -04:00
dependencies :
- app_id : pine-whisper
- app_id : pine-piper
2026-07-22 02:59:34 -04:00
- app_id : pine-openwakeword
2026-07-21 05:14:32 -04:00
- storage : 128Mi
resources :
memory_limit : 64Mi
security :
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
2026-07-21 08:25:24 -04:00
# workers) binds :443 inside the container — needs the worker-drop caps +
2026-07-21 05:14:32 -04:00
# NET_BIND_SERVICE for the privileged port.
capabilities : [ CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE]
readonly_root : false
no_new_privileges : true
network_policy : isolated
ports :
2026-07-21 08:32:52 -04:00
# 10380 (http) is the Open target — the UI launches apps as
# http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https
# listener on 10381, so the new tab lands on a secure context where
# navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works.
2026-07-21 05:14:32 -04:00
- host : 10380
2026-07-21 08:32:52 -04:00
container : 80
protocol : tcp
- host : 10381
2026-07-21 08:25:24 -04:00
container : 443
2026-07-21 05:14:32 -04:00
protocol : tcp
volumes :
2026-07-21 08:25:24 -04:00
- type : bind
source : /var/lib/archipelago/pine/nginx.conf
target : /etc/nginx/conf.d/default.conf
options : [ ro]
- type : bind
source : /var/lib/archipelago/pine/tls.crt
target : /etc/nginx/tls.crt
options : [ ro]
- type : bind
source : /var/lib/archipelago/pine/tls.key
target : /etc/nginx/tls.key
options : [ ro]
2026-07-21 05:14:32 -04:00
- type : bind
source : /var/lib/archipelago/pine/index.html
target : /usr/share/nginx/html/index.html
options : [ ro]
environment : [ ]
files :
2026-07-21 08:25:24 -04:00
- path : /var/lib/archipelago/pine/nginx.conf
overwrite : true
content : |
2026-07-21 08:32:52 -04:00
server {
listen 80;
server_name _;
return 301 https://$host:10381$request_uri;
}
2026-07-21 08:25:24 -04:00
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
root /usr/share/nginx/html;
index index.html;
2026-07-22 02:59:34 -04:00
# 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;
}
2026-07-21 08:25:24 -04:00
location / { try_files $uri $uri/ /index.html; }
}
2026-07-21 05:14:32 -04:00
- path : /var/lib/archipelago/pine/index.html
overwrite : true
content : |
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
2026-07-21 08:25:24 -04:00
<title>Pine — connect your speaker</title>
2026-07-21 05:14:32 -04:00
<style>
: root { color-scheme: dark; }
2026-07-21 08:25:24 -04:00
* { box-sizing : border-box; }
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
2026-07-21 05:14:32 -04:00
background: #0f1512; color : #e7efe9; }
2026-07-21 08:25:24 -04:00
.wrap { max-width: 520px; margin: 0 auto; padding: 40px 22px 64px; }
header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
header svg { width: 52px; height: 52px; flex: 0 0 auto; }
h1 { font-size: 26px; margin: 0; }
2026-07-21 05:14:32 -04:00
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
.card { background: #16201b; border: 1px solid #24332b;
2026-07-21 08:25:24 -04:00
border-radius: 14px; padding: 20px; margin : 20px 0; }
.status .row { display: flex; gap: 10px; align-items: baseline; font-size: 14px; }
.status .row b { min-width: 84px; color: #9fd3b6; }
.status code { background: #0f1512; padding: 1px 6px; border-radius: 5px;
font-size: 13px; color : #cfe9d9; }
label { display: block; font-size: 13px; color: #9fbfad; margin: 14px 0 5px; }
input { width: 100%; padding: 11px 12px; font-size: 15px; color: #e7efe9;
background: #0f1512; border: 1px solid #2b3d33; border-radius : 9px;
outline : none; }
input:focus { border-color: #4fae82; }
button { width: 100%; margin-top: 18px; padding: 13px; font-size: 15px;
font-weight: 600; color: #06110b; background: #7fd6a6; border : 0 ;
border-radius: 10px; cursor: pointer; transition : filter .15s; }
button:hover:not(:disabled) { filter: brightness(1.08); }
button:disabled { opacity: .45; cursor: default; }
#log { margin-top: 16px; padding: 12px; min-height: 68px; font-size: 13px;
font-family: ui-monospace, monospace; white-space : pre-wrap;
background: #0b100d; border: 1px solid #1e2a23; border-radius : 9px;
color: #a9c6b6; max-height: 220px; overflow-y : auto; }
.ok { color: #7fd6a6; } .err { color: #ee8f8f; } .warn { color: #e8c878; }
.ha { color: #6d8578; font-size: 13px; margin-top: 22px; }
.ha b { color: #9fbfad; }
.insecure { display: none; background: #2a1f12; border-color: #4a3418;
color: #e8c878; font-size : 13px; }
2026-07-21 05:14:32 -04:00
</style>
</head>
<body>
<div class="wrap">
<header>
<svg viewBox="0 0 512 512" aria-hidden="true"><g fill="#7fd6a6">
<rect x="236" y="396" width="40" height="72" rx="6"/>
<path d="M256 44 L336 168 L296 168 L256 108 L216 168 L176 168 Z"/>
<path d="M256 150 L360 300 L300 300 L256 236 L212 300 L152 300 Z"/>
<path d="M256 262 L392 430 L120 430 L256 262 Z"/>
</g></svg>
2026-07-21 08:25:24 -04:00
<div><h1>Pine</h1>
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
2026-07-21 05:14:32 -04:00
</header>
2026-07-21 08:25:24 -04:00
<div class="card status">
<div class="row"><b>Whisper</b><span>speech-to-text ready on <code>:10300</code></span></div>
<div class="row"><b>Piper</b><span>text-to-speech ready on <code>:10200</code></span></div>
2026-07-22 02:59:34 -04:00
<div class="row"><b>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</code>)</span></div>
2026-07-21 08:25:24 -04:00
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
</div>
2026-07-22 02:59:34 -04:00
<div class="card status">
<div class="row"><b>Node</b><span id="ns-node">checking…</span></div>
<div class="row"><b>Bitcoin</b><span id="ns-btc">—</span></div>
<div class="row"><b>Peers</b><span id="ns-peers">—</span></div>
</div>
2026-07-21 08:25:24 -04:00
<div class="card insecure" id="insecure">
This page isn’ t running over HTTPS, so the browser blocks Bluetooth.
Open it via its <b>https://…:10380</b> address (accept the self-signed
certificate) and the button below will work.
</div>
2026-07-21 05:14:32 -04:00
<div class="card">
2026-07-21 08:25:24 -04:00
<label for="ssid">WiFi network (SSID)</label>
<input id="ssid" autocomplete="off" spellcheck="false" placeholder="Your 2.4 GHz network">
<label for="pass">WiFi password — typed here, sent only over Bluetooth to the speaker</label>
<input id="pass" type="password" autocomplete="off">
<button id="go">Connect Pine to WiFi</button>
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
2026-07-21 05:14:32 -04:00
</div>
2026-07-21 23:45:53 +01:00
<p class="ha">After WiFi joins, one manual step remains — pair the
speaker in Home Assistant : <b>Settings → Devices & services →
Add Wyoming Protocol</b>, host = the speaker’ s IP, port
2026-07-22 02:59:34 -04:00
<b>10700</b>. Whisper, Piper, openWakeWord and the Assist pipeline
are wired up automatically when Pine installs. Wake word :
<b>“Hey Jarvis.”</b> Ask node things like <i>“what’ s the block
height?”</i>, <i>“how many peers?”</i>, <i>“is the node
synced?”</i> or <i>“what’ s my lightning balance?”</i> — 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.</p>
2026-07-21 23:45:53 +01:00
<p class="ha">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.</p>
2026-07-21 05:14:32 -04:00
</div>
2026-07-21 08:25:24 -04:00
<script>
"use strict" ;
// Improv-over-BLE (improv-wifi spec, verified vs improv-wifi-sdk 1.4.0).
const SVC = "00467768-6228-2272-4663-277478268000";
const CHAR_STATE = "00467768-6228-2272-4663-277478268001";
const CHAR_ERROR = "00467768-6228-2272-4663-277478268002";
const CHAR_RPC = "00467768-6228-2272-4663-277478268003";
const CHAR_RESULT = "00467768-6228-2272-4663-277478268004";
const STATES = {1:"authorization required",2:"authorized",3:"provisioning…",4:"PROVISIONED"};
const ERRORS = {1:"invalid RPC packet",2:"unknown RPC command",
3 : "unable to connect — wrong password, or the SSID isn't reachable on 2.4 GHz" ,
4 : "not authorized — press the button on the device" , 5 : "bad hostname" ,
255 : "unknown device error" };
const logEl = document.getElementById("log");
const log = (msg, cls) => { const l = document.createElement("div");
if (cls) l.className = cls; l.textContent = msg; logEl.appendChild(l);
logEl.scrollTop = logEl.scrollHeight; };
const hex = dv => [...new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength)]
.map(b => b.toString(16).padStart(2, "0")).join(" ");
const btn = document.getElementById("go");
if (!navigator.bluetooth) {
document.getElementById("insecure").style.display = "block";
logEl.textContent = "Web Bluetooth unavailable — open this page over https (see note above).";
}
btn.addEventListener("click", async () => {
const ssid = document.getElementById("ssid").value.trim();
const pass = document.getElementById("pass").value;
if (!ssid) { log("Enter your WiFi network name first.", "err"); return; }
if (!navigator.bluetooth) { log("No Web Bluetooth — open this page over https.", "err"); return; }
btn.disabled = true; logEl.textContent = "";
let device;
try {
log("Opening Bluetooth device chooser…");
device = await navigator.bluetooth.requestDevice({
filters : [ { services : [ SVC] }, { namePrefix: "PineVoice" }],
optionalServices : [ SVC],
});
log(`Selected : ${device.name || "(unnamed)"}`);
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
log("Connecting…");
const gatt = await device.gatt.connect();
const svc = await gatt.getPrimaryService(SVC);
const stateChar = await svc.getCharacteristic(CHAR_STATE);
const errorChar = await svc.getCharacteristic(CHAR_ERROR);
const rpcChar = await svc.getCharacteristic(CHAR_RPC);
const resultChar = await svc.getCharacteristic(CHAR_RESULT);
let done, fail;
const outcome = new Promise((res, rej) => { done = res; fail = rej; });
let authorize; const authorized = new Promise(res => { authorize = res; });
let state = -1;
const onState = (s, via = "") => {
if (s === state) return; state = s;
log(`Device state${via}: ${STATES[s] || s}`, s === 4 ? "ok" : undefined);
if (s >= 2) authorize();
if (s === 4) done(null);
};
stateChar.addEventListener("characteristicvaluechanged",
e => onState(e.target.value.getUint8(0)));
errorChar.addEventListener("characteristicvaluechanged", e => {
const c = e.target.value.getUint8(0);
if (c !== 0) fail(new Error(ERRORS[c] || `error ${c}`)); });
resultChar.addEventListener("characteristicvaluechanged", e => {
const v = e.target.value; log(`RPC result : ${hex(v)}`);
if (v.byteLength > 2 && v.getUint8(1) > 0) {
const n = v.getUint8(2); const b = new Uint8Array(n);
for (let i = 0; i < n; i++) b[i] = v.getUint8(3 + i);
done(new TextDecoder().decode(b)); } });
await stateChar.startNotifications();
await errorChar.startNotifications();
await resultChar.startNotifications();
onState((await stateChar.readValue()).getUint8(0));
const poller = setInterval(async () => {
try { onState((await stateChar.readValue()).getUint8(0), " (polled)");
const ec = (await errorChar.readValue()).getUint8(0);
if (ec !== 0) fail(new Error(ERRORS[ec] || `error ${ec}`)); } catch {} }, 2000);
let nextUrl;
try {
if (state === 1) {
log("Authorization required — press the centre button on the speaker now.", "warn");
await Promise.race([authorized, outcome,
new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out waiting for the button press")), 60000))]);
log("Authorized.", "ok");
}
const enc = new TextEncoder();
const sb = enc.encode(ssid), pb = enc.encode(pass);
const data = new Uint8Array([sb.length, ...sb, pb.length, ...pb]);
const pkt = new Uint8Array([1, data.length, ...data, 0]);
pkt[pkt.length - 1] = pkt.reduce((s, b) => s + b, 0);
log(`Sending WiFi credentials for “${ssid}”…`);
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
else await rpcChar.writeValue(pkt);
log("Credentials delivered — speaker acknowledged.", "ok");
log("Joining WiFi… (the speaker plays a sound within ~20s either way)");
const timeout = new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out after 60s waiting for the device")), 60000));
nextUrl = await Promise.race([outcome, timeout]);
} finally { clearInterval(poller); }
log("✓ PROVISIONED — the ring LED should breathe dim magenta.", "ok");
if (nextUrl) log(`Device reports next step : ${nextUrl}`, "ok");
try { gatt.disconnect(); } catch {}
} catch (err) {
log(`✗ ${err.name || "Error"}: ${err.message}`, "err");
if (err.name === "NotFoundError")
log("No speaker matched, or you closed the chooser. LED blinking yellow? "
+ "Hold the dot button 15s to reset.", "warn");
if (err.name === "NetworkError")
log("BLE link dropped — move closer, and make sure the Mac isn't already "
+ "paired to the speaker (it can hold the link exclusively).", "warn");
try { device?.gatt?.disconnect(); } catch {}
} finally { btn.disabled = false; }
});
2026-07-22 02:59:34 -04:00
// 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);
2026-07-21 08:25:24 -04:00
</script>
2026-07-21 05:14:32 -04:00
</body>
</html>
health_check :
type : tcp
2026-07-21 08:25:24 -04:00
endpoint : localhost:443
2026-07-21 05:14:32 -04:00
interval : 30s
timeout : 5s
retries : 5
start_period : 10s
interfaces :
main :
name : Pine
2026-07-21 08:25:24 -04:00
description : Connect your speaker to WiFi and check the voice assistant
2026-07-21 05:14:32 -04:00
type : ui
port : 10380
2026-07-21 08:32:52 -04:00
protocol : http
2026-07-21 05:14:32 -04:00
path : /
metadata :
author : Archipelago
icon : /assets/img/app-icons/pine.svg
website : https://github.com/rhasspy/wyoming
repo : https://github.com/rhasspy/wyoming
license : MIT
category : home
launch :
open_in_new_tab : true
tags :
- home
- voice
- assistant
- privacy