pine/index.html

224 lines
10 KiB
HTML
Raw Permalink Normal View History

<!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>