fix: web UI review findings (step 2b)

- Saving the security settings threw: the form's values were named
  security, hiding the Security object the new token goes to. A save from
  None to None no longer reconnects, as the device closes nobody then.
- Each sync redraws the lists and reloads the settings, so a reconnect
  shows what changed meanwhile; closing the config panel drops its feeds
  even while the socket is down.
- A 401 on backup or an upload opens the login, as a refused socket does
  (Security.tokenRefused); a failed firmware upload says so.
- The general and security panels fill in what the device leaves out at
  its default, so a save never writes back what the form showed before.
  The hostname holds 31 characters, as the device does.
- The views (settings with defaults, a group's shades, frames, scans, the
  network and radio panels, op errors) move to data-src/js/views.js, with
  tests. runOp is the one op path with the busy retry; repeatOf,
  commandArgs, gone(); dead update*List and editedRepeaters removed.
- Tests for ProtoClient.off and a listener removing itself mid-event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-25 20:43:09 +01:00
parent 0df740df6a
commit 3212230f01
5 changed files with 357 additions and 208 deletions

80
data-src/js/views.js Normal file
View file

@ -0,0 +1,80 @@
// What the web UI's panels bind, made from socket protocol 2's items, events
// and settings (docs/PROTOCOL.md), and back. The UI kept its names where they
// differ from the protocol's: a frame's address and rcode, a scan's RSSI, the
// network panel's wifi, ethernet and ip objects, the radio's type. The minify
// step joins this file into index.js; Node loads it on its own for the tests
// (test/js).
class ProtoViews {
// A settings section's reply with what the device left out at its default
// filled in, so a form shows what is saved rather than what it showed
// before.
static settings(schema, section, d) {
return schema.fill(Object.assign({}, d), ProtoViews.SETTINGS[section]);
}
// A group with its shades whole, where the device sends their ids.
static group(model, group) {
if (!group) return group;
const view = Object.assign({}, group);
view.linkedShades = group.linkedShades.map((id) => model.shades.get(id) || { shadeId: id, name: `Shade ${id}`, remoteAddress: 0 });
return view;
}
// A received frame as the frames list and remote linking read it.
static frame(schema, d) {
const f = schema.fill(Object.assign({}, d), ['encKey', 'remoteAddress', 'lastRollingCode', 'command', 'rawCmd', 'rssi', 'bits', 'proto',
'valid', 'pulses']);
return { encKey: f.encKey, address: f.remoteAddress, rcode: f.lastRollingCode, command: f.command, rawCmd: f.rawCmd, rssi: f.rssi,
bits: f.bits, proto: f.proto, valid: f.valid, stepSize: d.stepSize, pulses: f.pulses };
}
static scan(schema, d) {
const s = schema.fill(Object.assign({}, d), ['scanning', 'testFreq', 'testRssi', 'frequency', 'rssi']);
return { scanning: s.scanning, testFreq: s.testFreq, testRSSI: s.testRssi, frequency: s.frequency, RSSI: s.rssi };
}
// The network section as the panel binds it: WiFi, Ethernet and the
// address each in an object of their own, the connection as connType.
static network(schema, d) {
const n = ProtoViews.settings(schema, 'network', d);
const view = { connType: n.connMode, hasEthernet: n.hasEthernet, wifi: {}, ethernet: {}, ip: {} };
for (const name of ProtoViews.WIFI) view.wifi[name] = n[name];
for (const name of ProtoViews.ETHERNET) view.ethernet[name] = n[name];
for (const name of ProtoViews.IP) view.ip[name] = n[name];
return view;
}
// The radio section as the panel binds it, where the bit length is the
// radio's type.
static radio(schema, d) {
const config = ProtoViews.settings(schema, 'radio', d);
config.type = config.bitLength;
delete config.bitLength;
return config;
}
// A save of the radio panel's config: everything but what is only shown.
static radioSave(config) {
const fields = { section: 'radio', bitLength: config.type };
for (const name of ProtoViews.SETTINGS.radio) {
if (name !== 'bitLength' && name !== 'radioInit') fields[name] = config[name];
}
return fields;
}
// An op's refusal as the UI's error box shows it. A shade whose settings
// don't add up carries the reason's number, which the errors table
// describes.
static opError(name, err) {
const e = { htmlError: err.code, service: name, desc: err.desc };
if (err.data && typeof err.data.validation === 'number' && err.data.validation !== 0) e.code = err.data.validation;
return e;
}
}
ProtoViews.WIFI = ['ssid', 'hasPassphrase', 'roaming', 'hidden'];
ProtoViews.ETHERNET = ['boardType', 'phyType', 'CLKMode', 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin'];
ProtoViews.IP = ['dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2'];
// The fields of each settings section, as getSettings sends them
// (src/DeviceOps.cpp).
ProtoViews.SETTINGS = {
general: ['hostname', 'ssdpBroadcast', 'ntpServer', 'posixZone'],
network: ['connMode', 'hasEthernet'].concat(ProtoViews.WIFI, ProtoViews.ETHERNET, ProtoViews.IP),
mqtt: ['enabled', 'pubDisco', 'protocol', 'hostname', 'port', 'username', 'hasPassword', 'rootTopic', 'discoTopic'],
security: ['securityType', 'username', 'hasPassword', 'hasPin'],
radio: ['bitLength', 'proto', 'enabled', 'TXPin', 'RXPin', 'SCKPin', 'MOSIPin', 'MISOPin', 'CSNPin', 'frequency', 'rxBandwidth',
'deviation', 'txPower', 'noiseDetection', 'radioInit']
};
if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoViews };