feat: PineVoice Improv-BLE WiFi provisioning page

Local Web Bluetooth page that provisions the Pine64 PineVoice speaker over
the Improv BLE protocol. Waits for the AUTHORIZED state before sending
credentials (per the Improv spec), uses write-with-response since the
firmware declares the RPC characteristic write-only, and logs the device
state/error sequence for diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-20 22:51:54 +01:00
commit e798cde7a1
4 changed files with 300 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.DS_Store

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# PineVoice WiFi Setup Tool
Local Improv-BLE provisioning page for the Pine64 PineVoice smart speaker.
Serves from localhost so Web Bluetooth works and no VPN/proxy can interfere.
## Run
```sh
python3 -m http.server 8377 --bind 127.0.0.1 -d ~/Projects/pine
```
Then open <http://localhost:8377> in Chrome (needs Web Bluetooth — Chrome/Edge, not Safari/Firefox).
## Use
1. Put the speaker in provisioning mode: ring LED blinking yellow
(hold the • dot button 15 s to reset if needed).
2. Enter SSID + password, click **Connect PineVoice**, pick *PineVoice* in the
Bluetooth chooser.
3. If the log says *authorization required*, **press the centre button** on the
speaker. The page waits for this and only sends credentials once the device
reports `authorized`. Authorization lapses after ~1 min, so don't linger.
4. Success: log shows `✓ PROVISIONED`, speaker plays a chime, LED breathes dim
magenta. Failure: speaker plays a failure sound within ~20 s and the log
shows the reason.
## After WiFi: add to Home Assistant
Settings → Devices & services → discovered **Wyoming Protocol** device →
Configure. If not auto-discovered: Add integration → Wyoming Protocol →
host = speaker IP, port `10700`. Requires an Assist pipeline with
Whisper (STT) and Piper (TTS). Wake word: "Hey Jarvis", or press the center button.
## The gotchas this tool fixes
**Authorization ordering.** Per the [Improv BLE spec](https://www.improv-wifi.com/ble/),
a device in `AUTHORIZATION_REQUIRED` (`0x01`) rejects a `WIFI_SETTINGS` RPC with
error `0x04`. The client must wait for the state to become `AUTHORIZED` (`0x02`)
— i.e. for the physical button press — before writing. Sending immediately looks
like a successful write (the device ACKs the GATT write) but provisioning never
starts.
**Write-with-response.** The PineVoice firmware declares its Improv RPC characteristic as
write-WITH-response only (`GATT_CHRC_PROP_WRITE` in
`pinevoice_fw_e907/app/src/improv/improv.c`). Anything that sends credentials
as an unacknowledged BLE Write Command is **silently ignored** — no error, no
state change. This page uses `writeValueWithResponse` explicitly.
Firmware source: <https://codeberg.org/pine64/pinevoice_smartspeaker_sdk>

27
ha-stack.yml Normal file
View File

@ -0,0 +1,27 @@
# Home Assistant + Wyoming voice pipeline for the PineVoice speakers.
# Deploy as a Portainer stack (inside the Umbrel Portainer DinD).
# /data/ paths persist across reboots (map to ~/umbrel/app-data/portainer/data/docker/).
services:
homeassistant:
image: homeassistant/home-assistant:2026.7.1
restart: unless-stopped
ports:
- "8123:8123"
environment:
- TZ=Europe/London
volumes:
- /data/homeassistant:/config
whisper:
image: rhasspy/wyoming-whisper:3.4.1
restart: unless-stopped
command: --model base-int8 --language en
volumes:
- /data/wyoming-whisper:/data
piper:
image: rhasspy/wyoming-piper:2.2.2
restart: unless-stopped
command: --voice en_GB-alba-medium
volumes:
- /data/wyoming-piper:/data

223
index.html Normal file
View File

@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PineVoice WiFi Setup (local)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0a0a0f; color: #e8e8ee; min-height: 100vh;
font: 16px/1.5 -apple-system, BlinkMacSystemFont, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 2rem;
}
.card {
width: 100%; max-width: 440px; padding: 2rem;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12);
border-radius: 16px; backdrop-filter: blur(12px);
}
h1 { font-size: 1.3rem; margin-bottom: .25rem; }
p.sub { color: #9a9aa8; font-size: .85rem; margin-bottom: 1.5rem; }
label { display: block; font-size: .8rem; color: #9a9aa8; margin: 1rem 0 .3rem; }
input {
width: 100%; padding: .7rem .9rem; font-size: 1rem; color: #e8e8ee;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px; outline: none;
}
input:focus { border-color: rgba(120,160,255,0.6); }
button {
width: 100%; margin-top: 1.5rem; padding: .8rem; font-size: 1rem; font-weight: 600;
color: #e8e8ee; background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2); border-radius: 10px; cursor: pointer;
backdrop-filter: blur(8px); transition: background .15s;
}
button:hover:not(:disabled) { background: rgba(255,255,255,0.14); }
button:disabled { opacity: .4; cursor: default; }
#log {
margin-top: 1.25rem; padding: .8rem; min-height: 4.5rem; font-size: .8rem;
font-family: ui-monospace, monospace; white-space: pre-wrap;
background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #b8b8c8; max-height: 14rem; overflow-y: auto;
}
.ok { color: #7dd8a0; } .err { color: #ee8888; } .warn { color: #e8c878; }
</style>
</head>
<body>
<div class="card">
<h1>PineVoice WiFi Setup</h1>
<p class="sub">Improv-over-BLE, served from localhost. Speaker LED must be blinking yellow.</p>
<label for="ssid">Network (SSID)</label>
<input id="ssid" value="soveng" autocomplete="off" spellcheck="false">
<label for="pass">WiFi password — type it yourself, it never leaves this page except over BLE to the speaker</label>
<input id="pass" type="password" autocomplete="off">
<button id="go">Connect PineVoice</button>
<div id="log">Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.</div>
</div>
<script>
"use strict";
// Improv BLE protocol — constants and packet format per improv-wifi spec
// (verified against improv-wifi-sdk 1.4.0 source, Apache-2.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 line = document.createElement("div");
if (cls) line.className = cls;
line.textContent = msg;
logEl.appendChild(line);
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");
btn.addEventListener("click", async () => {
const ssid = document.getElementById("ssid").value.trim();
const pass = document.getElementById("pass").value;
if (!ssid) { log("Enter an SSID first.", "err"); return; }
if (!navigator.bluetooth) { log("No Web Bluetooth in this browser.", "err"); return; }
btn.disabled = true;
logEl.textContent = "";
let device;
try {
log("Opening Bluetooth device chooser…");
// Match on the service UUID *or* the name: Improv gadgets often advertise
// only their local name plus service data under 16-bit 0x4677, in which case
// a services-only filter shows an empty chooser. optionalServices is required
// for the namePrefix match — a device matched by name gets no automatic
// access to SVC and getPrimaryService() would throw SecurityError.
device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SVC] }, { namePrefix: "PineVoice" }],
optionalServices: [SVC],
});
log(`Selected: ${device.name || "(unnamed)"} [id ${device.id}]`);
device.addEventListener("gattserverdisconnected", () => log("BLE disconnected."));
log("Connecting GATT…");
const gatt = await device.gatt.connect();
const svc = await gatt.getPrimaryService(SVC);
// Sequential — some OSes reject parallel GATT commands
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);
const pr = rpcChar.properties;
log(`RPC char properties: write=${pr.write} writeWithoutResponse=${pr.writeWithoutResponse}`);
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);
// The device only accepts credentials once it has left AUTHORIZATION_REQUIRED.
if (s >= 2) authorize();
if (s === 4) done(null);
};
stateChar.addEventListener("characteristicvaluechanged",
e => onState(e.target.value.getUint8(0)));
errorChar.addEventListener("characteristicvaluechanged", e => {
const code = e.target.value.getUint8(0);
if (code !== 0) fail(new Error(ERRORS[code] || `error ${code}`));
});
resultChar.addEventListener("characteristicvaluechanged", e => {
const v = e.target.value;
log(`RPC result bytes: ${hex(v)}`);
// RPC result: <cmd><len><len1><str1>… — first value is redirect URL if any.
// A short/empty result carries no URL and is not itself proof of success,
// so fall through and wait for state 0x04 instead.
if (v.byteLength > 2 && v.getUint8(1) > 0) {
const n = v.getUint8(2);
const bytes = new Uint8Array(n);
for (let i = 0; i < n; i++) bytes[i] = v.getUint8(3 + i);
done(new TextDecoder().decode(bytes));
}
});
await stateChar.startNotifications();
await errorChar.startNotifications();
await resultChar.startNotifications();
onState((await stateChar.readValue()).getUint8(0));
// Belt-and-braces: poll in case notifications don't arrive. Runs from the
// start so it covers the authorization wait as well as provisioning.
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 center button on the PineVoice now.", "warn");
await Promise.race([
authorized,
outcome, // surfaces a device error while we're waiting
new Promise((_, rej) => setTimeout(
() => rej(new Error("timed out waiting for the button press")), 60000)),
]);
log("Authorized.", "ok");
}
// Packet: <cmd=0x01><data len><ssid len><ssid><pass len><pass><checksum>
const enc = new TextEncoder();
const ssidBytes = enc.encode(ssid), passBytes = enc.encode(pass);
const data = new Uint8Array([
ssidBytes.length, ...ssidBytes, passBytes.length, ...passBytes]);
const pkt = new Uint8Array([1, data.length, ...data, 0]);
pkt[pkt.length - 1] = pkt.reduce((sum, b) => sum + b, 0); // Uint8Array truncates to &0xFF
log(`Sending WiFi credentials for “${ssid}”…`);
// PineVoice firmware declares the RPC char as write-WITH-response only
// (GATT_CHRC_PROP_WRITE) — an unacknowledged Write Command is silently dropped.
if (rpcChar.writeValueWithResponse) await rpcChar.writeValueWithResponse(pkt);
else await rpcChar.writeValue(pkt);
log("Credentials delivered — speaker acknowledged the write.", "ok");
log("Joining WiFi… (the speaker will play 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 now 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("Chooser found no match, or you dismissed it. If the list was empty the speaker "
+ "isn't advertising — LED blinking yellow? Hold the dot button 15s to reset.", "warn");
if (err.name === "SecurityError")
log("Chrome blocked access to the Improv service (optionalServices).", "warn");
if (err.name === "NetworkError")
log("BLE link dropped. Move closer, and make sure macOS itself isn't already "
+ "paired/connected to the speaker — it can hold the GATT link exclusively.", "warn");
try { device?.gatt?.disconnect(); } catch {}
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>