From 3212230f01515fa6c4fdf4c371f8136a38cfad99 Mon Sep 17 00:00:00 2001 From: cjkas Date: Sun, 25 Oct 2026 20:43:09 +0100 Subject: [PATCH] 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 --- data-src/index.js | 386 +++++++++++++++++++---------------------- data-src/js/views.js | 80 +++++++++ minify.py | 2 +- test/js/client.test.js | 15 ++ test/js/views.test.js | 82 +++++++++ 5 files changed, 357 insertions(+), 208 deletions(-) create mode 100644 data-src/js/views.js create mode 100644 test/js/views.test.js diff --git a/data-src/index.js b/data-src/index.js index 480b320..7bbb3c4 100644 --- a/data-src/index.js +++ b/data-src/index.js @@ -356,82 +356,70 @@ function putJSONSync(url, data, cb) { // can't spare (docs/adr/0005-one-websocket-protocol.md). var proto = null; var protoSynced = false; +var everSynced = false; var connectFailed = 0; -// An op's refusal as ui.serviceError shows it. A shade whose settings don't -// add up carries the reason's number, which the errors table describes. -function opError(name, err) { - let 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; +// An op as a promise of its reply, refused with what ui.serviceError shows. +// retryBusy sends it again while the device says busy: it saves settings and +// applies edits one at a time, whoever sent them. A busy command is the +// radio's queue or a moving shade, which the user decides about. +function runOp(name, fields, retryBusy) { + return new Promise((resolve, reject) => { + if (!proto) { + reject(ProtoViews.opError(name, { code: 'notConnected', desc: 'Not connected to the device' })); + return; + } + let attempts = 0; + let send = () => proto.op(name, fields).then(resolve, (err) => { + if (retryBusy && err.code === 'busy' && ++attempts < 10) setTimeout(send, 300); + else reject(ProtoViews.opError(name, err)); + }); + send(); + }); } -function notConnected(name) { return opError(name, { code: 'notConnected', desc: 'Not connected to the device' }); } -// Sends an op; cb(err, reply) as the HTTP helpers did. With wait the spinner -// shows until the reply. The device saves settings one at a time, so a save -// that finds another running tries again; a busy command is the radio's queue -// or a moving shade, which the user decides about. +// cb(err, reply) as the HTTP helpers did. With wait the spinner shows until +// the reply. function sendOp(name, fields, cb, wait) { let overlay = wait ? ui.waitMessage(document.getElementById('divContainer')) : null; - let attempts = 0; let done = (err, d) => { if (overlay) overlay.remove(); if (typeof cb === 'function') cb(err, d); }; - if (!proto) { - done(notConnected(name), null); - return; - } - let send = () => proto.op(name, fields).then((d) => done(null, d), (err) => { - if (err.code === 'busy' && name === 'saveSettings' && ++attempts < 10) setTimeout(send, 300); - else done(opError(name, err), null); - }); - send(); + runOp(name, fields, name === 'saveSettings').then((d) => done(null, d), (err) => done(err, null)); } -// What an edit's cb gets: the room, shade or group as the edit's event left -// it. id picks the item's id from the reply (an add's new id) or the fields. -function editedItem(kind, id) { +// Where an edit's room, shade or group is once its event is in: the event to +// wait for, and the item's id from the reply (an add's new id) or the fields. +function editTarget(kind, idOf) { let key = `${kind}Id`; let map = { room: 'rooms', shade: 'shades', group: 'groups' }[kind]; - return { event: kind, id: id, match: (d, i) => d[key] === i, read: (i) => kind === 'group' ? groupView(proto.model.groups.get(i)) : proto.model[map].get(i) }; + return { + event: kind, idOf: idOf, match: (d, id) => d[key] === id, + read: (id) => kind === 'group' ? ProtoViews.group(proto.model, proto.model.groups.get(id)) : proto.model[map].get(id) + }; } -function editedRepeaters() { return { event: 'repeaters', id: () => 0, match: () => true, read: () => proto.model.repeaters.slice() }; } -// An edit. The device runs one at a time, so one that finds another running -// tries again. Its events follow the reply: with after, cb gets the item once -// its event is in; without, the lists redraw from the events anyway. -function sendEdit(name, fields, after, cb) { - if (!proto) { - cb(notConnected(name), null); - return; - } +// An edit, with the spinner up until it is done. Its events follow the +// reply: with a target, cb gets the item once its event is in; without, the +// lists redraw from the events anyway. +function sendEdit(name, fields, target, cb) { let overlay = ui.waitMessage(document.getElementById('divContainer')); - let attempts = 0; let finish = (err, item) => { overlay.remove(); if (typeof cb === 'function') cb(err, item); }; - let send = () => { - proto.op(name, fields).then((reply) => { - if (!after) { - finish(null, reply); - return; - } - let id = after.id(reply); - // Without the event the reply is all there is, an add's new id in it. - proto.waitFor(after.event, (d) => after.match(d, id), 3000).then(() => finish(null, after.read(id) || reply)); - }, (err) => { - if (err.code === 'busy' && ++attempts < 10) setTimeout(send, 300); - else finish(opError(name, err), null); - }); - }; - send(); -} -// A group as the UI shows it: its shades whole, where the device sends ids. -function groupView(group) { - if (!group) return group; - let view = Object.assign({}, group); - view.linkedShades = group.linkedShades.map((id) => proto.model.shades.get(id) || { shadeId: id, name: `Shade ${id}`, remoteAddress: 0 }); - return view; + runOp(name, fields, true).then((reply) => { + if (!target) { + finish(null, reply); + return; + } + let id = target.idOf(reply); + // Without the event the reply is all there is, an add's new id in it. + proto.waitFor(target.event, (d) => target.match(d, id), 3000).then(() => finish(null, target.read(id) || reply)); + }, (err) => finish(err, null)); } +// A command's repeat count, when the caller gave one. +function repeatOf(repeat) { return typeof repeat === 'number' ? repeat : undefined; } +function gone(what) { ui.errorMessage(`The ${what} is no longer there.`); } // The lists redraw from the model after events, a burst of them at once. +// While the socket is down they wait for the next sync. var renderPending = {}; var renderTimer = null; function render(part) { @@ -444,7 +432,7 @@ function render(part) { // Shades and groups show their room's name. if (p.rooms) somfy.setRoomsList(proto.model.roomList()); if (p.rooms || p.shades) somfy.setShadesList(proto.model.shadeList()); - if (p.rooms || p.groups) somfy.setGroupsList(proto.model.groupList().map(groupView)); + if (p.rooms || p.groups) somfy.setGroupsList(proto.model.groupList().map((g) => ProtoViews.group(proto.model, g))); if (p.repeaters) somfy.setRepeaterList(proto.model.repeaters); }, 20); } @@ -466,19 +454,11 @@ function showStatus(status) { else wifi.procWifiStrength({ ssid: status.ssid, channel: status.channel, strength: status.strength }); } // The feeds the config panel shows: received frames and the frequency scan. +// The client keeps them across reconnects, so they are set even while the +// socket is down. function subscribeConfigFeeds(on) { - if (!proto || !protoSynced) return; - for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch((err) => console.log(err)); -} -// A received frame as the frames list and remote linking read it. -function frameView(d) { - let f = proto.schema.fill(Object.assign({}, d), ['encKey', 'remoteAddress', 'lastRollingCode', 'command', 'rawCmd', 'rssi', 'bits', 'proto', 'valid', 'stepSize', '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 }; -} -function scanView(d) { - let s = proto.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 }; + if (!proto) return; + for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch(() => {}); } function showVersionMismatch(v) { if (document.getElementById('divVersionMismatch')) return; @@ -496,8 +476,10 @@ function startSocket() { } let host = window.location.protocol === 'file:' ? hst : window.location.host; proto = new ProtoClient({ host: host, token: security.apiKey }); + // Each sync, the first and after a reconnect: what changed meanwhile shows. proto.on('synced', () => { - let first = !protoSynced; + let first = !everSynced; + everSynced = true; protoSynced = true; connectFailed = 0; clearSocketWait(); @@ -505,14 +487,13 @@ function startSocket() { general.reload(); return; } - if (!first) return; - ui.clearErrors(); + if (first) ui.clearErrors(); general.loadGeneral(); wifi.loadNetwork(); somfy.loadSomfy(); mqtt.loadMQTT(); showStatus(proto.model.status); - if (ui.isConfigOpen()) subscribeConfigFeeds(true); + if (first && ui.isConfigOpen()) subscribeConfigFeeds(true); }); let lost = (refused) => { protoSynced = false; @@ -543,8 +524,8 @@ function startSocket() { proto.on('memory', () => showStatus(proto.model.status)); proto.on('wifi', () => showStatus(proto.model.status)); proto.on('ethernet', () => showStatus(proto.model.status)); - proto.on('frame', (d) => somfy.procRemoteFrame(frameView(d))); - proto.on('frequencyScan', (d) => somfy.procFrequencyScan(scanView(d))); + proto.on('frame', (d) => somfy.procRemoteFrame(ProtoViews.frame(proto.schema, d))); + proto.on('frequencyScan', (d) => somfy.procFrequencyScan(ProtoViews.scan(proto.schema, d))); proto.on('shadeCommanded', (d) => console.log(d)); // Another client saved settings: show them as they are now. proto.on('settingsChanged', (d) => { @@ -1128,15 +1109,20 @@ class Security { getJSON('/loginContext', (err, ctx) => { this.checkingRefusal = false; if (err || ctx.type === 0) return; - proto.close(); - clearSocketWait(); - this.setToken(''); - this.authenticated = false; this.type = ctx.type; - this.authUser(); - document.getElementById('btnCancelLogin').style.display = 'none'; + this.tokenRefused(); }); } + // The device refused the token: open the login, with no retry. Logging + // in connects the socket again with the new one. + tokenRefused() { + if (proto) proto.close(); + clearSocketWait(); + this.setToken(''); + this.authenticated = false; + this.authUser(); + document.getElementById('btnCancelLogin').style.display = 'none'; + } async loadContext() { let pnl = document.getElementById('divUnauthenticated'); pnl.querySelector('#loginButtons').style.display = 'none'; @@ -1396,7 +1382,7 @@ class General { general.setAppVersion(); sendOp('getSettings', { section: 'general' }, (err, settings) => { if (err) console.log(err); - else ui.toElement(pnl, { general: settings }); + else ui.toElement(pnl, { general: ProtoViews.settings(proto.schema, 'general', settings) }); }); } loadSecurity() { @@ -1405,7 +1391,7 @@ class General { sendOp('getSettings', { section: 'security' }, (err, security) => { overlay.remove(); if (err) ui.serviceError(err); - else general.setSecurityConfig(security); + else general.setSecurityConfig(ProtoViews.settings(proto.schema, 'security', security)); }); } async setAppVersion() { @@ -1438,8 +1424,8 @@ class General { ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The host name must only include numbers, letters, or dash.'; valid = false; } - if (valid && obj.hostname.length > 32) { - ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 32 characters.'; + if (valid && obj.hostname.length > 31) { + ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 31 characters.'; valid = false; } if (valid && typeof obj.ntpServer === 'string' && obj.ntpServer.length > 64) { @@ -1502,24 +1488,24 @@ class General { } } saveSecurity() { - let security = ui.fromElement(document.getElementById('divSecurityOptions')).security; - console.log(security); - let sec = { type: security.type, username: security.username, password: security.password, pin: '' }; + let form = ui.fromElement(document.getElementById('divSecurityOptions')).security; + console.log(form); + let sec = { type: form.type, username: form.username, password: form.password, pin: '' }; // Pin entry. for (let i = 0; i < 4; i++) { - sec.pin += security.pin[`d${i}`]; + sec.pin += form.pin[`d${i}`]; } // Blank secret fields keep the stored value while the type (and username) stay the same. let saved = this.savedSecurity || {}; - let keepPin = security.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; - let keepPassword = security.type === 2 && sec.password === '' && security.repeatpassword === '' && + let keepPin = form.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; + let keepPassword = form.type === 2 && sec.password === '' && form.repeatpassword === '' && saved.hasPassword && saved.type === 2 && sec.username === saved.username; // Only send the secret for the selected type so the other one stays stored. - if (security.type !== 1 || keepPin) delete sec.pin; - if (security.type !== 2 || keepPassword) delete sec.password; + if (form.type !== 1 || keepPin) delete sec.pin; + if (form.type !== 2 || keepPassword) delete sec.password; let confirm = ''; console.log(sec); - if (security.type === 1 && !keepPin) { // Pin Entry + if (form.type === 1 && !keepPin) { // Pin Entry // Make sure our pin is 4 digits. if (sec.pin.length !== 4) { ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.'; @@ -1527,7 +1513,7 @@ class General { } confirm = '

Please keep your PIN safe and above all remember it. The only way to recover a lost PIN is to completely reload the onboarding firmware which will wipe out your configuration.

Have you stored your PIN in a safe place?

'; } - else if (security.type === 2) { // Password + else if (form.type === 2) { // Password if (sec.username.length === 0) { ui.errorMessage('No Username Provided').querySelector('.sub-message').innerHTML = 'You must provide a username for password security.'; return; @@ -1537,7 +1523,7 @@ class General { return; } } - if (security.type === 2 && !keepPassword) { // Password + if (form.type === 2 && !keepPassword) { // Password if (sec.password.length === 0) { ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.'; return; @@ -1547,11 +1533,11 @@ class General { return; } - if (security.repeatpassword.length === 0) { + if (form.repeatpassword.length === 0) { ui.errorMessage('Re-enter Password').querySelector('.sub-message').innerHTML = 'You must re-enter the password in the Re-enter Password field.'; return; } - if (sec.password !== security.repeatpassword) { + if (sec.password !== form.repeatpassword) { ui.errorMessage('Passwords do not Match').querySelector('.sub-message').innerHTML = 'Please re-enter the password exactly as you typed it in the Re-enter Password field.'; return; } @@ -1565,12 +1551,14 @@ class General { ui.serviceError(err); return; } - // The device closes every socket once a save leaves it Secured; - // this one comes back with the token in the reply. + // The device closes every socket when a save leaves it Secured or + // changes its mode; this one comes back with the token in the + // reply. None to None closes nothing. + let closed = sec.type !== 0 || security.type !== 0; security.type = sec.type; security.authenticated = sec.type !== 0; security.setToken(reply.apiKey); - startSocket(); + if (closed) startSocket(); }, true); }); prompt.querySelector('.sub-message').innerHTML = confirm; @@ -1644,19 +1632,6 @@ class Wifi { } } onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; } - static NETWORK_FIELDS = ['connMode', 'hasEthernet', 'ssid', 'hasPassphrase', 'roaming', 'hidden', 'boardType', 'phyType', 'CLKMode', - 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin', 'dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2']; - static ETHERNET_FIELDS = ['boardType', 'phyType', 'CLKMode', 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin']; - // The network section as the panel binds it: WiFi, Ethernet and the - // address each in an object of its own. - networkView(n) { - n = proto.schema.fill(Object.assign({}, n), Wifi.NETWORK_FIELDS); - let view = { connType: n.connMode, hasEthernet: n.hasEthernet, wifi: {}, ethernet: {}, ip: {} }; - for (let name of ['ssid', 'hasPassphrase', 'roaming', 'hidden']) view.wifi[name] = n[name]; - for (let name of Wifi.ETHERNET_FIELDS) view.ethernet[name] = n[name]; - for (let name of ['dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2']) view.ip[name] = n[name]; - return view; - } // Saves part of the network section; the device may restart for it. saveNetworkSection(fields, done) { sendOp('saveSettings', Object.assign({ section: 'network' }, fields), (err, reply) => { @@ -1672,7 +1647,7 @@ class Wifi { ui.serviceError(err); } else { - let settings = this.networkView(reply); + let settings = ProtoViews.network(proto.schema, reply); console.log(settings); let hasEthernet = settings.hasEthernet; this.hasEthernet = hasEthernet; @@ -1873,7 +1848,7 @@ class Wifi { sendNetworkSettings(obj) { let fields = { connMode: obj.connType, ssid: obj.wifi.ssid, passphrase: obj.wifi.passphrase, roaming: obj.wifi.roaming, hidden: obj.wifi.hidden }; // A device without Ethernet refuses its settings. - if (this.hasEthernet) for (let name of Wifi.ETHERNET_FIELDS) fields[name] = obj.ethernet[name]; + if (this.hasEthernet) for (let name of ProtoViews.ETHERNET) fields[name] = obj.ethernet[name]; this.saveNetworkSection(fields); } connectWiFi() { @@ -1969,19 +1944,13 @@ class Somfy { firmware.procFwStatus({ fwVersion: { name: header.fw } }); this.loadRadio(); } - static RADIO_FIELDS = ['bitLength', 'proto', 'enabled', 'TXPin', 'RXPin', 'SCKPin', 'MOSIPin', 'MISOPin', 'CSNPin', 'frequency', - 'rxBandwidth', 'deviation', 'txPower', 'noiseDetection', 'radioInit']; - // The radio section as the panel binds it, where the bit length is the - // radio's type. loadRadio() { sendOp('getSettings', { section: 'radio' }, (err, radio) => { if (err) { ui.serviceError(err); return; } - let config = proto.schema.fill(Object.assign({}, radio), Somfy.RADIO_FIELDS); - config.type = config.bitLength; - delete config.bitLength; + let config = ProtoViews.radio(proto.schema, radio); ui.toElement(document.getElementById('divTransceiverSettings'), { transceiver: { config: config } }); document.getElementById('divRadioError').style.display = config.radioInit ? 'none' : ''; }); @@ -2031,11 +2000,7 @@ class Somfy { if (valid) valid = fnValDup(trans.config, 'TXPin'); if (valid) valid = fnValDup(trans.config, 'RXPin'); if (valid) { - let fields = { section: 'radio', bitLength: trans.config.type }; - for (let name of Somfy.RADIO_FIELDS) { - if (name !== 'bitLength' && name !== 'radioInit') fields[name] = trans.config[name]; - } - sendOp('saveSettings', fields, (err) => { + sendOp('saveSettings', ProtoViews.radioSave(trans.config), (err) => { if (err) ui.serviceError(err); else { document.getElementById('btnSaveRadio').classList.remove('disabled'); @@ -2827,7 +2792,7 @@ class Somfy { }; // The frames keep coming while the edit runs: link it once. lnk.remove(); - sendEdit('linkRemote', obj, editedItem('shade', () => obj.shadeId), (err, shade) => { + sendEdit('linkRemote', obj, editTarget('shade', () => obj.shadeId), (err, shade) => { if (err) ui.serviceError(err); else if (shade) this.setLinkedRemotesList(shade); }); @@ -2970,7 +2935,7 @@ class Somfy { else { document.getElementById('btnSaveRoom').innerText = 'Save Room'; let room = proto.model.rooms.get(roomId); - if (!room) ui.errorMessage('The room is no longer there.'); + if (!room) gone('room'); else { document.getElementById('spanRoomId').innerText = roomId; ui.toElement(document.getElementById('somfyRoom'), room); @@ -3057,7 +3022,8 @@ class Somfy { } }; let shade = proto.model.shades.get(shadeId); - fnShow(shade ? null : { service: 'shade', desc: 'The shade is no longer there.' }, shade); + if (shade) fnShow(null, shade); + else gone('shade'); } } openEditGroup(groupId) { @@ -3085,8 +3051,8 @@ class Somfy { document.getElementById('btnSaveGroup').style.display = 'none'; document.getElementById('btnSaveGroup').innerText = 'Save Group'; document.getElementById('spanGroupId').innerText = groupId; - let group = groupView(proto.model.groups.get(groupId)); - if (!group) ui.errorMessage('The group is no longer there.'); + let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId)); + if (!group) gone('group'); else { ui.toElement(document.getElementById('somfyGroup'), group); this.showEditGroup(true); @@ -3163,7 +3129,7 @@ class Somfy { if (valid) { if (isNaN(roomId) || roomId === 0) { // We are adding. - sendEdit('addRoom', obj, editedItem('room', (r) => r.roomId), (err, room) => { + sendEdit('addRoom', obj, editTarget('room', (r) => r.roomId), (err, room) => { if (err) { ui.serviceError(err); console.log(err); @@ -3233,7 +3199,7 @@ class Somfy { if (valid) { if (isNaN(shadeId) || shadeId >= 255) { // We are adding. - sendEdit('addShade', obj, editedItem('shade', (r) => r.shadeId), (err, shade) => { + sendEdit('addShade', obj, editTarget('shade', (r) => r.shadeId), (err, shade) => { if (err) { ui.serviceError(err); console.log(err); @@ -3251,7 +3217,7 @@ class Somfy { } else { obj.shadeId = shadeId; - sendEdit('saveShade', obj, editedItem('shade', () => shadeId), (err, shade) => { + sendEdit('saveShade', obj, editTarget('shade', () => shadeId), (err, shade) => { if (err) { ui.serviceError(err); return; @@ -3286,7 +3252,7 @@ class Somfy { if (valid) { if (isNaN(groupId) || groupId >= 255) { // We are adding. - sendEdit('addGroup', obj, editedItem('group', (r) => r.groupId), (err, group) => { + sendEdit('addGroup', obj, editTarget('group', (r) => r.groupId), (err, group) => { if (err) ui.serviceError(err); else { console.log(group); @@ -3305,11 +3271,6 @@ class Somfy { } } } - // The lists as the model has them; the device's events redraw them too. - updateRoomsList() { render('rooms'); } - updateShadeList() { render('shades'); } - updateGroupList() { render('groups'); } - updateRepeatList() { render('repeaters'); } deleteRoom(roomId) { let valid = true; if (isNaN(roomId) || roomId >= 255 || roomId <= 0) { @@ -3318,7 +3279,7 @@ class Somfy { } if (valid) { let room = proto.model.rooms.get(roomId); - if (!room) ui.errorMessage('The room is no longer there.'); + if (!room) gone('room'); else { let prompt = ui.promptMessage(`Are you sure you want to delete this room?`, () => { ui.clearErrors(); @@ -3339,7 +3300,7 @@ class Somfy { } if (valid) { let shade = proto.model.shades.get(shadeId); - if (!shade) ui.errorMessage('The shade is no longer there.'); + if (!shade) gone('shade'); else if (shade.inGroup) ui.errorMessage(`You may not delete this shade because it is a member of a group.`); else { let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { @@ -3361,7 +3322,7 @@ class Somfy { } if (valid) { let group = proto.model.groups.get(groupId); - if (!group) ui.errorMessage('The group is no longer there.'); + if (!group) gone('group'); else if (group.linkedShades.length > 0) { ui.errorMessage('You may not delete this group until all shades have been removed from it.'); } @@ -3387,36 +3348,33 @@ class Somfy { } openSetRollingCode(shadeId) { let shade = proto.model.shades.get(shadeId); - { - if (!shade) { - ui.errorMessage('The shade is no longer there.'); - } - else { - console.log(shade); - let div = document.createElement('div'); - div.setAttribute('id', 'divRollingCode'); - let html = `
`; - html += '
BEWARE ... WARNING ... DANGER
'; - html += '
'; - html += '

If this shade is already paired with a motor then changing the rolling code WILL cause it to stop working. Rolling codes are tied to the remote address and the Somfy motor expects these to be sequential.

'; - html += '

If you hesitated just a little bit do not press the red button. Green represents safety so press it, wipe the sweat from your brow, and go through the normal pairing process.'; - html += '

'; - html += ``; - html += ''; - html += '
'; - html += `
`; - html += ``; - html += ``; - html += `
`; - div.innerHTML = html; - document.getElementById('somfyShade').appendChild(div); - } + if (!shade) { + gone('shade'); + return; } + console.log(shade); + let div = document.createElement('div'); + div.setAttribute('id', 'divRollingCode'); + let html = `
`; + html += '
BEWARE ... WARNING ... DANGER
'; + html += '
'; + html += '

If this shade is already paired with a motor then changing the rolling code WILL cause it to stop working. Rolling codes are tied to the remote address and the Somfy motor expects these to be sequential.

'; + html += '

If you hesitated just a little bit do not press the red button. Green represents safety so press it, wipe the sweat from your brow, and go through the normal pairing process.'; + html += '

'; + html += ``; + html += ''; + html += '
'; + html += `
`; + html += ``; + html += ``; + html += `
`; + div.innerHTML = html; + document.getElementById('somfyShade').appendChild(div); } setPaired(shadeId, paired) { let obj = { shadeId: shadeId, paired: paired || false }; let div = document.getElementById('divPairing'); - sendEdit('setPaired', obj, editedItem('shade', () => shadeId), (err, shade) => { + sendEdit('setPaired', obj, editTarget('shade', () => shadeId), (err, shade) => { if (err) { console.log(err); ui.errorMessage(err.desc); @@ -3561,21 +3519,25 @@ class Somfy { // cb(err, reply): the reply carries no state; the shade's state events // follow as it moves. sendCommand(shadeId, command, repeat, cb) { - let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; - if (typeof shadeId === 'object') cb = command; - let fields = { shadeId: o.shadeId, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }; - if (isNaN(parseInt(o.command, 10))) fields.command = o.command; - else fields.target = parseInt(o.command, 10); - sendOp('shadeCommand', fields, cb); + let [cmd, done] = this.commandArgs(shadeId, command, repeat, cb); + let fields = { shadeId: cmd.shadeId, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize }; + if (isNaN(parseInt(cmd.command, 10))) fields.command = cmd.command; + else fields.target = parseInt(cmd.command, 10); + sendOp('shadeCommand', fields, done); } // The last frame again when it was this command, otherwise the command. sendCommandRepeat(shadeId, command, repeat, cb) { - let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; - if (typeof shadeId === 'object') cb = command; - sendOp('repeatCommand', { shadeId: o.shadeId, command: o.command, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }, cb); + let [cmd, done] = this.commandArgs(shadeId, command, repeat, cb); + sendOp('repeatCommand', { shadeId: cmd.shadeId, command: cmd.command, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize }, done); + } + // (shadeId, command, repeat, cb), or (object, cb) as the virtual remote + // calls it: the command and the callback. + commandArgs(shadeId, command, repeat, cb) { + if (typeof shadeId === 'object') return [shadeId, command]; + return [{ shadeId: shadeId, command: command, repeat: repeat }, cb]; } sendGroupRepeat(groupId, command, repeat, cb) { - sendOp('repeatCommand', { groupId: groupId, command: command, repeat: typeof repeat === 'number' ? repeat : undefined }, cb); + sendOp('repeatCommand', { groupId: groupId, command: command, repeat: repeatOf(repeat) }, cb); } sendVRCommand(el) { let pnl = document.getElementById('divVirtualRemote'); @@ -3623,14 +3585,14 @@ class Somfy { } // obj: a shade's or a group's id, and sunny, windy or both. sendSetSensor(obj, cb) { - let fields = { sunny: obj.sunny, windy: obj.windy, repeat: typeof obj.repeat === 'number' ? obj.repeat : undefined }; + let fields = { sunny: obj.sunny, windy: obj.windy, repeat: repeatOf(obj.repeat) }; if (typeof obj.groupId === 'number' && !isNaN(obj.groupId)) fields.groupId = obj.groupId; else fields.shadeId = obj.shadeId; sendOp('setSensor', fields, cb); } sendGroupCommand(groupId, command, repeat, cb) { console.log(`Sending Group command ${groupId}-${command}`); - sendOp('groupCommand', { groupId: groupId, command: command, repeat: typeof repeat === 'number' ? repeat : undefined }, cb); + sendOp('groupCommand', { groupId: groupId, command: command, repeat: repeatOf(repeat) }, cb); } sendTiltCommand(shadeId, command, cb) { console.log(`Sending Tilt command ${shadeId}-${command}`); @@ -3750,7 +3712,7 @@ class Somfy { mouseDown = false; let obj = ui.fromElement(div); let prompt = ui.promptMessage('Confirm Motor Response', () => { - sendEdit('linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, editedItem('group', () => groupId), (err, group) => { + sendEdit('linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, editTarget('group', () => groupId), (err, group) => { if (err) ui.serviceError(err); else if (group) somfy.setLinkedShadesList(group); }); @@ -3845,7 +3807,7 @@ class Somfy { if (err) ui.serviceError(err); else { let prompt = ui.promptMessage('Confirm Motor Response', () => { - sendEdit('unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, editedItem('group', () => groupId), (err, group) => { + sendEdit('unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, editTarget('group', () => groupId), (err, group) => { if (err) ui.serviceError(err); else if (group) somfy.setLinkedShadesList(group); }); @@ -3856,25 +3818,23 @@ class Somfy { } }); }); - { - let group = groupView(proto.model.groups.get(groupId)); - if (!group) { - div.remove(); - ui.errorMessage('The group is no longer there.'); + let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId)); + if (!group) { + div.remove(); + gone('group'); + } + else { + let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }); + if (typeof shade !== 'undefined') { + // Add in all the available shades. + let grpName = div.querySelector('#divGroupName'); + if (grpName) grpName.innerHTML = group.name; + let divWizShadeName = div.querySelector('#divWizShadeName'); + if (divWizShadeName) divWizShadeName.innerHTML = shade.name; } else { - let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }); - if (typeof shade !== 'undefined') { - // Add in all the available shades. - let grpName = div.querySelector('#divGroupName'); - if (grpName) grpName.innerHTML = group.name; - let divWizShadeName = div.querySelector('#divWizShadeName'); - if (divWizShadeName) divWizShadeName.innerHTML = shade.name; - } - else { - div.remove(); - ui.errorMessage('The specified shade could not be found in this group.'); - } + div.remove(); + ui.errorMessage('The specified shade could not be found in this group.'); } } return div; @@ -3895,7 +3855,7 @@ class Somfy { shadeId: shadeId, remoteAddress: remoteAddress }; - sendEdit('unlinkRemote', obj, editedItem('shade', () => shadeId), (err, shade) => { + sendEdit('unlinkRemote', obj, editTarget('shade', () => shadeId), (err, shade) => { prompt.remove(); if (err) ui.serviceError(err); else if (shade) this.setLinkedRemotesList(shade); @@ -4004,13 +3964,12 @@ var somfy = new Somfy(); class MQTT { initialized = false; init() { this.initialized = true; } - static FIELDS = ['enabled', 'pubDisco', 'protocol', 'hostname', 'port', 'username', 'hasPassword', 'rootTopic', 'discoTopic']; loadMQTT() { sendOp('getSettings', { section: 'mqtt' }, (err, reply) => { if (err) console.log(err); else { - let settings = proto.schema.fill(Object.assign({}, reply), MQTT.FIELDS); + let settings = ProtoViews.settings(proto.schema, 'mqtt', reply); ui.toElement(document.getElementById('divMQTT'), { mqtt: settings }); // The device never returns the stored password; leave the field blank to keep it. this.savedUsername = settings.username; @@ -4053,7 +4012,7 @@ class MQTT { } } let fields = { section: 'mqtt' }; - for (let name of MQTT.FIELDS) if (name !== 'hasPassword') fields[name] = obj.mqtt[name]; + for (let name of ProtoViews.SETTINGS.mqtt) if (name !== 'hasPassword') fields[name] = obj.mqtt[name]; fields.password = obj.mqtt.password; sendOp('saveSettings', fields, (err) => { if (err) ui.serviceError(err); @@ -4098,7 +4057,11 @@ class Firmware { xhr.onload = (evt) => { if (typeof overlay !== 'undefined') overlay.remove(); let status = xhr.status; - if (status !== 200) { + if (status === 401) { + security.tokenRefused(); + reject({ htmlError: status, service: 'GET /backup', desc: 'Log in again, then back up' }); + } + else if (status !== 200) { let err = xhr.response || {}; err.htmlError = status; err.service = `GET /backup`; @@ -4331,6 +4294,15 @@ class Firmware { xhr.onload = function () { console.log('File upload load called'); btnCancel.innerText = 'Close'; + if (xhr.status === 401) { + el.remove(); + security.tokenRefused(); + return; + } + if (service !== '/restore' && (xhr.status < 200 || xhr.status >= 300)) { + ui.errorMessage(el, `The upload failed (${xhr.status}).`); + return; + } switch (service) { case '/restore': if (xhr.status < 200 || xhr.status >= 300) { diff --git a/data-src/js/views.js b/data-src/js/views.js new file mode 100644 index 0000000..e1719b6 --- /dev/null +++ b/data-src/js/views.js @@ -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 }; diff --git a/minify.py b/minify.py index b92de59..6996529 100644 --- a/minify.py +++ b/minify.py @@ -58,7 +58,7 @@ PNG_EXTENSIONS = {".png"} # comes before index.js, which uses it. A source listed here isn't copied on # its own. BUNDLES = { - "index.js": ["js/schema.js", "js/model.js", "js/client.js", "index.js"], + "index.js": ["js/schema.js", "js/model.js", "js/client.js", "js/views.js", "index.js"], "app.css": ["main.css", "widgets.css", "icons.css"], } diff --git a/test/js/client.test.js b/test/js/client.test.js index 64e0879..571ec5e 100644 --- a/test/js/client.test.js +++ b/test/js/client.test.js @@ -315,6 +315,21 @@ test('waitFor gives null when no event comes, and stops listening', async () => assert.equal((c.listeners.room || []).length, 0); }); +test('off stops a listener, and one that removes itself mid-event leaves the others called', () => { + const { c, ws } = connected(); + const seen = []; + const once = (d) => { seen.push('once'); c.off('memory', once); }; + const always = () => seen.push('always'); + const dropped = () => seen.push('dropped'); + c.on('memory', once); + c.on('memory', always); + c.on('memory', dropped); + c.off('memory', dropped); + ws.deliver({ v: 2, t: T.memory, d: { fr: 1 } }); + ws.deliver({ v: 2, t: T.memory, d: { fr: 2 } }); + assert.deepEqual(seen, ['once', 'always', 'always']); +}); + test('subscribe sends the feed number', () => { const { c, ws } = connected(); c.subscribe('frames'); diff --git a/test/js/views.test.js b/test/js/views.test.js new file mode 100644 index 0000000..30e1775 --- /dev/null +++ b/test/js/views.test.js @@ -0,0 +1,82 @@ +// What the web UI's panels bind (data-src/js/views.js), from protocol 2's +// shapes. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { ProtoViews } = require('../../data-src/js/views.js'); +const { ProtoModel } = require('../../data-src/js/model.js'); +const { recordedSchema, deviceSections } = require('./helpers.js'); + +const schema = recordedSchema(); +function synced() { + const model = new ProtoModel(schema); + model.beginSync(); + for (const d of deviceSections(schema)) model.applySync(d); + model.endSync(); + return model; +} + +test('a settings section has every field, defaults where the device left them out', () => { + // ssdpBroadcast off and no NTP server are at their defaults, so not sent. + assert.deepEqual(ProtoViews.settings(schema, 'general', { hostname: 'Somfy' }), + { hostname: 'Somfy', ssdpBroadcast: false, ntpServer: '', posixZone: '' }); + assert.deepEqual(ProtoViews.settings(schema, 'security', {}), { securityType: 0, username: '', hasPassword: false, hasPin: false }); +}); + +test('the settings sections are what the device sends', () => { + for (const section of Object.keys(ProtoViews.SETTINGS)) + for (const name of ProtoViews.SETTINGS[section]) assert.ok(schema.keys[name], `${section}.${name}`); +}); + +test('a group shows its shades whole', () => { + const m = synced(); + const group = m.groupList()[0]; + const view = ProtoViews.group(m, group); + assert.equal(view.linkedShades.length, group.linkedShades.length); + assert.equal(view.linkedShades[0].name, m.shades.get(group.linkedShades[0]).name); + // The model's item is left as it is. + assert.equal(typeof group.linkedShades[0], 'number'); + // A shade the model hasn't got yet still shows. + assert.equal(ProtoViews.group(m, Object.assign({}, group, { linkedShades: [250] })).linkedShades[0].shadeId, 250); + assert.equal(ProtoViews.group(m, undefined), undefined); +}); + +test('a frame and a scan under the names the UI reads', () => { + const f = ProtoViews.frame(schema, { remoteAddress: 5, lastRollingCode: 9, command: 'Up', rssi: -60, valid: true, pulses: [1, 2] }); + assert.equal(f.address, 5); + assert.equal(f.rcode, 9); + assert.equal(f.encKey, 0); + assert.deepEqual(f.pulses, [1, 2]); + assert.equal(f.stepSize, undefined); + const s = ProtoViews.scan(schema, { scanning: true, testFreq: 433.1, testRssi: -80 }); + assert.deepEqual(s, { scanning: true, testFreq: 433.1, testRSSI: -80, frequency: 0, RSSI: 0 }); +}); + +test('the network section as the panel binds it', () => { + const v = ProtoViews.network(schema, { connMode: 1, ssid: 'home', hasPassphrase: true, dhcp: true, PWRPin: -1 }); + assert.equal(v.connType, 1); + assert.equal(v.hasEthernet, false); + assert.deepEqual(v.wifi, { ssid: 'home', hasPassphrase: true, roaming: false, hidden: false }); + assert.equal(v.ethernet.PWRPin, -1); + assert.deepEqual(v.ip, { dhcp: true, ip: '', gateway: '', subnet: '', dns1: '', dns2: '' }); +}); + +test('the radio section as the panel binds it, and back', () => { + const config = ProtoViews.radio(schema, { bitLength: 80, frequency: 433.42, enabled: true, radioInit: true }); + assert.equal(config.type, 80); + assert.equal(config.bitLength, undefined); + assert.equal(config.TXPin, 0); + const save = ProtoViews.radioSave(config); + assert.equal(save.section, 'radio'); + assert.equal(save.bitLength, 80); + assert.equal(save.frequency, 433.42); + // Only shown, never saved. + assert.equal('radioInit' in save, false); + assert.equal('type' in save, false); +}); + +test('an op refusal for the error box, with a shade validation code', () => { + assert.deepEqual(ProtoViews.opError('saveShade', { code: 'badRequest', desc: 'Pin in use', data: { validation: -10 } }), + { htmlError: 'badRequest', service: 'saveShade', desc: 'Pin in use', code: -10 }); + assert.deepEqual(ProtoViews.opError('reboot', { code: 'closed', desc: 'The connection closed' }), + { htmlError: 'closed', service: 'reboot', desc: 'The connection closed' }); +});