mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
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:
parent
0df740df6a
commit
3212230f01
5 changed files with 357 additions and 208 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
function notConnected(name) { return opError(name, { code: 'notConnected', desc: 'Not connected to the device' }); }
|
let attempts = 0;
|
||||||
// Sends an op; cb(err, reply) as the HTTP helpers did. With wait the spinner
|
let send = () => proto.op(name, fields).then(resolve, (err) => {
|
||||||
// shows until the reply. The device saves settings one at a time, so a save
|
if (retryBusy && err.code === 'busy' && ++attempts < 10) setTimeout(send, 300);
|
||||||
// that finds another running tries again; a busy command is the radio's queue
|
else reject(ProtoViews.opError(name, err));
|
||||||
// or a moving shade, which the user decides about.
|
});
|
||||||
|
send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// cb(err, reply) as the HTTP helpers did. With wait the spinner shows until
|
||||||
|
// the reply.
|
||||||
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) => {
|
// Where an edit's room, shade or group is once its event is in: the event to
|
||||||
if (err.code === 'busy' && name === 'saveSettings' && ++attempts < 10) setTimeout(send, 300);
|
// wait for, and the item's id from the reply (an add's new id) or the fields.
|
||||||
else done(opError(name, err), null);
|
function editTarget(kind, idOf) {
|
||||||
});
|
|
||||||
send();
|
|
||||||
}
|
|
||||||
// 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) {
|
|
||||||
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,
|
||||||
function editedRepeaters() { return { event: 'repeaters', id: () => 0, match: () => true, read: () => proto.model.repeaters.slice() }; }
|
read: (id) => kind === 'group' ? ProtoViews.group(proto.model, proto.model.groups.get(id)) : proto.model[map].get(id)
|
||||||
// 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 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
80
data-src/js/views.js
Normal 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 };
|
||||||
|
|
@ -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"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
82
test/js/views.test.js
Normal 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' });
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue