diff --git a/data-src/index.html b/data-src/index.html index 398f0f0..4772b99 100644 --- a/data-src/index.html +++ b/data-src/index.html @@ -179,10 +179,6 @@ -
It may or may not have been made. Press YES to reload the page and see.
'; - }, 5000); - pendingEdits[id] = (result) => { - clearTimeout(timer); - finish(result); - }; - }); - }; - send(); -} -function procRequestResult(result) { - let fn = pendingEdits[result.requestId]; - if (typeof fn === 'function') { - delete pendingEdits[result.requestId]; - fn(result); - } - // The socket can beat the reply that carries the id. Results of other - // clients' edits land here too, so only the latest few are kept. - else { - earlyResults[result.requestId] = result; - let ids = Object.keys(earlyResults); - if (ids.length > 10) delete earlyResults[ids[0]]; - } -} -function editsPending() { return Object.keys(pendingEdits).length > 0; } -// Reads are served from a copy of the shade model the device takes for each, -// and it takes at most two at once: a third is told to try again. -function isBusy(status, response) { - return status === 503 && response && typeof response.desc === 'string' && response.desc.startsWith('Busy'); -} -var socket; -var tConnect = null; -var sockIsOpen = false; -var connecting = false; -var connects = 0; +// The device over socket protocol 2 (docs/PROTOCOL.md; the client is in +// data-src/js). Everything but the login and the file transfers goes over the +// one socket: a page load opens no other connection, which the device's heap +// can't spare (docs/adr/0005-one-websocket-protocol.md). +var proto = null; +var protoSynced = false; +var everSynced = false; var connectFailed = 0; -async function initSockets() { - if (connecting) return; - console.log('Connecting to socket...'); - connecting = true; - if (tConnect) clearTimeout(tConnect); - tConnect = null; - let wms = document.getElementsByClassName('socket-wait'); - for (let i = 0; i < wms.length; i++) { - wms[i].remove(); - } - ui.waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); - let host = window.location.protocol === 'file:' ? hst : window.location.hostname; - try { - socket = new WebSocket(`ws://${host}:8080/`); - socket.onmessage = (evt) => { - if (evt.data.startsWith('42')) { - let ndx = evt.data.indexOf(','); - let eventName = evt.data.substring(3, ndx); - let data = evt.data.substring(ndx + 1, evt.data.length - 1); - try { - var reISO = /^(\d{4}|\+010000)-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*))(?:Z|(\+|-)([\d|:]*))?$/; - var reMsAjax = /^\/Date\((d|-|.*)\)[\/|\\]$/; - var msg = JSON.parse(data, (key, value) => { - if (typeof value === 'string') { - var a = reISO.exec(value); - if (a) return new Date(value); - a = reMsAjax.exec(value); - if (a) { - var b = a[1].split(/[-+,.]/); - return new Date(b[0] ? +b[0] : 0 - +b[1]); - } - } - return value; - }); - switch (eventName) { - case 'memStatus': - firmware.procMemoryStatus(msg); - break; - case 'fwStatus': - firmware.procFwStatus(msg); - break; - case 'remoteFrame': - somfy.procRemoteFrame(msg); - break; - case 'groupState': - somfy.procGroupState(msg); - break; - case 'shadeState': - somfy.procShadeState(msg); - break; - case 'shadeCommand': - console.log(msg); - break; - case 'requestResult': - procRequestResult(msg); - break; - case 'roomRemoved': - somfy.procRoomRemoved(msg); - break; - case 'roomAdded': - somfy.procRoomAdded(msg); - break; - // Another client changed the lists. This one's own edits - // refresh them when their result comes in. - case 'shadeRemoved': - case 'shadeAdded': - if (!editsPending()) somfy.updateShadeList(); - break; - case 'groupRemoved': - case 'groupAdded': - if (!editsPending()) somfy.updateGroupList(); - break; - case 'ethernet': - wifi.procEthernet(msg); - break; - case 'wifiStrength': - wifi.procWifiStrength(msg); - break; - case 'packetPulses': - console.log(msg); - break; - case 'frequencyScan': - somfy.procFrequencyScan(msg); - break; - - } - } catch (err) { - console.log({ eventName: eventName, data: data, err: err }); - } - } - }; - socket.onopen = (evt) => { - if (tConnect) clearTimeout(tConnect); - tConnect = null; - console.log({ msg: 'open', evt: evt }); - sockIsOpen = true; - connecting = false; - connects++; - connectFailed = 0; - let wms = document.getElementsByClassName('socket-wait'); - for (let i = 0; i < wms.length; i++) { - wms[i].remove(); - } - let errs = document.getElementsByClassName('socket-error'); - for (let i = 0; i < errs.length; i++) - errs[i].remove(); - if (general.reloadApp) { - general.reload(); - } - else { - (async () => { - ui.clearErrors(); - await general.loadGeneral(); - await wifi.loadNetwork(); - await somfy.loadSomfy(); - await mqtt.loadMQTT(); - if (ui.isConfigOpen()) socket.send('join:0'); - - //await general.init(); - //await somfy.init(); - //await mqtt.init(); - //await wifi.init(); - })(); - } - }; - socket.onclose = (evt) => { - wifi.procWifiStrength({ ssid: '', channel: -1, strength: -100 }); - wifi.procEthernet({ connected: false, speed: 0, fullduplex: false }); - if (document.getElementsByClassName('socket-wait').length === 0) - ui.waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); - if (evt.wasClean) { - console.log({ msg: 'close-clean', evt: evt }); - connectFailed = 0; - tConnect = setTimeout(async () => { await reopenSocket(); }, 7000); - console.log('Reconnecting socket in 7 seconds'); - } - else { - console.log({ msg: 'close-died', reason: evt.reason, evt: evt, sock: socket }); - if (connects > 0) { - console.log('Reconnecting socket in 3 seconds'); - tConnect = setTimeout(async () => { await reopenSocket(); }, 3000); - } - else { - if (connecting) { - connectFailed++; - let timeout = Math.min(connectFailed * 500, 10000); - console.log(`Initial socket did not connect try again (server was busy and timed out ${connectFailed} times)`); - tConnect = setTimeout(async () => { await reopenSocket(); }, timeout); - if (connectFailed === 5) { - ui.socketError('Too many clients connected. A maximum of 5 clients may be connected at any one time. Close some connections to the ESP Somfy RTS device to proceed.'); - } - let spanAttempts = document.getElementById('spanSocketAttempts'); - if (spanAttempts) spanAttempts.innerHTML = connectFailed.fmt("#,##0"); - - } - else { - console.log('Connecting socket in .5 seconds'); - tConnect = setTimeout(async () => { await reopenSocket(); }, 500); - } - - } - - } - connecting = false; - }; - socket.onerror = (evt) => { - console.log({ msg: 'socket error', evt: evt, sock: socket }); - }; - } catch (err) { - console.log({ - msg: 'Websocket connection error', err: err +// An op as a promise of its reply, refused with what ui.serviceError shows. +// retryBusy sends it again while the device says busy: it saves settings and +// applies edits one at a time, whoever sent them. A busy command is the +// radio's queue or a moving shade, which the user decides about. +function runOp(name, fields, retryBusy) { + return new Promise((resolve, reject) => { + if (!proto) { + reject(ProtoViews.opError(name, { code: 'notConnected', desc: 'Not connected to the device' })); + return; + } + let attempts = 0; + let send = () => proto.op(name, fields).then(resolve, (err) => { + if (retryBusy && err.code === 'busy' && ++attempts < 10) setTimeout(send, 300); + else reject(ProtoViews.opError(name, err)); }); - tConnect = setTimeout(async () => { await reopenSocket(); }, 5000); - } + send(); + }); } -async function reopenSocket() { - if (tConnect) clearTimeout(tConnect); - tConnect = null; - await initSockets(); +// cb(err, reply) as the HTTP helpers did. With wait the spinner shows until +// the reply. +function sendOp(name, fields, cb, wait) { + let overlay = wait ? ui.waitMessage(document.getElementById('divContainer')) : null; + let done = (err, d) => { + if (overlay) overlay.remove(); + if (typeof cb === 'function') cb(err, d); + }; + runOp(name, fields, name === 'saveSettings').then((d) => done(null, d), (err) => done(err, null)); +} +// Where an edit's room, shade or group is once its event is in: the event to +// wait for, and the item's id from the reply (an add's new id) or the fields. +function editTarget(kind, idOf) { + let key = `${kind}Id`; + let map = { room: 'rooms', shade: 'shades', group: 'groups' }[kind]; + return { + event: kind, 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) + }; +} +// 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 finish = (err, item) => { + overlay.remove(); + if (typeof cb === 'function') cb(err, item); + }; + runOp(name, fields, true).then((reply) => { + if (!target) { + finish(null, reply); + return; + } + let id = target.idOf(reply); + // Without the event the reply is all there is, an add's new id in it. + proto.waitFor(target.event, (d) => target.match(d, id), 3000).then(() => finish(null, target.read(id) || reply)); + }, (err) => finish(err, null)); +} +// A command's repeat count, when the caller gave one. +function repeatOf(repeat) { return typeof repeat === 'number' ? repeat : undefined; } +function gone(what) { ui.errorMessage(`The ${what} is no longer there.`); } +// The lists redraw from the model after events, a burst of them at once. +// While the socket is down they wait for the next sync. +var renderPending = {}; +var renderTimer = null; +function render(part) { + renderPending[part] = true; + if (!protoSynced || renderTimer) return; + renderTimer = setTimeout(() => { + renderTimer = null; + let p = renderPending; + renderPending = {}; + // Shades and groups show their room's name. + if (p.rooms) somfy.setRoomsList(proto.model.roomList()); + if (p.rooms || p.shades) somfy.setShadesList(proto.model.shadeList()); + if (p.rooms || p.groups) somfy.setGroupsList(proto.model.groupList().map((g) => ProtoViews.group(proto.model, g))); + if (p.repeaters) somfy.setRepeaterList(proto.model.repeaters); + }, 20); +} +function renderAll() { + renderPending = { rooms: true, shades: true, groups: true, repeaters: true }; + render('rooms'); +} +function showSocketWait() { + if (document.getElementsByClassName('socket-wait').length === 0) + ui.waitMessage(document.getElementById('divContainer')).classList.add('socket-wait'); +} +function clearSocketWait() { + for (let el of [...document.getElementsByClassName('socket-wait')]) el.remove(); + for (let el of [...document.getElementsByClassName('socket-error')]) el.remove(); +} +function showStatus(status) { + firmware.procMemoryStatus({ free: status.freeHeap, max: status.maxHeap, min: status.minHeap, uptime: status.uptime }); + if (proto.model.header.connType === 'Ethernet') wifi.procEthernet({ connected: status.connected, speed: status.speed, fullduplex: status.fullDuplex }); + else wifi.procWifiStrength({ ssid: status.ssid, channel: status.channel, strength: status.strength }); +} +// The feeds the config panel shows: received frames and the frequency scan. +// The client keeps them across reconnects, so they are set even while the +// socket is down. +function subscribeConfigFeeds(on) { + if (!proto) return; + for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch(() => {}); +} +function showVersionMismatch(v) { + if (document.getElementById('divVersionMismatch')) return; + let div = document.createElement('div'); + div.id = 'divVersionMismatch'; + div.className = 'version-banner'; + div.innerHTML = `This page speaks protocol ${v.client} but the device speaks ${v.device}. Update the application files, or reload the page.`; + document.body.prepend(div); +} +// Opens the socket, or opens it again with the token a login gave. +function startSocket() { + if (proto) { + proto.setToken(security.apiKey); + return; + } + let host = window.location.protocol === 'file:' ? hst : window.location.host; + proto = new ProtoClient({ host: host, token: security.apiKey }); + // Each sync, the first and after a reconnect: what changed meanwhile shows. + proto.on('synced', () => { + let first = !everSynced; + everSynced = true; + protoSynced = true; + connectFailed = 0; + clearSocketWait(); + if (general.reloadApp) { + general.reload(); + return; + } + if (first) ui.clearErrors(); + general.loadGeneral(); + wifi.loadNetwork(); + somfy.loadSomfy(); + mqtt.loadMQTT(); + showStatus(proto.model.status); + if (first && ui.isConfigOpen()) subscribeConfigFeeds(true); + }); + let lost = (refused) => { + protoSynced = false; + wifi.procWifiStrength({ ssid: '', channel: -1, strength: -100 }); + wifi.procEthernet({ connected: false, speed: 0, fullduplex: false }); + showSocketWait(); + if (++connectFailed === 5) + ui.socketError('The device does not answer, or 5 clients are connected to it already. Close some connections to the ESP Somfy RTS device to proceed.'); + let spanAttempts = document.getElementById('spanSocketAttempts'); + if (spanAttempts) spanAttempts.innerHTML = connectFailed.fmt('#,##0'); + if (refused) security.socketRefused(); + }; + proto.on('closed', () => lost(false)); + proto.on('refused', () => lost(true)); + proto.on('versionMismatch', showVersionMismatch); + proto.on('shadeState', (d) => { + let shade = proto.model.shades.get(d.shadeId); + if (shade) somfy.procShadeState(shade); + }); + proto.on('groupState', (d) => { + let group = proto.model.groups.get(d.groupId); + if (group) somfy.procGroupState(group); + }); + for (let e of ['room', 'roomRemoved', 'roomOrder']) proto.on(e, () => render('rooms')); + for (let e of ['shade', 'shadeRemoved', 'shadeOrder']) proto.on(e, () => render('shades')); + for (let e of ['group', 'groupRemoved', 'groupOrder']) proto.on(e, () => render('groups')); + proto.on('repeaters', () => render('repeaters')); + proto.on('memory', () => showStatus(proto.model.status)); + proto.on('wifi', () => showStatus(proto.model.status)); + proto.on('ethernet', () => showStatus(proto.model.status)); + proto.on('frame', (d) => somfy.procRemoteFrame(ProtoViews.frame(proto.schema, d))); + proto.on('frequencyScan', (d) => somfy.procFrequencyScan(ProtoViews.scan(proto.schema, d))); + proto.on('shadeCommanded', (d) => console.log(d)); + // Another client saved settings: show them as they are now. + proto.on('settingsChanged', (d) => { + switch (d.section) { + case 'general': general.loadGeneral(); break; + case 'network': wifi.loadNetwork(); break; + case 'mqtt': mqtt.loadMQTT(); break; + case 'radio': somfy.loadRadio(); break; + } + }); + proto.on('error', (err) => console.log(err)); + showSocketWait(); + proto.connect(); } async function init() { await security.init(); @@ -1213,17 +1032,8 @@ class UIBinder { divHome.style.display = 'none'; divCfg.style.display = ''; document.getElementById('icoConfig').className = 'icss-home'; - if (sockIsOpen) socket.send('join:0'); - let overlay = ui.waitMessage(document.getElementById('divSecurityOptions')); - overlay.style.borderRadius = '5px'; - getJSON('/getSecurity', (err, security) => { - overlay.remove(); - if (err) ui.serviceError(err); - else { - console.log(security); - general.setSecurityConfig(security); - } - }); + subscribeConfigFeeds(true); + general.loadSecurity(); } setHomePanel() { if (!this.isConfigOpen()) return; @@ -1232,8 +1042,8 @@ class UIBinder { divHome.style.display = ''; divCfg.style.display = 'none'; document.getElementById('icoConfig').className = 'icss-gear'; - if (sockIsOpen) socket.send('leave:0'); - general.setSecurityConfig({ type: 0, username: '', permissions: 0 }); + subscribeConfigFeeds(false); + general.setSecurityConfig({ securityType: 0, username: '' }); } toggleConfig() { if (this.isConfigOpen()) @@ -1257,21 +1067,62 @@ class Security { type = 0; authenticated = false; apiKey = ''; - permissions = 0; + checkingRefusal = false; + // The token lives as long as the tab: a reload keeps it, closing the tab + // drops it (docs/SECURITY_PLAN.md). + static TOKEN_KEY = 'espsomfyToken'; + loadToken() { + try { return sessionStorage.getItem(Security.TOKEN_KEY) || ''; } catch (e) { return ''; } + } + setToken(token) { + this.apiKey = token || ''; + try { + if (this.apiKey) sessionStorage.setItem(Security.TOKEN_KEY, this.apiKey); + else sessionStorage.removeItem(Security.TOKEN_KEY); + } catch (e) { /* No storage: a reload asks for the login again. */ } + } + showApp() { + document.getElementById('divUnauthenticated').style.display = 'none'; + document.getElementById('divAuthenticated').style.display = ''; + document.getElementById('divContainer').setAttribute('data-auth', true); + } async init() { - let fld = document.getElementById('divUnauthenticated').querySelector('.pin-digit[data-bind="security.pin.d0"]'); document.getElementById('divUnauthenticated').querySelector('.pin-digit[data-bind="login.pin.d3"]').addEventListener('digitentered', (evt) => { security.login(); }); await this.loadContext(); - if (this.type === 0 || (this.permissions & 0x01) === 0x01) { // No login required or only the config is protected. - if (typeof socket === 'undefined' || !socket) (async () => { await initSockets(); })(); - //ui.setMode(mode); - document.getElementById('divUnauthenticated').style.display = 'none'; - document.getElementById('divAuthenticated').style.display = ''; - document.getElementById('divContainer').setAttribute('data-auth', true); + this.apiKey = this.loadToken(); + // Under Secured the socket needs a token; one kept from earlier in this + // tab is tried, and a refusal brings the login back. + if (this.type === 0 || this.apiKey) { + this.authenticated = this.type !== 0; + this.showApp(); + startSocket(); } } + // The socket closed before it opened. A browser can't tell a refused + // token from a device that is down, so ask the device: if it answers and + // wants a login, stop retrying and show the login. + socketRefused() { + if (this.checkingRefusal) return; + this.checkingRefusal = true; + getJSON('/loginContext', (err, ctx) => { + this.checkingRefusal = false; + if (err || ctx.type === 0) return; + 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(); + this.setToken(''); + this.authenticated = false; + this.authUser(); + document.getElementById('btnCancelLogin').style.display = 'none'; + } async loadContext() { let pnl = document.getElementById('divUnauthenticated'); pnl.querySelector('#loginButtons').style.display = 'none'; @@ -1286,7 +1137,6 @@ class Security { console.log(ctx); document.getElementById('divContainer').setAttribute('data-securitytype', ctx.type); this.type = ctx.type; - this.permissions = ctx.permissions; switch (ctx.type) { case 1: pnl.querySelector('#divLoginPin').style.display = ''; @@ -1351,14 +1201,10 @@ class Security { navigator.credentials.store(cred); } catch (e) { /* ignore; browsers without support fall back to the form-submit heuristic */ } } - if (typeof socket === 'undefined' || !socket) (async () => { await initSockets(); })(); - //ui.setMode(mode); - - document.getElementById('divUnauthenticated').style.display = 'none'; - document.getElementById('divAuthenticated').style.display = ''; - document.getElementById('divContainer').setAttribute('data-auth', true); - this.apiKey = log.apiKey; + this.setToken(log.apiKey); this.authenticated = true; + this.showApp(); + startSocket(); let evt2 = new CustomEvent('afterlogin', { detail: { authenticated: true } }); document.getElementById('divContainer').dispatchEvent(evt2); } @@ -1397,7 +1243,6 @@ class General { if (this.initialized) return; this.setAppVersion(); this.setTimeZones(); - if (sockIsOpen && ui.isConfigOpen()) socket.send('join:0'); ui.toElement(document.getElementById('divSystemSettings'), { general: { hostname: 'ESPSomfyRTS', username: '', password: '', posixZone: 'UTC0', ntpServer: 'pool.ntp.org' } }); this.initialized = true; } @@ -1528,44 +1373,25 @@ class General { ]; loadGeneral() { let pnl = document.getElementById('divSystemOptions'); - getJSONSync('/modulesettings', (err, settings) => { - if (err) { - console.log(err); - } - else { - console.log(settings); - document.getElementById('spanFwVersion').innerText = settings.fwVersion; - document.getElementById('spanHwVersion').innerText = settings.chipModel.length > 0 ? '-' + settings.chipModel : ''; - document.getElementById('divContainer').setAttribute('data-chipmodel', settings.chipModel); - somfy.initPins(); - general.setAppVersion(); - ui.toElement(pnl, { general: settings }); - } + // The firmware and the chip come with the sync. + let header = proto.model.header; + document.getElementById('spanFwVersion').innerText = header.fw; + document.getElementById('spanHwVersion').innerText = header.chipModel.length > 0 ? '-' + header.chipModel : ''; + document.getElementById('divContainer').setAttribute('data-chipmodel', header.chipModel); + somfy.initPins(); + general.setAppVersion(); + sendOp('getSettings', { section: 'general' }, (err, settings) => { + if (err) console.log(err); + else ui.toElement(pnl, { general: ProtoViews.settings(proto.schema, 'general', settings) }); }); } - loadLogin() { - getJSONSync('/loginContext', (err, ctx) => { + loadSecurity() { + let overlay = ui.waitMessage(document.getElementById('divSecurityOptions')); + overlay.style.borderRadius = '5px'; + sendOp('getSettings', { section: 'security' }, (err, security) => { + overlay.remove(); if (err) ui.serviceError(err); - else { - console.log(ctx); - let pnl = document.getElementById('divContainer'); - pnl.setAttribute('data-securitytype', ctx.type); - let fld; - switch (ctx.type) { - case 1: - document.getElementById('divPinSecurity').style.display = ''; - fld = document.getElementById('divPinSecurity').querySelector('.pin-digit[data-bind="security.pin.d0"]'); - document.getElementById('divPinSecurity').querySelector('.pin-digit[data-bind="security.pin.d3"]').addEventListener('digitentered', (evt) => { - general.login(); - }); - break; - case 2: - document.getElementById('divPasswordSecurity').style.display = ''; - fld = document.getElementById('fldUsername'); - break; - } - if (fld) fld.focus(); - } + else general.setSecurityConfig(ProtoViews.settings(proto.schema, 'security', security)); }); } async setAppVersion() { @@ -1598,8 +1424,8 @@ class General { ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The host name must only include numbers, letters, or dash.'; valid = false; } - if (valid && obj.hostname.length > 32) { - ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 32 characters.'; + if (valid && obj.hostname.length > 31) { + ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'The maximum Host Name length is 31 characters.'; valid = false; } if (valid && typeof obj.ntpServer === 'string' && obj.ntpServer.length > 64) { @@ -1607,20 +1433,21 @@ class General { valid = false; } if (valid) { - putJSONSync('/setgeneral', obj, (err, response) => { + sendOp('saveSettings', { section: 'general', hostname: obj.hostname, ssdpBroadcast: obj.ssdpBroadcast, ntpServer: obj.ntpServer, + posixZone: obj.posixZone }, (err, response) => { if (err) ui.serviceError(err); console.log(response); - }); + }, true); } } + // security: the security section's settings. setSecurityConfig(security) { // The device never returns the stored password or PIN, only whether one is set. - this.savedSecurity = { type: security.type, username: security.username, hasPassword: makeBool(security.hasPassword), hasPin: makeBool(security.hasPin) }; + this.savedSecurity = { type: security.securityType, username: security.username, hasPassword: makeBool(security.hasPassword), hasPin: makeBool(security.hasPin) }; // We need to transform the security object so that it can be set to the configuration. let obj = { security: { - type: security.type, username: security.username, password: '', repeatpassword: '', - permissions: { configOnly: makeBool(security.permissions & 0x01) }, + type: security.securityType, username: security.username, password: '', repeatpassword: '', pin: { d0: '', d1: '', d2: '', d3: '' } } }; @@ -1634,10 +1461,9 @@ class General { } rebootDevice() { ui.promptMessage(document.getElementById('divContainer'), 'Are you sure you want to reboot the device?', () => { - if(typeof socket !== 'undefined') socket.close(3000, 'reboot'); - putJSONSync('/reboot', {}, (err, response) => { + sendOp('reboot', {}, (err, response) => { document.getElementById('btnSaveGeneral').classList.remove('disabled'); - console.log(response); + if (err) ui.serviceError(err); }); ui.clearErrors(); }); @@ -1647,17 +1473,14 @@ class General { let sec = ui.fromElement(pnl).security; switch (sec.type) { case 0: - pnl.querySelector('#divPermissions').style.display = 'none'; pnl.querySelector('#divPinSecurity').style.display = 'none'; pnl.querySelector('#divPasswordSecurity').style.display = 'none'; break; case 1: - pnl.querySelector('#divPermissions').style.display = ''; pnl.querySelector('#divPinSecurity').style.display = ''; pnl.querySelector('#divPasswordSecurity').style.display = 'none'; break; case 2: - pnl.querySelector('#divPermissions').style.display = ''; pnl.querySelector('#divPinSecurity').style.display = 'none'; pnl.querySelector('#divPasswordSecurity').style.display = ''; break; @@ -1665,25 +1488,24 @@ class General { } } saveSecurity() { - let security = ui.fromElement(document.getElementById('divSecurityOptions')).security; - console.log(security); - let sec = { type: security.type, username: security.username, password: security.password, pin: '', perm: 0 }; + let form = ui.fromElement(document.getElementById('divSecurityOptions')).security; + console.log(form); + let sec = { type: form.type, username: form.username, password: form.password, pin: '' }; // Pin entry. for (let i = 0; i < 4; i++) { - sec.pin += security.pin[`d${i}`]; + sec.pin += form.pin[`d${i}`]; } - sec.permissions |= security.permissions.configOnly ? 0x01 : 0x00; // Blank secret fields keep the stored value while the type (and username) stay the same. let saved = this.savedSecurity || {}; - let keepPin = security.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; - let keepPassword = security.type === 2 && sec.password === '' && security.repeatpassword === '' && + let keepPin = form.type === 1 && sec.pin === '' && saved.hasPin && saved.type === 1; + let keepPassword = form.type === 2 && sec.password === '' && form.repeatpassword === '' && saved.hasPassword && saved.type === 2 && sec.username === saved.username; // Only send the secret for the selected type so the other one stays stored. - if (security.type !== 1 || keepPin) delete sec.pin; - if (security.type !== 2 || keepPassword) delete sec.password; + if (form.type !== 1 || keepPin) delete sec.pin; + if (form.type !== 2 || keepPassword) delete sec.password; let confirm = ''; console.log(sec); - if (security.type === 1 && !keepPin) { // Pin Entry + if (form.type === 1 && !keepPin) { // Pin Entry // Make sure our pin is 4 digits. if (sec.pin.length !== 4) { ui.errorMessage('Invalid Pin').querySelector('.sub-message').innerHTML = 'Pins must be exactly 4 alpha-numeric values in length. Please enter a complete pin.'; @@ -1691,7 +1513,7 @@ class General { } confirm = 'Please keep your PIN safe and above all remember it. The only way to recover a lost PIN is to completely reload the onboarding firmware which will wipe out your configuration.
Have you stored your PIN in a safe place?
'; } - else if (security.type === 2) { // Password + else if (form.type === 2) { // Password if (sec.username.length === 0) { ui.errorMessage('No Username Provided').querySelector('.sub-message').innerHTML = 'You must provide a username for password security.'; return; @@ -1701,7 +1523,7 @@ class General { return; } } - if (security.type === 2 && !keepPassword) { // Password + if (form.type === 2 && !keepPassword) { // Password if (sec.password.length === 0) { ui.errorMessage('No Password Provided').querySelector('.sub-message').innerHTML = 'You must provide a password for password security.'; return; @@ -1711,24 +1533,33 @@ class General { return; } - if (security.repeatpassword.length === 0) { + if (form.repeatpassword.length === 0) { ui.errorMessage('Re-enter Password').querySelector('.sub-message').innerHTML = 'You must re-enter the password in the Re-enter Password field.'; return; } - if (sec.password !== security.repeatpassword) { + if (sec.password !== form.repeatpassword) { ui.errorMessage('Passwords do not Match').querySelector('.sub-message').innerHTML = 'Please re-enter the password exactly as you typed it in the Re-enter Password field.'; return; } confirm = 'Please keep your password safe and above all remember it. The only way to recover a password is to completely reload the onboarding firmware which will wipe out your configuration.
Have you stored your username and password in a safe place?
'; } let prompt = ui.promptMessage('Confirm Security', () => { - putJSONSync('/saveSecurity', sec, (err, objApiKey) => { + let fields = { section: 'security', securityType: sec.type, username: sec.username, password: sec.password, pin: sec.pin }; + sendOp('saveSettings', fields, (err, reply) => { prompt.remove(); - if (err) ui.serviceError(err); - else { - console.log(objApiKey); + if (err) { + ui.serviceError(err); + return; } - }); + // The device closes every socket when a save leaves it Secured or + // changes its mode; this one comes back with the token in the + // reply. None to None closes nothing. + let closed = sec.type !== 0 || security.type !== 0; + security.type = sec.type; + security.authenticated = sec.type !== 0; + security.setToken(reply.apiKey); + if (closed) startSocket(); + }, true); }); prompt.querySelector('.sub-message').innerHTML = confirm; @@ -1801,16 +1632,25 @@ class Wifi { } } onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; } + // Saves part of the network section; the device may restart for it. + saveNetworkSection(fields, done) { + sendOp('saveSettings', Object.assign({ section: 'network' }, fields), (err, reply) => { + if (err) ui.serviceError(err); + else if (reply.rebooting) ui.infoMessage('The device is restarting with the new network settings.'); + if (typeof done === 'function') done(err, reply); + }, true); + } loadNetwork() { let pnl = document.getElementById('divNetAdapter'); - getJSONSync('/networksettings', (err, settings) => { - console.log(settings); + sendOp('getSettings', { section: 'network' }, (err, reply) => { if (err) { ui.serviceError(err); } else { - // Firmware older than hasEthernet always built Ethernet in. - let hasEthernet = settings.hasEthernet !== false; + let settings = ProtoViews.network(proto.schema, reply); + console.log(settings); + let hasEthernet = settings.hasEthernet; + this.hasEthernet = hasEthernet; document.getElementById('divHardwired').style.display = hasEthernet ? '' : 'none'; document.getElementById('cbHardwired').checked = hasEthernet && settings.connType >= 2; document.getElementById('cbFallbackWireless').checked = hasEthernet && settings.connType === 3; @@ -1861,17 +1701,14 @@ class Wifi { if (document.getElementById('btnScanAPs').classList.contains('disabled')) return; document.getElementById('divAps').innerHTML = 'If this room was previously selected for motors or groups, they will be automatically assigned to the Home room.
`; - } - }); + }); + prompt.querySelector('.sub-message').innerHTML = `If this room was previously selected for motors or groups, they will be automatically assigned to the Home room.
`; + } } } deleteShade(shadeId) { @@ -3591,21 +3299,19 @@ class Somfy { valid = false; } if (valid) { - getJSONSync(`/shade?shadeId=${shadeId}`, (err, shade) => { - if (err) ui.serviceError(err); - else if (shade.inGroup) ui.errorMessage(`You may not delete this shade because it is a member of a group.`); - else { - let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { - ui.clearErrors(); - editJSON('/deleteShade', { shadeId: shadeId }, null, (err, shade) => { - prompt.remove(); - if (err) ui.serviceError(err); - this.updateShadeList(); - }); + let shade = proto.model.shades.get(shadeId); + if (!shade) gone('shade'); + else if (shade.inGroup) ui.errorMessage(`You may not delete this shade because it is a member of a group.`); + else { + let prompt = ui.promptMessage(`Are you sure you want to delete this shade?`, () => { + ui.clearErrors(); + sendEdit('deleteShade', { shadeId: shadeId }, null, (err) => { + prompt.remove(); + if (err) ui.serviceError(err); }); - prompt.querySelector('.sub-message').innerHTML = `If this shade was previously paired with a motor, you should first unpair it from the motor and remove it from any groups. Otherwise its address will remain in the motor memory.
Press YES to delete ${shade.name} or NO to cancel this operation.
`; - } - }); + }); + prompt.querySelector('.sub-message').innerHTML = `If this shade was previously paired with a motor, you should first unpair it from the motor and remove it from any groups. Otherwise its address will remain in the motor memory.
Press YES to delete ${shade.name} or NO to cancel this operation.
`; + } } } deleteGroup(groupId) { @@ -3615,144 +3321,65 @@ class Somfy { valid = false; } if (valid) { - getJSONSync(`/group?groupId=${groupId}`, (err, group) => { - if (err) ui.serviceError(err); - else { - if (group.linkedShades.length > 0) { - ui.errorMessage('You may not delete this group until all shades have been removed from it.'); - } - else { - let prompt = ui.promptMessage(`Are you sure you want to delete this group?`, () => { - editJSON('/deleteGroup', { groupId: groupId }, null, (err, g) => { - if (err) ui.serviceError(err); - this.updateGroupList(); - prompt.remove(); - }); - - }); - prompt.querySelector('.sub-message').innerHTML = `Press YES to delete the ${group.name} group or NO to cancel this operation.
`; - - } - } - }); + let group = proto.model.groups.get(groupId); + if (!group) gone('group'); + else if (group.linkedShades.length > 0) { + ui.errorMessage('You may not delete this group until all shades have been removed from it.'); + } + else { + let prompt = ui.promptMessage(`Are you sure you want to delete this group?`, () => { + sendEdit('deleteGroup', { groupId: groupId }, null, (err) => { + if (err) ui.serviceError(err); + prompt.remove(); + }); + }); + prompt.querySelector('.sub-message').innerHTML = `Press YES to delete the ${group.name} group or NO to cancel this operation.
`; + } } } - sendPairCommand(shadeId) { - putJSON('/pairShade', { shadeId: shadeId }, (err, shade) => { - if (err) { - console.log(err); - } - else { - console.log(shade); - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyShade').style.display = ''; - document.getElementById('btnSaveShade').style.display = 'inline-block'; - document.getElementById('btnLinkRemote').style.display = ''; - document.getElementsByName('shadeAddress')[0].value = shade.remoteAddress; - document.getElementsByName('shadeName')[0].value = shade.name; - document.getElementsByName('shadeUpTime')[0].value = shade.upTime; - document.getElementsByName('shadeDownTime')[0].value = shade.downTime; - let ico = document.getElementById('icoShade'); - ico.style.setProperty('--shade-position', `${shade.flipPosition ? 100 - shade.position : shade.position}%`); - ico.style.setProperty('--fpos', `${shade.position}%`); - //ico.style.setProperty('--shade-position', `${shade.position}%`); - ico.setAttribute('data-shadeid', shade.shadeId); - if (shade.paired) { - document.getElementById('btnUnpairShade').style.display = 'inline-block'; - document.getElementById('btnPairShade').style.display = 'none'; - } - else { - document.getElementById('btnPairShade').style.display = 'inline-block'; - document.getElementById('btnUnpairShade').style.display = 'none'; - } - this.setLinkedRemotesList(shade); - document.getElementById('divPairing').remove(); - } - - }); - } - sendUnpairCommand(shadeId) { - putJSON('/unpairShade', { shadeId: shadeId }, (err, shade) => { - if (err) { - console.log(err); - } - else { - console.log(shade); - document.getElementById('somfyMain').style.display = 'none'; - document.getElementById('somfyShade').style.display = ''; - document.getElementById('btnSaveShade').style.display = 'inline-block'; - document.getElementById('btnLinkRemote').style.display = ''; - document.getElementsByName('shadeAddress')[0].value = shade.remoteAddress; - document.getElementsByName('shadeName')[0].value = shade.name; - document.getElementsByName('shadeUpTime')[0].value = shade.upTime; - document.getElementsByName('shadeDownTime')[0].value = shade.downTime; - let ico = document.getElementById('icoShade'); - ico.style.setProperty('--shade-position', `${shade.flipPosition ? 100 - shade.position : shade.position}%`); - ico.style.setProperty('--fpos', `${shade.position}%`); - //ico.style.setProperty('--shade-position', `${shade.position}%`); - ico.setAttribute('data-shadeid', shade.shadeId); - if (shade.paired) { - document.getElementById('btnUnpairShade').style.display = 'inline-block'; - document.getElementById('btnPairShade').style.display = 'none'; - } - else { - document.getElementById('btnPairShade').style.display = 'inline-block'; - document.getElementById('btnUnpairShade').style.display = 'none'; - } - this.setLinkedRemotesList(shade); - document.getElementById('divPairing').remove(); - } - }); - } setRollingCode(shadeId, rollingCode) { - putJSONSync('/setRollingCode', { shadeId: shadeId, rollingCode: rollingCode }, (err, shade) => { + sendOp('setRollingCode', { shadeId: shadeId, lastRollingCode: rollingCode }, (err) => { if (err) ui.serviceError(document.getElementById('divSomfySettings'), err); else { let dlg = document.getElementById('divRollingCode'); if (dlg) dlg.remove(); } - }); + }, true); } openSetRollingCode(shadeId) { - let overlay = ui.waitMessage(document.getElementById('divContainer')); - getJSON(`/shade?shadeId=${shadeId}`, (err, shade) => { - overlay.remove(); - if (err) { - ui.serviceError(err); - } - else { - console.log(shade); - let div = document.createElement('div'); - div.setAttribute('id', 'divRollingCode'); - let html = `If this shade is already paired with a motor then changing the rolling code WILL cause it to stop working. Rolling codes are tied to the remote address and the Somfy motor expects these to be sequential.
'; - html += 'If you hesitated just a little bit do not press the red button. Green represents safety so press it, wipe the sweat from your brow, and go through the normal pairing process.'; - html += '
If this shade is already paired with a motor then changing the rolling code WILL cause it to stop working. Rolling codes are tied to the remote address and the Somfy motor expects these to be sequential.
'; + html += 'If you hesitated just a little bit do not press the red button. Green represents safety so press it, wipe the sweat from your brow, and go through the normal pairing process.'; + html += '
Did the shade jog? If the shade jogged press the YES button and your shade will be linked to the group. If it did not press the NO button and try again.
Once the shade has jogged the shade will be added to the group and this process will be finished.
`; }); - getJSONSync(`/groupOptions?groupId=${groupId}`, (err, options) => { + sendOp('groupOptions', { groupId: groupId }, (err, reply) => { if (err) { div.remove(); ui.serviceError(err); } else { - console.log(options); + // The shades the group doesn't have yet, by name from the model. + let group = proto.model.groups.get(groupId) || { name: '' }; + let options = { name: group.name, availShades: (reply.ids || []).map((id) => proto.model.shades.get(id)).filter((x) => x) }; if (options.availShades.length > 0) { // Add in all the available shades. let selAvail = div.querySelector('#selAvailShades'); @@ -4196,7 +3789,7 @@ class Somfy { btnOpenMemory.addEventListener('click', (evt) => { let obj = ui.fromElement(div); console.log(obj); - putJSONSync('/shadeCommand', { shadeId: shadeId, command: 'prog', repeat: 40 }, (err, shade) => { + sendOp('shadeCommand', { shadeId: shadeId, command: 'prog', repeat: 40 }, (err) => { if (err) ui.serviceError(err); else { let prompt = ui.promptMessage('Confirm Motor Response', () => { @@ -4210,15 +3803,13 @@ class Somfy { let btnUnpairFromGroup = div.querySelector('#btnUnpairFromGroup'); btnUnpairFromGroup.addEventListener('click', (evt) => { let obj = ui.fromElement(div); - putJSONSync('/groupCommand', { groupId: groupId, command: 'prog', repeat: 1 }, (err, shade) => { + sendOp('groupCommand', { groupId: groupId, command: 'prog', repeat: 1 }, (err) => { if (err) ui.serviceError(err); else { let prompt = ui.promptMessage('Confirm Motor Response', () => { - editJSON('/unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, (id) => `/group?groupId=${id}`, (err, group) => { - console.log(group); + sendEdit('unlinkFromGroup', { groupId: groupId, shadeId: shadeId }, editTarget('group', () => groupId), (err, group) => { if (err) ui.serviceError(err); - else somfy.setLinkedShadesList(group); - this.updateGroupList(); + else if (group) somfy.setLinkedShadesList(group); }); prompt.remove(); div.remove(); @@ -4227,35 +3818,31 @@ class Somfy { } }); }); - getJSONSync(`/group?groupId=${groupId}`, (err, group) => { - if (err) { - div.remove(); - ui.serviceError(err); + let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId)); + if (!group) { + div.remove(); + gone('group'); + } + else { + let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }); + if (typeof shade !== 'undefined') { + // Add in all the available shades. + let grpName = div.querySelector('#divGroupName'); + if (grpName) grpName.innerHTML = group.name; + let divWizShadeName = div.querySelector('#divWizShadeName'); + if (divWizShadeName) divWizShadeName.innerHTML = shade.name; } else { - console.log(group); - console.log(shadeId); - let shade = group.linkedShades.find((x) => { return shadeId === x.shadeId; }); - if (typeof shade !== 'undefined') { - // Add in all the available shades. - let grpName = div.querySelector('#divGroupName'); - if (grpName) grpName.innerHTML = group.name; - let divWizShadeName = div.querySelector('#divWizShadeName'); - if (divWizShadeName) divWizShadeName.innerHTML = shade.name; - } - else { - div.remove(); - ui.errorMessage('The specified shade could not be found in this group.'); - } + div.remove(); + ui.errorMessage('The specified shade could not be found in this group.'); } - }); + } return div; } unlinkRepeater(address) { let prompt = ui.promptMessage('Are you sure you want to stop repeating frames from this address?', () => { - editJSON('/unlinkRepeater', { address: address }, () => '/repeaters', (err, repeaters) => { + sendEdit('unlinkRepeater', { remoteAddress: address }, null, (err) => { if (err) ui.serviceError(err); - else this.setRepeaterList(repeaters); prompt.remove(); }); @@ -4268,11 +3855,10 @@ class Somfy { shadeId: shadeId, remoteAddress: remoteAddress }; - editJSON('/unlinkRemote', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { - console.log(shade); + sendEdit('unlinkRemote', obj, editTarget('shade', () => shadeId), (err, shade) => { prompt.remove(); if (err) ui.serviceError(err); - else this.setLinkedRemotesList(shade); + else if (shade) this.setLinkedRemotesList(shade); }); }); @@ -4378,12 +3964,12 @@ var somfy = new Somfy(); class MQTT { initialized = false; init() { this.initialized = true; } - async loadMQTT() { - getJSONSync('/mqttsettings', (err, settings) => { - if (err) + loadMQTT() { + sendOp('getSettings', { section: 'mqtt' }, (err, reply) => { + if (err) console.log(err); else { - console.log(settings); + let settings = ProtoViews.settings(proto.schema, 'mqtt', reply); ui.toElement(document.getElementById('divMQTT'), { mqtt: settings }); // The device never returns the stored password; leave the field blank to keep it. this.savedUsername = settings.username; @@ -4425,10 +4011,12 @@ class MQTT { return; } } - putJSONSync('/connectmqtt', obj.mqtt, (err, response) => { + let fields = { section: 'mqtt' }; + for (let name of ProtoViews.SETTINGS.mqtt) if (name !== 'hasPassword') fields[name] = obj.mqtt[name]; + fields.password = obj.mqtt.password; + sendOp('saveSettings', fields, (err) => { if (err) ui.serviceError(err); - console.log(response); - }); + }, true); } } var mqtt = new MQTT(); @@ -4469,7 +4057,11 @@ class Firmware { xhr.onload = (evt) => { if (typeof overlay !== 'undefined') overlay.remove(); let status = xhr.status; - if (status !== 200) { + if (status === 401) { + security.tokenRefused(); + reject({ htmlError: status, service: 'GET /backup', desc: 'Log in again, then back up' }); + } + else if (status !== 200) { let err = xhr.response || {}; err.htmlError = status; err.service = `GET /backup`; @@ -4498,6 +4090,8 @@ class Firmware { reject({ htmlError: status, service: 'GET /backup' }); }; xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}/backup?attach=true` : '/backup?attach=true', true); + // Under Secured the file transfers need the token too. + xhr.setRequestHeader('apikey', security.apiKey); xhr.send(); }); } @@ -4684,8 +4278,8 @@ class Firmware { prog.style.display = ''; btnSelectFile.style.visibility = 'hidden'; let xhr = new XMLHttpRequest(); - //xhr.open('POST', service, true); xhr.open('POST', baseUrl.length > 0 ? `${baseUrl}${service}` : service, true); + xhr.setRequestHeader('apikey', security.apiKey); xhr.upload.onprogress = function (evt) { let pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0; prog.style.setProperty('--progress', `${pct}%`); @@ -4700,6 +4294,15 @@ class Firmware { xhr.onload = function () { console.log('File upload load called'); btnCancel.innerText = 'Close'; + if (xhr.status === 401) { + el.remove(); + security.tokenRefused(); + return; + } + if (service !== '/restore' && (xhr.status < 200 || xhr.status >= 300)) { + ui.errorMessage(el, `The upload failed (${xhr.status}).`); + return; + } switch (service) { case '/restore': if (xhr.status < 200 || xhr.status >= 300) { diff --git a/data-src/js/client.js b/data-src/js/client.js index fff68a3..533e1ae 100644 --- a/data-src/js/client.js +++ b/data-src/js/client.js @@ -68,7 +68,27 @@ class ProtoClient { // unless a listener calls close(), as the UI does once /loginContext says // a login is needed (docs/SECURITY_PLAN.md). on(name, cb) { (this.listeners[name] = this.listeners[name] || []).push(cb); } - emit(name, d) { for (const cb of this.listeners[name] || []) cb(d); } + off(name, cb) { this.listeners[name] = (this.listeners[name] || []).filter((x) => x !== cb); } + emit(name, d) { for (const cb of (this.listeners[name] || []).slice()) cb(d); } + // The next event of that name that match(d) accepts, or null after ms. + // An edit's events follow its reply, so this is how a caller gets the + // item the edit left: the model has it by then. + waitFor(name, match, ms) { + return new Promise((resolve) => { + let timer = null; + const cb = (d) => { + if (match && !match(d)) return; + this.off(name, cb); + this.clearTimeout(timer); + resolve(d); + }; + this.on(name, cb); + timer = this.setTimeout(() => { + this.off(name, cb); + resolve(null); + }, ms); + }); + } connect() { if (!this.stopped && (this.ws || this.reconnectTimer)) return; diff --git a/data-src/js/views.js b/data-src/js/views.js new file mode 100644 index 0000000..e1719b6 --- /dev/null +++ b/data-src/js/views.js @@ -0,0 +1,80 @@ +// What the web UI's panels bind, made from socket protocol 2's items, events +// and settings (docs/PROTOCOL.md), and back. The UI kept its names where they +// differ from the protocol's: a frame's address and rcode, a scan's RSSI, the +// network panel's wifi, ethernet and ip objects, the radio's type. The minify +// step joins this file into index.js; Node loads it on its own for the tests +// (test/js). +class ProtoViews { + // A settings section's reply with what the device left out at its default + // filled in, so a form shows what is saved rather than what it showed + // before. + static settings(schema, section, d) { + return schema.fill(Object.assign({}, d), ProtoViews.SETTINGS[section]); + } + // A group with its shades whole, where the device sends their ids. + static group(model, group) { + if (!group) return group; + const view = Object.assign({}, group); + view.linkedShades = group.linkedShades.map((id) => model.shades.get(id) || { shadeId: id, name: `Shade ${id}`, remoteAddress: 0 }); + return view; + } + // A received frame as the frames list and remote linking read it. + static frame(schema, d) { + const f = schema.fill(Object.assign({}, d), ['encKey', 'remoteAddress', 'lastRollingCode', 'command', 'rawCmd', 'rssi', 'bits', 'proto', + 'valid', 'pulses']); + return { encKey: f.encKey, address: f.remoteAddress, rcode: f.lastRollingCode, command: f.command, rawCmd: f.rawCmd, rssi: f.rssi, + bits: f.bits, proto: f.proto, valid: f.valid, stepSize: d.stepSize, pulses: f.pulses }; + } + static scan(schema, d) { + const s = schema.fill(Object.assign({}, d), ['scanning', 'testFreq', 'testRssi', 'frequency', 'rssi']); + return { scanning: s.scanning, testFreq: s.testFreq, testRSSI: s.testRssi, frequency: s.frequency, RSSI: s.rssi }; + } + // The network section as the panel binds it: WiFi, Ethernet and the + // address each in an object of their own, the connection as connType. + static network(schema, d) { + const n = ProtoViews.settings(schema, 'network', d); + const view = { connType: n.connMode, hasEthernet: n.hasEthernet, wifi: {}, ethernet: {}, ip: {} }; + for (const name of ProtoViews.WIFI) view.wifi[name] = n[name]; + for (const name of ProtoViews.ETHERNET) view.ethernet[name] = n[name]; + for (const name of ProtoViews.IP) view.ip[name] = n[name]; + return view; + } + // The radio section as the panel binds it, where the bit length is the + // radio's type. + static radio(schema, d) { + const config = ProtoViews.settings(schema, 'radio', d); + config.type = config.bitLength; + delete config.bitLength; + return config; + } + // A save of the radio panel's config: everything but what is only shown. + static radioSave(config) { + const fields = { section: 'radio', bitLength: config.type }; + for (const name of ProtoViews.SETTINGS.radio) { + if (name !== 'bitLength' && name !== 'radioInit') fields[name] = config[name]; + } + return fields; + } + // An op's refusal as the UI's error box shows it. A shade whose settings + // don't add up carries the reason's number, which the errors table + // describes. + static opError(name, err) { + const e = { htmlError: err.code, service: name, desc: err.desc }; + if (err.data && typeof err.data.validation === 'number' && err.data.validation !== 0) e.code = err.data.validation; + return e; + } +} +ProtoViews.WIFI = ['ssid', 'hasPassphrase', 'roaming', 'hidden']; +ProtoViews.ETHERNET = ['boardType', 'phyType', 'CLKMode', 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin']; +ProtoViews.IP = ['dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2']; +// The fields of each settings section, as getSettings sends them +// (src/DeviceOps.cpp). +ProtoViews.SETTINGS = { + general: ['hostname', 'ssdpBroadcast', 'ntpServer', 'posixZone'], + network: ['connMode', 'hasEthernet'].concat(ProtoViews.WIFI, ProtoViews.ETHERNET, ProtoViews.IP), + mqtt: ['enabled', 'pubDisco', 'protocol', 'hostname', 'port', 'username', 'hasPassword', 'rootTopic', 'discoTopic'], + security: ['securityType', 'username', 'hasPassword', 'hasPin'], + radio: ['bitLength', 'proto', 'enabled', 'TXPin', 'RXPin', 'SCKPin', 'MOSIPin', 'MISOPin', 'CSNPin', 'frequency', 'rxBandwidth', + 'deviation', 'txPower', 'noiseDetection', 'radioInit'] +}; +if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoViews }; diff --git a/data-src/login.html b/data-src/login.html deleted file mode 100644 index c789f6c..0000000 --- a/data-src/login.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - -