fix: web UI client review findings (step 2a)

- One socket at a time: a socket being replaced is let go without a word,
  and connect() or a new token while a reconnect waits opens no second one.
  A new token reconnects at once, without a refused.
- Events that came before the schema go as soon as it is whole, ahead of
  the sync frames formatted after them, not at syncEnd over newer state.
- A schema's hash counts only once it has come whole, so a sync cut short
  gets it again.
- Feeds are subscribed again after a reconnect; an unknown feed is refused.
- A long op refused as busy without its id goes again.
- A sync without repeaters clears them; order changes name their list; a
  refused resync is called off.
- refused: a listener can close() to stop the retries, which the UI does
  once /loginContext says a login is needed. The client itself can't tell a
  refused token from a device that is down.
- localStorage keeps the schema by default. minify.py removes its work
  folder even when a bundle fails. ADR 0005 records the two bundles.
- Smaller: ProtoSchema.ROWS/withoutRows, ProtoModel.ITEMS, named type
  numbers, unused feeds map and FakeSocket.all gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 18:04:07 +01:00
parent 99c6019431
commit 4415beb4f7
8 changed files with 382 additions and 147 deletions

View file

@ -15,15 +15,15 @@ class ProtoError extends Error {
}
}
class ProtoClient {
// opts: host, token, WebSocket, storage (getItem/setItem, for the schema),
// setTimeout/clearTimeout, opTimeoutMs.
// opts: host, token, WebSocket, storage (getItem/setItem for the schema;
// localStorage when there is one), setTimeout/clearTimeout, opTimeoutMs.
constructor(opts) {
const deps = ProtoClient.deps();
this.Schema = deps.ProtoSchema;
this.host = opts.host;
this.token = opts.token || '';
this.WebSocket = opts.WebSocket || (typeof WebSocket !== 'undefined' ? WebSocket : null);
this.storage = opts.storage || null;
this.storage = typeof opts.storage !== 'undefined' ? opts.storage : ProtoClient.browserStorage();
this.setTimeout = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
this.clearTimeout = opts.clearTimeout || ((t) => clearTimeout(t));
this.opTimeoutMs = opts.opTimeoutMs || 10000;
@ -32,9 +32,12 @@ class ProtoClient {
this.model.onChange((kind, id, item) => this.emit('change', { kind: kind, id: id, item: item }));
this.listeners = {};
this.pending = new Map();
this.feeds = new Set();
this.nextId = 1;
this.ws = null;
this.ready = false;
this.ready = false; // synced: ops may go
this.schemaReady = false; // frames can be read
this.helloHash = null;
this.opened = false;
this.stopped = true;
this.retries = 0;
@ -49,29 +52,41 @@ class ProtoClient {
return { ProtoSchema: require('./schema.js').ProtoSchema, ProtoModel: require('./model.js').ProtoModel };
return { ProtoSchema: ProtoSchema, ProtoModel: ProtoModel };
}
static browserStorage() {
try {
return typeof localStorage !== 'undefined' ? localStorage : null;
} catch (err) {
return null;
}
}
// cb(d) for a device event by its name (shadeState, frame ...), and for
// the client's own: open, hello, synced, change, refused (closed before it
// opened: the token may have been refused), closed, versionMismatch and
// error (a refusal the device couldn't tie to an op).
// the client's own: open, hello, synced, change, closed, versionMismatch,
// error (a refusal the device couldn't tie to an op) and refused.
// refused: the socket closed before it opened. A browser can't tell a
// refused token from a device that is down, so the client tries again
// 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); }
connect() {
if (!this.stopped && (this.ws || this.reconnectTimer)) return;
this.stopped = false;
this.open();
}
close() {
this.stopped = true;
if (this.reconnectTimer) this.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.cancelReconnect();
if (this.ws) this.ws.close();
}
// A new token, from a login: the device checks it as the socket opens.
// A new token, from a login: the device checks it as the socket opens, so
// the client connects again with it at once.
setToken(token) {
this.token = token || '';
if (this.ws) this.ws.close();
else if (!this.stopped) this.open();
this.stopped = false;
this.retries = 0;
this.open();
}
url() {
const params = [];
@ -79,28 +94,38 @@ class ProtoClient {
if (this.schema.hash) params.push(`schema=${encodeURIComponent(this.schema.hash)}`);
return `ws://${this.host}/ws${params.length ? '?' + params.join('&') : ''}`;
}
open() {
cancelReconnect() {
if (this.reconnectTimer) this.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// One socket at a time: one being replaced is let go without a word.
open() {
this.cancelReconnect();
if (this.ws) {
const old = this.ws;
this.ws = null;
this.lost();
old.close();
}
this.ready = false;
this.schemaReady = false;
this.opened = false;
this.held = [];
const ws = new this.WebSocket(this.url());
this.ws = ws;
ws.onopen = () => {
if (this.ws !== ws) return;
this.opened = true;
this.retries = 0;
this.emit('open');
};
ws.onmessage = (evt) => this.receive(evt.data);
ws.onmessage = (evt) => {
if (this.ws === ws) this.receive(evt.data);
};
ws.onclose = () => {
if (this.ws !== ws) return;
this.ws = null;
this.ready = false;
for (const [, p] of this.pending) {
this.clearTimeout(p.timer);
p.reject(new ProtoError('closed', 'The connection closed'));
}
this.pending.clear();
this.lost();
this.emit(this.opened ? 'closed' : 'refused');
if (this.stopped) return;
// Soon after a close, slower after each failure, at most 10 s.
@ -108,12 +133,21 @@ class ProtoClient {
this.reconnectTimer = this.setTimeout(() => this.open(), ms);
};
}
send(type, d, id) {
// The socket has gone: nothing waiting gets its reply.
lost() {
this.ready = false;
for (const [, p] of this.pending) {
this.clearTimeout(p.timer);
p.reject(new ProtoError('closed', 'The connection closed'));
}
this.pending.clear();
}
frame(type, d, id) {
const frame = { v: ProtoClient.VERSION, t: this.schema.typeNumbers[type] };
if (typeof frame.t === 'undefined') throw new ProtoError('unknownOp', `The device has no message ${type}`);
if (id) frame.id = id;
if (d) frame.d = d;
this.ws.send(JSON.stringify(frame));
return JSON.stringify(frame);
}
// Sends an op, or a control message that wants a reply, by its name with
@ -126,25 +160,39 @@ class ProtoClient {
return;
}
const id = this.nextId++;
let text;
try {
this.send(name, this.schema.pack(fields || {}), id);
text = this.frame(name, this.schema.pack(fields || {}), id);
} catch (err) {
reject(err instanceof ProtoError ? err : new ProtoError('badRequest', err.message));
return;
}
this.ws.send(text);
const timer = this.setTimeout(() => {
this.pending.delete(id);
reject(new ProtoError('timeout', `No reply to ${name}`));
}, this.opTimeoutMs);
this.pending.set(id, { resolve: resolve, reject: reject, timer: timer });
this.pending.set(id, { resolve: resolve, reject: reject, timer: timer, text: text, resends: 0 });
});
}
subscribe(feed) { return this.op('subscribe', { feed: this.schema.feedNumbers[feed] }); }
unsubscribe(feed) { return this.op('unsubscribe', { feed: this.schema.feedNumbers[feed] }); }
// Feeds stay subscribed across reconnects.
subscribe(feed) {
if (typeof this.schema.feedNumbers[feed] === 'undefined') return Promise.reject(new ProtoError('badRequest', `The device has no feed ${feed}`));
this.feeds.add(feed);
return this.op('subscribe', { feed: this.schema.feedNumbers[feed] });
}
unsubscribe(feed) {
this.feeds.delete(feed);
if (typeof this.schema.feedNumbers[feed] === 'undefined') return Promise.resolve();
return this.op('unsubscribe', { feed: this.schema.feedNumbers[feed] });
}
// The shade model and status again, without the schema.
resync() {
this.model.beginSync();
return this.op('sync');
return this.op('sync').catch((err) => {
this.model.cancelSync();
throw err;
});
}
receive(text) {
@ -159,67 +207,99 @@ class ProtoClient {
return;
}
const d = frame.d || {};
// hello, the sync and syncEnd have fixed numbers: they come before the
// schema.
// These have fixed numbers: they come before the schema.
switch (frame.t) {
case 1: this.hello(d); return;
case 4: this.syncFrame(d); return;
case 5: this.syncEnd(); return;
case ProtoClient.HELLO: this.hello(d); return;
case ProtoClient.SYNC: this.syncFrame(d); return;
case ProtoClient.SYNC_END: this.syncEnd(); return;
default: break;
}
if (!this.ready) this.held.push(frame);
// A frame the client can't read yet waits for the schema.
if (!this.schemaReady) this.held.push(frame);
else this.dispatch(frame);
}
hello(d) {
this.fw = d.fw;
// A schema cached for another firmware is no use: the device sends the
// one it has, as it didn't get its hash.
this.helloHash = d.sh;
// A schema the client kept for this one needn't come again; any other
// is no use, and the device sends the one it has.
if (this.schema.hash !== d.sh) {
this.schema = new this.Schema();
this.model.schema = this.schema;
}
this.schema.hash = d.sh;
this.model.beginSync();
this.emit('hello', d);
if (this.schema.hash === d.sh) this.readable();
}
syncFrame(d) {
this.schema.add(d);
const device = {};
for (const key of Object.keys(d)) if (!['T', 'F', 'E', 'Q'].includes(key)) device[key] = d[key];
const device = this.Schema.withoutRows(d);
// The schema's rows come first, so a frame with the device's sections
// has the whole of it.
if (Object.keys(device).length) this.model.applySync(this.schema.expand(device));
this.ws.send(JSON.stringify({ v: ProtoClient.VERSION, t: 3 }));
if (Object.keys(device).length) {
this.readable();
this.model.applySync(this.schema.expand(device));
}
this.ws.send(JSON.stringify({ v: ProtoClient.VERSION, t: ProtoClient.NEXT }));
}
// The schema is whole: frames that waited for it go, in the order they
// came and ahead of the sync frames formatted after them.
readable() {
if (this.schemaReady) return;
this.schemaReady = true;
const held = this.held;
this.held = [];
for (const frame of held) this.dispatch(frame);
}
syncEnd() {
this.readable();
this.model.endSync();
if (!this.ready) {
this.ready = true;
// Only a whole schema is kept, so a sync cut short gets it again.
this.schema.hash = this.helloHash;
this.saveSchema();
const held = this.held;
this.held = [];
for (const frame of held) this.dispatch(frame);
for (const feed of this.feeds) this.op('subscribe', { feed: this.schema.feedNumbers[feed] }).catch(() => {});
}
this.emit('synced', this.model);
}
dispatch(frame) {
const d = this.schema.expand(frame.d || {});
if (frame.t === 2) {
const p = typeof frame.id !== 'undefined' ? this.pending.get(frame.id) : null;
if (!p) {
if (!d.ok) this.emit('error', new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d));
return;
}
this.pending.delete(frame.id);
if (frame.t === ProtoClient.REPLY) this.reply(frame.id, d);
else {
const name = this.schema.types[frame.t];
if (!name) return;
this.model.applyEvent(name, d);
this.emit(name, d);
}
}
reply(id, d) {
const err = d.ok ? null : new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d);
const p = typeof id !== 'undefined' ? this.pending.get(id) : null;
if (p) {
this.pending.delete(id);
this.clearTimeout(p.timer);
if (d.ok) p.resolve(d);
else p.reject(new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d));
if (err) p.reject(err);
else p.resolve(d);
return;
}
const name = this.schema.types[frame.t];
if (!name) return;
this.model.applyEvent(name, d);
this.emit(name, d);
if (!err) return;
// A long message the device couldn't take while it put another one
// together is refused without its id: the long ones waiting go again.
if (err.code === 'busy' && this.resendLong()) return;
this.emit('error', err);
}
resendLong() {
let any = false;
for (const [, p] of this.pending) {
if (p.text.length < ProtoClient.LONG_MESSAGE || p.resends >= 3) continue;
p.resends++;
any = true;
this.setTimeout(() => {
if (this.ws && [...this.pending.values()].includes(p)) this.ws.send(p.text);
}, 200 * p.resends);
}
return any;
}
loadSchema() {
@ -238,5 +318,15 @@ class ProtoClient {
}
}
ProtoClient.VERSION = 2;
// The types a client reads before it has the schema, and next, which it sends
// during the sync.
ProtoClient.HELLO = 1;
ProtoClient.REPLY = 2;
ProtoClient.NEXT = 3;
ProtoClient.SYNC = 4;
ProtoClient.SYNC_END = 5;
// A message longer than a TCP segment reaches the device in pieces
// (docs/PROTOCOL.md).
ProtoClient.LONG_MESSAGE = 1400;
ProtoClient.SCHEMA_KEY = 'espsomfyProtoSchema';
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoClient, ProtoError };

View file

@ -17,21 +17,24 @@ class ProtoModel {
this.listeners = [];
this.seen = null;
}
// cb(kind, id, item): kind is room, shade, group, roomRemoved,
// shadeRemoved, groupRemoved, order, repeaters, header or status.
// cb(kind, id, item): kind is room, shade or group for an item added or
// changed; roomRemoved, shadeRemoved or groupRemoved; roomOrder,
// shadeOrder or groupOrder (item: the ids in order); repeaters, header or
// status.
onChange(cb) { this.listeners.push(cb); }
changed(kind, id, item) { for (const cb of this.listeners) cb(kind, id, item); }
// A sync: the items it doesn't list are gone once it ends.
beginSync() { this.seen = { rooms: new Set(), shades: new Set(), groups: new Set() }; }
// A sync: what it doesn't list is gone once it ends, the repeaters too,
// which it leaves out when there are none.
beginSync() { this.seen = { rooms: new Set(), shades: new Set(), groups: new Set(), repeaters: false }; }
// A sync that won't come after all.
cancelSync() { this.seen = null; }
applySync(d) {
if (d.header) {
this.header = this.schema.fill(Object.assign({}, d.header), ProtoModel.FIELDS.header);
this.changed('header', 0, this.header);
}
for (const r of d.rooms || []) this.putRoom(r);
for (const s of d.shades || []) this.putShade(s);
for (const g of d.groups || []) this.putGroup(g);
for (const kind of ProtoModel.ITEMS) for (const item of d[kind.list] || []) this.put(kind, item);
if (d.repeaters) this.putRepeaters(d.repeaters);
if (d.status) {
this.putStatus(d.status, ProtoModel.FIELDS.memory);
@ -40,30 +43,31 @@ class ProtoModel {
}
endSync() {
if (!this.seen) return;
const lists = [['rooms', 'roomRemoved'], ['shades', 'shadeRemoved'], ['groups', 'groupRemoved']];
for (const [list, removed] of lists) {
for (const id of [...this[list].keys()]) {
if (this.seen[list].has(id)) continue;
this[list].delete(id);
this.changed(removed, id);
for (const kind of ProtoModel.ITEMS) {
const map = this[kind.list];
for (const id of [...map.keys()]) {
if (this.seen[kind.list].has(id)) continue;
map.delete(id);
this.changed(kind.removed, id);
}
}
if (!this.seen.repeaters && this.repeaters.length) this.putRepeaters([]);
this.seen = null;
}
// An event by its type's name; false for one that isn't about the model.
// An event by its type's name; false for one that isn't about the model,
// or that changed nothing.
applyEvent(type, d) {
for (const kind of ProtoModel.ITEMS) {
const id = d[kind.id] || 0;
switch (type) {
case kind.name: this.put(kind, d); return true;
case kind.state: return this.mergeState(kind, id, d);
case kind.removed: return this.remove(kind, id);
case kind.order: return this.order(kind, d.ids || []);
default: break;
}
}
switch (type) {
case 'room': this.putRoom(d); return true;
case 'shade': this.putShade(d); return true;
case 'group': this.putGroup(d); return true;
case 'shadeState': return this.mergeState(this.shades, 'shade', d.shadeId, d, ProtoModel.FIELDS.shadeState);
case 'groupState': return this.mergeState(this.groups, 'group', d.groupId, d, ProtoModel.FIELDS.groupState);
case 'roomRemoved': return this.remove(this.rooms, type, d.roomId || 0);
case 'shadeRemoved': return this.remove(this.shades, type, d.shadeId || 0);
case 'groupRemoved': return this.remove(this.groups, type, d.groupId || 0);
case 'roomOrder': return this.order(this.rooms, d.ids || []);
case 'shadeOrder': return this.order(this.shades, d.ids || []);
case 'groupOrder': return this.order(this.groups, d.ids || []);
case 'repeaters': this.putRepeaters(d.repeaters || []); return true;
case 'memory': this.putStatus(d, ProtoModel.FIELDS.memory); return true;
case 'wifi': this.putStatus(d, ProtoModel.FIELDS.wifi); return true;
@ -78,45 +82,42 @@ class ProtoModel {
groupList() { return ProtoModel.sorted(this.groups); }
static sorted(map) { return [...map.values()].sort((a, b) => a.sortOrder - b.sortOrder); }
putRoom(r) { this.put(this.rooms, 'room', r.roomId, r, ProtoModel.FIELDS.room, 'rooms'); }
putShade(s) {
// Its linked remotes leave out their defaults too.
const linked = (s.linkedRemotes || []).map((r) => this.schema.fill(Object.assign({}, r), ProtoModel.FIELDS.linkedRemote));
this.put(this.shades, 'shade', s.shadeId, Object.assign({}, s, { linkedRemotes: linked }), ProtoModel.FIELDS.shade, 'shades');
}
putGroup(g) { this.put(this.groups, 'group', g.groupId, g, ProtoModel.FIELDS.group, 'groups'); }
// A whole item: what it had before is replaced.
put(map, kind, id, item, fields, list) {
id = id || 0;
const whole = this.schema.fill(Object.assign({}, item), fields);
map.set(id, whole);
if (this.seen) this.seen[list].add(id);
this.changed(kind, id, whole);
put(kind, item) {
const id = item[kind.id] || 0;
const whole = this.schema.fill(Object.assign({}, item), ProtoModel.FIELDS[kind.name]);
// A shade's linked remotes leave out their defaults too.
if (kind.name === 'shade')
whole.linkedRemotes = whole.linkedRemotes.map((r) => this.schema.fill(Object.assign({}, r), ProtoModel.FIELDS.linkedRemote));
this[kind.list].set(id, whole);
if (this.seen) this.seen[kind.list].add(id);
this.changed(kind.name, id, whole);
}
// Part of an item: only its state. A state of an item not synced yet
// waits for the item.
mergeState(map, kind, id, d, fields) {
const item = map.get(id || 0);
mergeState(kind, id, d) {
const item = this[kind.list].get(id);
if (!item) return false;
Object.assign(item, this.schema.fill(Object.assign({}, d), fields));
this.changed(kind, item[`${kind}Id`], item);
Object.assign(item, this.schema.fill(Object.assign({}, d), ProtoModel.FIELDS[kind.state]));
this.changed(kind.name, id, item);
return true;
}
remove(map, kind, id) {
if (!map.delete(id)) return false;
this.changed(kind, id);
remove(kind, id) {
if (!this[kind.list].delete(id)) return false;
this.changed(kind.removed, id);
return true;
}
order(map, ids) {
order(kind, ids) {
ids.forEach((id, i) => {
const item = map.get(id);
const item = this[kind.list].get(id);
if (item) item.sortOrder = i;
});
this.changed('order', 0, ids);
this.changed(kind.order, 0, ids);
return true;
}
putRepeaters(list) {
this.repeaters = list.slice();
if (this.seen) this.seen.repeaters = true;
this.changed('repeaters', 0, this.repeaters);
}
putStatus(d, fields) {
@ -126,9 +127,17 @@ class ProtoModel {
this.changed('status', 0, this.status);
}
}
// Rooms, shades and groups: their list, id, and the events about them. Rooms
// have no state of their own.
ProtoModel.ITEMS = [
{ name: 'room', list: 'rooms', id: 'roomId', state: null, removed: 'roomRemoved', order: 'roomOrder' },
{ name: 'shade', list: 'shades', id: 'shadeId', state: 'shadeState', removed: 'shadeRemoved', order: 'shadeOrder' },
{ name: 'group', list: 'groups', id: 'groupId', state: 'groupState', removed: 'groupRemoved', order: 'groupOrder' }
];
// The fields of each kind of item, as the device writes them
// (src/ProtoSync.cpp, src/outputs/ProtoSocket.cpp): what a frame that leaves
// one out means by it.
// one out means by it. The schema has each field's default, not which items
// have it.
ProtoModel.FIELDS = {
header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength',
'proto', 'serverId', 'hostname', 'model', 'chipModel', 'fw', 'connType'],

View file

@ -11,10 +11,15 @@ class ProtoSchema {
this.keys = {}; // name -> key
this.errors = {};
this.errorNumbers = {};
this.feeds = {};
this.feedNumbers = {};
this.rows = { T: [], F: [], E: [], Q: [] };
}
// A sync frame's payload without the schema's rows: the device's sections.
static withoutRows(d) {
const out = {};
for (const key of Object.keys(d)) if (!ProtoSchema.ROWS.includes(key)) out[key] = d[key];
return out;
}
// The schema's rows in a sync frame's payload: T types, F fields, E error
// codes, Q feeds. They may span several frames.
add(d) {
@ -30,11 +35,8 @@ class ProtoSchema {
this.errors[number] = name;
this.errorNumbers[name] = number;
}
for (const [number, name] of d.Q || []) {
this.feeds[number] = name;
this.feedNumbers[name] = number;
}
for (const k of ['T', 'F', 'E', 'Q']) if (d[k]) this.rows[k].push(...d[k]);
for (const [number, name] of d.Q || []) this.feedNumbers[name] = number;
for (const k of ProtoSchema.ROWS) if (d[k]) this.rows[k].push(...d[k]);
}
get empty() { return Object.keys(this.fields).length === 0; }
// What a field left out of a frame is: its default.
@ -93,4 +95,7 @@ class ProtoSchema {
return s.empty ? null : s;
}
}
// The keys of the schema's rows in a sync frame, fixed as a client needs them
// before it has the schema.
ProtoSchema.ROWS = ['T', 'F', 'E', 'Q'];
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoSchema };