feat: web UI on socket protocol 2 (step 2b)

The UI talks to the device over the one socket at /ws: the sync fills the
shade model, its events redraw the lists, and every read, command, edit,
setting, scan and reboot is an op. Only /loginContext, /login and the file
transfers stay on HTTP, so a page load opens no other connection.

- The 8080 socket, editJSON/requestResult and the /controller, /rooms,
  /shades, /groups, /room, /shade, /group, settings and command routes are
  no longer used by the UI.
- An edit's callback gets the item once its event is in (ProtoClient
  waitFor); busy edits and saves are sent again.
- The token is kept in sessionStorage. When /ws is refused and
  /loginContext says a login is needed, the client stops retrying and the
  login panel opens. Backup and uploads send the token.
- A banner shows when the device speaks another protocol version.
- Removed dead code: the /pairShade call and its unused /unpairShade twin,
  loadLogin, login.html (broken, linked from nowhere), Secure Config Only's
  checkbox (protocol 2 has two modes) and the HTTP helpers nothing uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 20:12:54 +01:00
parent 4415beb4f7
commit 0df740df6a
6 changed files with 570 additions and 944 deletions

View file

@ -68,7 +68,27 @@ class ProtoClient {
// unless a listener calls close(), as the UI does once /loginContext says
// a login is needed (docs/SECURITY_PLAN.md).
on(name, cb) { (this.listeners[name] = this.listeners[name] || []).push(cb); }
emit(name, d) { for (const cb of this.listeners[name] || []) cb(d); }
off(name, cb) { this.listeners[name] = (this.listeners[name] || []).filter((x) => x !== cb); }
emit(name, d) { for (const cb of (this.listeners[name] || []).slice()) cb(d); }
// The next event of that name that match(d) accepts, or null after ms.
// An edit's events follow its reply, so this is how a caller gets the
// item the edit left: the model has it by then.
waitFor(name, match, ms) {
return new Promise((resolve) => {
let timer = null;
const cb = (d) => {
if (match && !match(d)) return;
this.off(name, cb);
this.clearTimeout(timer);
resolve(d);
};
this.on(name, cb);
timer = this.setTimeout(() => {
this.off(name, cb);
resolve(null);
}, ms);
});
}
connect() {
if (!this.stopped && (this.ws || this.reconnectTimer)) return;