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

View file

@ -356,82 +356,70 @@ function putJSONSync(url, data, cb) {
// can't spare (docs/adr/0005-one-websocket-protocol.md). // can't spare (docs/adr/0005-one-websocket-protocol.md).
var proto = null; var proto = null;
var protoSynced = false; var protoSynced = false;
var everSynced = false;
var connectFailed = 0; var connectFailed = 0;
// An op's refusal as ui.serviceError shows it. A shade whose settings don't // An op as a promise of its reply, refused with what ui.serviceError shows.
// add up carries the reason's number, which the errors table describes. // retryBusy sends it again while the device says busy: it saves settings and
function opError(name, err) { // applies edits one at a time, whoever sent them. A busy command is the
let e = { htmlError: err.code, service: name, desc: err.desc }; // radio's queue or a moving shade, which the user decides about.
if (err.data && typeof err.data.validation === 'number' && err.data.validation !== 0) e.code = err.data.validation; function runOp(name, fields, retryBusy) {
return e; 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' }); } // cb(err, reply) as the HTTP helpers did. With wait the spinner shows until
// Sends an op; cb(err, reply) as the HTTP helpers did. With wait the spinner // the reply.
// 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.
function sendOp(name, fields, cb, wait) { function sendOp(name, fields, cb, wait) {
let overlay = wait ? ui.waitMessage(document.getElementById('divContainer')) : null; let overlay = wait ? ui.waitMessage(document.getElementById('divContainer')) : null;
let attempts = 0;
let done = (err, d) => { let done = (err, d) => {
if (overlay) overlay.remove(); if (overlay) overlay.remove();
if (typeof cb === 'function') cb(err, d); if (typeof cb === 'function') cb(err, d);
}; };
if (!proto) { runOp(name, fields, name === 'saveSettings').then((d) => done(null, d), (err) => done(err, null));
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();
} }
// What an edit's cb gets: the room, shade or group as the edit's event left // Where an edit's room, shade or group is once its event is in: the event to
// it. id picks the item's id from the reply (an add's new id) or the fields. // wait for, and the item's id from the reply (an add's new id) or the fields.
function editedItem(kind, id) { function editTarget(kind, idOf) {
let key = `${kind}Id`; let key = `${kind}Id`;
let map = { room: 'rooms', shade: 'shades', group: 'groups' }[kind]; 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, with the spinner up until it is done. Its events follow the
// An edit. The device runs one at a time, so one that finds another running // reply: with a target, cb gets the item once its event is in; without, the
// tries again. Its events follow the reply: with after, cb gets the item once // lists redraw from the events anyway.
// its event is in; without, the lists redraw from the events anyway. function sendEdit(name, fields, target, cb) {
function sendEdit(name, fields, after, cb) {
if (!proto) {
cb(notConnected(name), null);
return;
}
let overlay = ui.waitMessage(document.getElementById('divContainer')); let overlay = ui.waitMessage(document.getElementById('divContainer'));
let attempts = 0;
let finish = (err, item) => { let finish = (err, item) => {
overlay.remove(); overlay.remove();
if (typeof cb === 'function') cb(err, item); if (typeof cb === 'function') cb(err, item);
}; };
let send = () => { runOp(name, fields, true).then((reply) => {
proto.op(name, fields).then((reply) => { if (!target) {
if (!after) {
finish(null, reply); finish(null, reply);
return; return;
} }
let id = after.id(reply); let id = target.idOf(reply);
// Without the event the reply is all there is, an add's new id in it. // 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)); proto.waitFor(target.event, (d) => target.match(d, id), 3000).then(() => finish(null, target.read(id) || reply));
}, (err) => { }, (err) => finish(err, null));
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;
} }
// 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. // 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 renderPending = {};
var renderTimer = null; var renderTimer = null;
function render(part) { function render(part) {
@ -444,7 +432,7 @@ function render(part) {
// Shades and groups show their room's name. // Shades and groups show their room's name.
if (p.rooms) somfy.setRoomsList(proto.model.roomList()); if (p.rooms) somfy.setRoomsList(proto.model.roomList());
if (p.rooms || p.shades) somfy.setShadesList(proto.model.shadeList()); 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); if (p.repeaters) somfy.setRepeaterList(proto.model.repeaters);
}, 20); }, 20);
} }
@ -466,19 +454,11 @@ function showStatus(status) {
else wifi.procWifiStrength({ ssid: status.ssid, channel: status.channel, strength: status.strength }); 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 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) { function subscribeConfigFeeds(on) {
if (!proto || !protoSynced) return; if (!proto) return;
for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch((err) => console.log(err)); for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch(() => {});
}
// 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 };
} }
function showVersionMismatch(v) { function showVersionMismatch(v) {
if (document.getElementById('divVersionMismatch')) return; if (document.getElementById('divVersionMismatch')) return;
@ -496,8 +476,10 @@ function startSocket() {
} }
let host = window.location.protocol === 'file:' ? hst : window.location.host; let host = window.location.protocol === 'file:' ? hst : window.location.host;
proto = new ProtoClient({ host: host, token: security.apiKey }); proto = new ProtoClient({ host: host, token: security.apiKey });
// Each sync, the first and after a reconnect: what changed meanwhile shows.
proto.on('synced', () => { proto.on('synced', () => {
let first = !protoSynced; let first = !everSynced;
everSynced = true;
protoSynced = true; protoSynced = true;
connectFailed = 0; connectFailed = 0;
clearSocketWait(); clearSocketWait();
@ -505,14 +487,13 @@ function startSocket() {
general.reload(); general.reload();
return; return;
} }
if (!first) return; if (first) ui.clearErrors();
ui.clearErrors();
general.loadGeneral(); general.loadGeneral();
wifi.loadNetwork(); wifi.loadNetwork();
somfy.loadSomfy(); somfy.loadSomfy();
mqtt.loadMQTT(); mqtt.loadMQTT();
showStatus(proto.model.status); showStatus(proto.model.status);
if (ui.isConfigOpen()) subscribeConfigFeeds(true); if (first && ui.isConfigOpen()) subscribeConfigFeeds(true);
}); });
let lost = (refused) => { let lost = (refused) => {
protoSynced = false; protoSynced = false;
@ -543,8 +524,8 @@ function startSocket() {
proto.on('memory', () => showStatus(proto.model.status)); proto.on('memory', () => showStatus(proto.model.status));
proto.on('wifi', () => showStatus(proto.model.status)); proto.on('wifi', () => showStatus(proto.model.status));
proto.on('ethernet', () => showStatus(proto.model.status)); proto.on('ethernet', () => showStatus(proto.model.status));
proto.on('frame', (d) => somfy.procRemoteFrame(frameView(d))); proto.on('frame', (d) => somfy.procRemoteFrame(ProtoViews.frame(proto.schema, d)));
proto.on('frequencyScan', (d) => somfy.procFrequencyScan(scanView(d))); proto.on('frequencyScan', (d) => somfy.procFrequencyScan(ProtoViews.scan(proto.schema, d)));
proto.on('shadeCommanded', (d) => console.log(d)); proto.on('shadeCommanded', (d) => console.log(d));
// Another client saved settings: show them as they are now. // Another client saved settings: show them as they are now.
proto.on('settingsChanged', (d) => { proto.on('settingsChanged', (d) => {
@ -1128,14 +1109,19 @@ class Security {
getJSON('/loginContext', (err, ctx) => { getJSON('/loginContext', (err, ctx) => {
this.checkingRefusal = false; this.checkingRefusal = false;
if (err || ctx.type === 0) return; if (err || ctx.type === 0) return;
proto.close(); this.type = ctx.type;
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(); clearSocketWait();
this.setToken(''); this.setToken('');
this.authenticated = false; this.authenticated = false;
this.type = ctx.type;
this.authUser(); this.authUser();
document.getElementById('btnCancelLogin').style.display = 'none'; document.getElementById('btnCancelLogin').style.display = 'none';
});
} }
async loadContext() { async loadContext() {
let pnl = document.getElementById('divUnauthenticated'); let pnl = document.getElementById('divUnauthenticated');
@ -1396,7 +1382,7 @@ class General {
general.setAppVersion(); general.setAppVersion();
sendOp('getSettings', { section: 'general' }, (err, settings) => { sendOp('getSettings', { section: 'general' }, (err, settings) => {
if (err) console.log(err); if (err) console.log(err);
else ui.toElement(pnl, { general: settings }); else ui.toElement(pnl, { general: ProtoViews.settings(proto.schema, 'general', settings) });
}); });
} }
loadSecurity() { loadSecurity() {
@ -1405,7 +1391,7 @@ class General {
sendOp('getSettings', { section: 'security' }, (err, security) => { sendOp('getSettings', { section: 'security' }, (err, security) => {
overlay.remove(); overlay.remove();
if (err) ui.serviceError(err); if (err) ui.serviceError(err);
else general.setSecurityConfig(security); else general.setSecurityConfig(ProtoViews.settings(proto.schema, 'security', security));
}); });
} }
async setAppVersion() { 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.'; ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The host name must only include numbers, letters, or dash.';
valid = false; valid = false;
} }
if (valid && obj.hostname.length > 32) { if (valid && obj.hostname.length > 31) {
ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 32 characters.'; ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 31 characters.';
valid = false; valid = false;
} }
if (valid && typeof obj.ntpServer === 'string' && obj.ntpServer.length > 64) { if (valid && typeof obj.ntpServer === 'string' && obj.ntpServer.length > 64) {
@ -1502,24 +1488,24 @@ class General {
} }
} }
saveSecurity() { saveSecurity() {
let security = ui.fromElement(document.getElementById('divSecurityOptions')).security; let form = ui.fromElement(document.getElementById('divSecurityOptions')).security;
console.log(security); console.log(form);
let sec = { type: security.type, username: security.username, password: security.password, pin: '' }; let sec = { type: form.type, username: form.username, password: form.password, pin: '' };
// Pin entry. // Pin entry.
for (let i = 0; i < 4; i++) { 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. // Blank secret fields keep the stored value while the type (and username) stay the same.
let saved = this.savedSecurity || {}; let saved = this.savedSecurity || {};
let keepPin = security.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; let keepPin = form.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1;
let keepPassword = security.type === 2 && sec.password === '' && security.repeatpassword === '' && let keepPassword = form.type === 2 && sec.password === '' && form.repeatpassword === '' &&
saved.hasPassword && saved.type === 2 && sec.username === saved.username; saved.hasPassword && saved.type === 2 && sec.username === saved.username;
// Only send the secret for the selected type so the other one stays stored. // Only send the secret for the selected type so the other one stays stored.
if (security.type !== 1 || keepPin) delete sec.pin; if (form.type !== 1 || keepPin) delete sec.pin;
if (security.type !== 2 || keepPassword) delete sec.password; if (form.type !== 2 || keepPassword) delete sec.password;
let confirm = ''; let confirm = '';
console.log(sec); console.log(sec);
if (security.type === 1 && !keepPin) { // Pin Entry if (form.type === 1 && !keepPin) { // Pin Entry
// Make sure our pin is 4 digits. // Make sure our pin is 4 digits.
if (sec.pin.length !== 4) { 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.'; 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 = '<p>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.</p><p>Have you stored your PIN in a safe place?</p>'; confirm = '<p>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.</p><p>Have you stored your PIN in a safe place?</p>';
} }
else if (security.type === 2) { // Password else if (form.type === 2) { // Password
if (sec.username.length === 0) { if (sec.username.length === 0) {
ui.errorMessage('No Username Provided').querySelector('.sub-message').innerHTML = 'You must provide a username for password security.'; ui.errorMessage('No Username Provided').querySelector('.sub-message').innerHTML = 'You must provide a username for password security.';
return; return;
@ -1537,7 +1523,7 @@ class General {
return; return;
} }
} }
if (security.type === 2 && !keepPassword) { // Password if (form.type === 2 && !keepPassword) { // Password
if (sec.password.length === 0) { if (sec.password.length === 0) {
ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.'; ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.';
return; return;
@ -1547,11 +1533,11 @@ class General {
return; 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.'; ui.errorMessage('Re-enter Password').querySelector('.sub-message').innerHTML = 'You must re-enter the password in the Re-enter Password field.';
return; 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.'; 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; return;
} }
@ -1565,12 +1551,14 @@ class General {
ui.serviceError(err); ui.serviceError(err);
return; return;
} }
// The device closes every socket once a save leaves it Secured; // The device closes every socket when a save leaves it Secured or
// this one comes back with the token in the reply. // 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.type = sec.type;
security.authenticated = sec.type !== 0; security.authenticated = sec.type !== 0;
security.setToken(reply.apiKey); security.setToken(reply.apiKey);
startSocket(); if (closed) startSocket();
}, true); }, true);
}); });
prompt.querySelector('.sub-message').innerHTML = confirm; prompt.querySelector('.sub-message').innerHTML = confirm;
@ -1644,19 +1632,6 @@ class Wifi {
} }
} }
onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; } 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. // Saves part of the network section; the device may restart for it.
saveNetworkSection(fields, done) { saveNetworkSection(fields, done) {
sendOp('saveSettings', Object.assign({ section: 'network' }, fields), (err, reply) => { sendOp('saveSettings', Object.assign({ section: 'network' }, fields), (err, reply) => {
@ -1672,7 +1647,7 @@ class Wifi {
ui.serviceError(err); ui.serviceError(err);
} }
else { else {
let settings = this.networkView(reply); let settings = ProtoViews.network(proto.schema, reply);
console.log(settings); console.log(settings);
let hasEthernet = settings.hasEthernet; let hasEthernet = settings.hasEthernet;
this.hasEthernet = hasEthernet; this.hasEthernet = hasEthernet;
@ -1873,7 +1848,7 @@ class Wifi {
sendNetworkSettings(obj) { sendNetworkSettings(obj) {
let fields = { connMode: obj.connType, ssid: obj.wifi.ssid, passphrase: obj.wifi.passphrase, roaming: obj.wifi.roaming, hidden: obj.wifi.hidden }; 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. // 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); this.saveNetworkSection(fields);
} }
connectWiFi() { connectWiFi() {
@ -1969,19 +1944,13 @@ class Somfy {
firmware.procFwStatus({ fwVersion: { name: header.fw } }); firmware.procFwStatus({ fwVersion: { name: header.fw } });
this.loadRadio(); 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() { loadRadio() {
sendOp('getSettings', { section: 'radio' }, (err, radio) => { sendOp('getSettings', { section: 'radio' }, (err, radio) => {
if (err) { if (err) {
ui.serviceError(err); ui.serviceError(err);
return; return;
} }
let config = proto.schema.fill(Object.assign({}, radio), Somfy.RADIO_FIELDS); let config = ProtoViews.radio(proto.schema, radio);
config.type = config.bitLength;
delete config.bitLength;
ui.toElement(document.getElementById('divTransceiverSettings'), { transceiver: { config: config } }); ui.toElement(document.getElementById('divTransceiverSettings'), { transceiver: { config: config } });
document.getElementById('divRadioError').style.display = config.radioInit ? 'none' : ''; 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, 'TXPin');
if (valid) valid = fnValDup(trans.config, 'RXPin'); if (valid) valid = fnValDup(trans.config, 'RXPin');
if (valid) { if (valid) {
let fields = { section: 'radio', bitLength: trans.config.type }; sendOp('saveSettings', ProtoViews.radioSave(trans.config), (err) => {
for (let name of Somfy.RADIO_FIELDS) {
if (name !== 'bitLength' && name !== 'radioInit') fields[name] = trans.config[name];
}
sendOp('saveSettings', fields, (err) => {
if (err) ui.serviceError(err); if (err) ui.serviceError(err);
else { else {
document.getElementById('btnSaveRadio').classList.remove('disabled'); document.getElementById('btnSaveRadio').classList.remove('disabled');
@ -2827,7 +2792,7 @@ class Somfy {
}; };
// The frames keep coming while the edit runs: link it once. // The frames keep coming while the edit runs: link it once.
lnk.remove(); 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); if (err) ui.serviceError(err);
else if (shade) this.setLinkedRemotesList(shade); else if (shade) this.setLinkedRemotesList(shade);
}); });
@ -2970,7 +2935,7 @@ class Somfy {
else { else {
document.getElementById('btnSaveRoom').innerText = 'Save Room'; document.getElementById('btnSaveRoom').innerText = 'Save Room';
let room = proto.model.rooms.get(roomId); let room = proto.model.rooms.get(roomId);
if (!room) ui.errorMessage('The room is no longer there.'); if (!room) gone('room');
else { else {
document.getElementById('spanRoomId').innerText = roomId; document.getElementById('spanRoomId').innerText = roomId;
ui.toElement(document.getElementById('somfyRoom'), room); ui.toElement(document.getElementById('somfyRoom'), room);
@ -3057,7 +3022,8 @@ class Somfy {
} }
}; };
let shade = proto.model.shades.get(shadeId); 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) { openEditGroup(groupId) {
@ -3085,8 +3051,8 @@ class Somfy {
document.getElementById('btnSaveGroup').style.display = 'none'; document.getElementById('btnSaveGroup').style.display = 'none';
document.getElementById('btnSaveGroup').innerText = 'Save Group'; document.getElementById('btnSaveGroup').innerText = 'Save Group';
document.getElementById('spanGroupId').innerText = groupId; document.getElementById('spanGroupId').innerText = groupId;
let group = groupView(proto.model.groups.get(groupId)); let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId));
if (!group) ui.errorMessage('The group is no longer there.'); if (!group) gone('group');
else { else {
ui.toElement(document.getElementById('somfyGroup'), group); ui.toElement(document.getElementById('somfyGroup'), group);
this.showEditGroup(true); this.showEditGroup(true);
@ -3163,7 +3129,7 @@ class Somfy {
if (valid) { if (valid) {
if (isNaN(roomId) || roomId === 0) { if (isNaN(roomId) || roomId === 0) {
// We are adding. // 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) { if (err) {
ui.serviceError(err); ui.serviceError(err);
console.log(err); console.log(err);
@ -3233,7 +3199,7 @@ class Somfy {
if (valid) { if (valid) {
if (isNaN(shadeId) || shadeId >= 255) { if (isNaN(shadeId) || shadeId >= 255) {
// We are adding. // 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) { if (err) {
ui.serviceError(err); ui.serviceError(err);
console.log(err); console.log(err);
@ -3251,7 +3217,7 @@ class Somfy {
} }
else { else {
obj.shadeId = shadeId; obj.shadeId = shadeId;
sendEdit('saveShade', obj, editedItem('shade', () => shadeId), (err, shade) => { sendEdit('saveShade', obj, editTarget('shade', () => shadeId), (err, shade) => {
if (err) { if (err) {
ui.serviceError(err); ui.serviceError(err);
return; return;
@ -3286,7 +3252,7 @@ class Somfy {
if (valid) { if (valid) {
if (isNaN(groupId) || groupId >= 255) { if (isNaN(groupId) || groupId >= 255) {
// We are adding. // 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); if (err) ui.serviceError(err);
else { else {
console.log(group); 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) { deleteRoom(roomId) {
let valid = true; let valid = true;
if (isNaN(roomId) || roomId >= 255 || roomId <= 0) { if (isNaN(roomId) || roomId >= 255 || roomId <= 0) {
@ -3318,7 +3279,7 @@ class Somfy {
} }
if (valid) { if (valid) {
let room = proto.model.rooms.get(roomId); let room = proto.model.rooms.get(roomId);
if (!room) ui.errorMessage('The room is no longer there.'); if (!room) gone('room');
else { else {
let prompt = ui.promptMessage(`Are you sure you want to delete this room?`, () => { let prompt = ui.promptMessage(`Are you sure you want to delete this room?`, () => {
ui.clearErrors(); ui.clearErrors();
@ -3339,7 +3300,7 @@ class Somfy {
} }
if (valid) { if (valid) {
let shade = proto.model.shades.get(shadeId); 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 if (shade.inGroup) ui.errorMessage(`You may not delete this shade because it is a member of a group.`);
else { else {
let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => {
@ -3361,7 +3322,7 @@ class Somfy {
} }
if (valid) { if (valid) {
let group = proto.model.groups.get(groupId); 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) { else if (group.linkedShades.length > 0) {
ui.errorMessage('You may not delete this group until all shades have been removed from it.'); ui.errorMessage('You may not delete this group until all shades have been removed from it.');
} }
@ -3387,11 +3348,10 @@ class Somfy {
} }
openSetRollingCode(shadeId) { openSetRollingCode(shadeId) {
let shade = proto.model.shades.get(shadeId); let shade = proto.model.shades.get(shadeId);
{
if (!shade) { if (!shade) {
ui.errorMessage('The shade is no longer there.'); gone('shade');
return;
} }
else {
console.log(shade); console.log(shade);
let div = document.createElement('div'); let div = document.createElement('div');
div.setAttribute('id', 'divRollingCode'); div.setAttribute('id', 'divRollingCode');
@ -3411,12 +3371,10 @@ class Somfy {
div.innerHTML = html; div.innerHTML = html;
document.getElementById('somfyShade').appendChild(div); document.getElementById('somfyShade').appendChild(div);
} }
}
}
setPaired(shadeId, paired) { setPaired(shadeId, paired) {
let obj = { shadeId: shadeId, paired: paired || false }; let obj = { shadeId: shadeId, paired: paired || false };
let div = document.getElementById('divPairing'); let div = document.getElementById('divPairing');
sendEdit('setPaired', obj, editedItem('shade', () => shadeId), (err, shade) => { sendEdit('setPaired', obj, editTarget('shade', () => shadeId), (err, shade) => {
if (err) { if (err) {
console.log(err); console.log(err);
ui.errorMessage(err.desc); ui.errorMessage(err.desc);
@ -3561,21 +3519,25 @@ class Somfy {
// cb(err, reply): the reply carries no state; the shade's state events // cb(err, reply): the reply carries no state; the shade's state events
// follow as it moves. // follow as it moves.
sendCommand(shadeId, command, repeat, cb) { sendCommand(shadeId, command, repeat, cb) {
let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; let [cmd, done] = this.commandArgs(shadeId, command, repeat, cb);
if (typeof shadeId === 'object') cb = command; let fields = { shadeId: cmd.shadeId, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize };
let fields = { shadeId: o.shadeId, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }; if (isNaN(parseInt(cmd.command, 10))) fields.command = cmd.command;
if (isNaN(parseInt(o.command, 10))) fields.command = o.command; else fields.target = parseInt(cmd.command, 10);
else fields.target = parseInt(o.command, 10); sendOp('shadeCommand', fields, done);
sendOp('shadeCommand', fields, cb);
} }
// The last frame again when it was this command, otherwise the command. // The last frame again when it was this command, otherwise the command.
sendCommandRepeat(shadeId, command, repeat, cb) { sendCommandRepeat(shadeId, command, repeat, cb) {
let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; let [cmd, done] = this.commandArgs(shadeId, command, repeat, cb);
if (typeof shadeId === 'object') cb = command; sendOp('repeatCommand', { shadeId: cmd.shadeId, command: cmd.command, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize }, done);
sendOp('repeatCommand', { shadeId: o.shadeId, command: o.command, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }, cb); }
// (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) { 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) { sendVRCommand(el) {
let pnl = document.getElementById('divVirtualRemote'); 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. // obj: a shade's or a group's id, and sunny, windy or both.
sendSetSensor(obj, cb) { 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; if (typeof obj.groupId === 'number' && !isNaN(obj.groupId)) fields.groupId = obj.groupId;
else fields.shadeId = obj.shadeId; else fields.shadeId = obj.shadeId;
sendOp('setSensor', fields, cb); sendOp('setSensor', fields, cb);
} }
sendGroupCommand(groupId, command, repeat, cb) { sendGroupCommand(groupId, command, repeat, cb) {
console.log(`Sending Group command ${groupId}-${command}`); 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) { sendTiltCommand(shadeId, command, cb) {
console.log(`Sending Tilt command ${shadeId}-${command}`); console.log(`Sending Tilt command ${shadeId}-${command}`);
@ -3750,7 +3712,7 @@ class Somfy {
mouseDown = false; mouseDown = false;
let obj = ui.fromElement(div); let obj = ui.fromElement(div);
let prompt = ui.promptMessage('Confirm Motor Response', () => { 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); if (err) ui.serviceError(err);
else if (group) somfy.setLinkedShadesList(group); else if (group) somfy.setLinkedShadesList(group);
}); });
@ -3845,7 +3807,7 @@ class Somfy {
if (err) ui.serviceError(err); if (err) ui.serviceError(err);
else { else {
let prompt = ui.promptMessage('Confirm Motor Response', () => { 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); if (err) ui.serviceError(err);
else if (group) somfy.setLinkedShadesList(group); else if (group) somfy.setLinkedShadesList(group);
}); });
@ -3856,11 +3818,10 @@ class Somfy {
} }
}); });
}); });
{ let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId));
let group = groupView(proto.model.groups.get(groupId));
if (!group) { if (!group) {
div.remove(); div.remove();
ui.errorMessage('The group is no longer there.'); gone('group');
} }
else { else {
let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }); let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; });
@ -3876,7 +3837,6 @@ class Somfy {
ui.errorMessage('The specified shade could not be found in this group.'); ui.errorMessage('The specified shade could not be found in this group.');
} }
} }
}
return div; return div;
} }
unlinkRepeater(address) { unlinkRepeater(address) {
@ -3895,7 +3855,7 @@ class Somfy {
shadeId: shadeId, shadeId: shadeId,
remoteAddress: remoteAddress remoteAddress: remoteAddress
}; };
sendEdit('unlinkRemote', obj, editedItem('shade', () => shadeId), (err, shade) => { sendEdit('unlinkRemote', obj, editTarget('shade', () => shadeId), (err, shade) => {
prompt.remove(); prompt.remove();
if (err) ui.serviceError(err); if (err) ui.serviceError(err);
else if (shade) this.setLinkedRemotesList(shade); else if (shade) this.setLinkedRemotesList(shade);
@ -4004,13 +3964,12 @@ var somfy = new Somfy();
class MQTT { class MQTT {
initialized = false; initialized = false;
init() { this.initialized = true; } init() { this.initialized = true; }
static FIELDS = ['enabled', 'pubDisco', 'protocol', 'hostname', 'port', 'username', 'hasPassword', 'rootTopic', 'discoTopic'];
loadMQTT() { loadMQTT() {
sendOp('getSettings', { section: 'mqtt' }, (err, reply) => { sendOp('getSettings', { section: 'mqtt' }, (err, reply) => {
if (err) if (err)
console.log(err); console.log(err);
else { 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 }); ui.toElement(document.getElementById('divMQTT'), { mqtt: settings });
// The device never returns the stored password; leave the field blank to keep it. // The device never returns the stored password; leave the field blank to keep it.
this.savedUsername = settings.username; this.savedUsername = settings.username;
@ -4053,7 +4012,7 @@ class MQTT {
} }
} }
let fields = { section: '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; fields.password = obj.mqtt.password;
sendOp('saveSettings', fields, (err) => { sendOp('saveSettings', fields, (err) => {
if (err) ui.serviceError(err); if (err) ui.serviceError(err);
@ -4098,7 +4057,11 @@ class Firmware {
xhr.onload = (evt) => { xhr.onload = (evt) => {
if (typeof overlay !== 'undefined') overlay.remove(); if (typeof overlay !== 'undefined') overlay.remove();
let status = xhr.status; 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 || {}; let err = xhr.response || {};
err.htmlError = status; err.htmlError = status;
err.service = `GET /backup`; err.service = `GET /backup`;
@ -4331,6 +4294,15 @@ class Firmware {
xhr.onload = function () { xhr.onload = function () {
console.log('File upload load called'); console.log('File upload load called');
btnCancel.innerText = 'Close'; 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) { switch (service) {
case '/restore': case '/restore':
if (xhr.status < 200 || xhr.status >= 300) { if (xhr.status < 200 || xhr.status >= 300) {

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 };

View file

@ -58,7 +58,7 @@ PNG_EXTENSIONS = {".png"}
# comes before index.js, which uses it. A source listed here isn't copied on # comes before index.js, which uses it. A source listed here isn't copied on
# its own. # its own.
BUNDLES = { 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"], "app.css": ["main.css", "widgets.css", "icons.css"],
} }

View file

@ -315,6 +315,21 @@ test('waitFor gives null when no event comes, and stops listening', async () =>
assert.equal((c.listeners.room || []).length, 0); 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', () => { test('subscribe sends the feed number', () => {
const { c, ws } = connected(); const { c, ws } = connected();
c.subscribe('frames'); c.subscribe('frames');

82
test/js/views.test.js Normal file
View file

@ -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' });
});