All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s
The launcher must be a secure context for Web Bluetooth, but the UI opens local apps as http://host:port (resolveAppUrl), so an https-only launcher (v1.1.0) would break the Open button with a TLS mismatch. Serve http on 10380 (the Open target) that 301-redirects to the https listener on 10381, so the new tab lands on https where navigator.bluetooth is available. Bump pine 1.1.0 -> 1.1.1; re-sign the catalog (only the pine entry changes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
337 lines
16 KiB
YAML
337 lines
16 KiB
YAML
app:
|
||
id: pine
|
||
name: Pine
|
||
version: "1.1.1"
|
||
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.
|
||
category: home
|
||
|
||
# The user-facing launcher (app_id + container both "pine", matching the
|
||
# runtime references + the live container so the orchestrator adopts it). A
|
||
# 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.
|
||
container_name: pine
|
||
|
||
container:
|
||
image: docker.io/library/nginx:1.27-alpine
|
||
pull_policy: if-not-present
|
||
network: archy-net
|
||
network_aliases: [pine]
|
||
# 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
|
||
|
||
dependencies:
|
||
- app_id: pine-whisper
|
||
- app_id: pine-piper
|
||
- storage: 128Mi
|
||
|
||
resources:
|
||
memory_limit: 64Mi
|
||
|
||
security:
|
||
# cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops
|
||
# workers) binds :443 inside the container — needs the worker-drop caps +
|
||
# 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:
|
||
# 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.
|
||
- host: 10380
|
||
container: 80
|
||
protocol: tcp
|
||
- host: 10381
|
||
container: 443
|
||
protocol: tcp
|
||
|
||
volumes:
|
||
- 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]
|
||
- type: bind
|
||
source: /var/lib/archipelago/pine/index.html
|
||
target: /usr/share/nginx/html/index.html
|
||
options: [ro]
|
||
|
||
environment: []
|
||
|
||
files:
|
||
- path: /var/lib/archipelago/pine/nginx.conf
|
||
overwrite: true
|
||
content: |
|
||
server {
|
||
listen 80;
|
||
server_name _;
|
||
return 301 https://$host:10381$request_uri;
|
||
}
|
||
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;
|
||
location / { try_files $uri $uri/ /index.html; }
|
||
}
|
||
- 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">
|
||
<title>Pine — connect your speaker</title>
|
||
<style>
|
||
:root { color-scheme: dark; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font: 16px/1.6 system-ui, -apple-system, sans-serif;
|
||
background: #0f1512; color: #e7efe9; }
|
||
.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; }
|
||
.tag { color: #8fbfa5; font-size: 14px; margin: 2px 0 0; }
|
||
.card { background: #16201b; border: 1px solid #24332b;
|
||
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; }
|
||
</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>
|
||
<div><h1>Pine</h1>
|
||
<p class="tag">Connect your speaker — everything stays on your node.</p></div>
|
||
</header>
|
||
|
||
<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>
|
||
<div class="row"><b>Speaker</b><span>put it in pairing mode — ring LED blinking yellow</span></div>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<div class="card">
|
||
<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>
|
||
</div>
|
||
|
||
<p class="ha">After WiFi joins, add the speaker to Home Assistant:
|
||
<b>Settings → Devices & services → Add Wyoming Protocol</b>, host =
|
||
the speaker’s IP, port <b>10700</b>, then pick an Assist pipeline using
|
||
Whisper + Piper. Wake word: <b>“Hey Jarvis.”</b></p>
|
||
</div>
|
||
|
||
<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; }
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
health_check:
|
||
type: tcp
|
||
endpoint: localhost:443
|
||
interval: 30s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 10s
|
||
|
||
interfaces:
|
||
main:
|
||
name: Pine
|
||
description: Connect your speaker to WiFi and check the voice assistant
|
||
type: ui
|
||
port: 10380
|
||
protocol: http
|
||
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
|