archy/apps/pine/manifest.yml
archipelago a71a18bffd
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m47s
fix(pine): serve the Connect-to-WiFi button over an http->https redirect
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>
2026-07-21 08:32:52 -04:00

337 lines
16 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 isnt 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 &amp; services → Add Wyoming Protocol</b>, host =
the speakers 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