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>
224 lines
10 KiB
HTML
224 lines
10 KiB
HTML
<!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>
|