403 lines
20 KiB
YAML
403 lines
20 KiB
YAML
app:
|
||||
|
|
id: pine
|
|||
|
|
name: Pine
|
|||
|
|
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
|
|||
|
|
# 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
|
|||
|
|
- app_id: pine-openwakeword
|
|||
|
|
- 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
|
|||
|
|
bind: 127.0.0.1
|
|||
|
|
auth: gated
|
|||
|
|
- host: 10381
|
|||
|
|
container: 443
|
|||
|
|
protocol: tcp
|
|||
|
|
auth: none
|
|||
|
|
auth_rationale: >-
|
|||
|
|
Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would
|
|||
|
|
break the secure context navigator.bluetooth needs for WiFi provisioning.
|
|||
|
|
The plain-HTTP entry point (10380) is gated, and it is what the UI opens.
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
# 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
|
|||
|
|
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>Wake word</b><span>“Hey Jarvis” on the speaker (openWakeWord on <code>:10400</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 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>
|
|||
|
|
|
|||
|
|
<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, one manual step remains — pair the
|
|||
|
|
speaker in Home Assistant: <b>Settings → Devices & services →
|
|||
|
|
Add Wyoming Protocol</b>, host = the speaker’s IP, port
|
|||
|
|
<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>
|
|||
|
|
<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>
|
|||
|
|
</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; }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 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);
|
|||
|
|
</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
|