diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b94857f..562aa41 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -28,9 +28,26 @@ jobs: - name: Run native unit tests run: pio test -e native + webui: + name: Web UI tests + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + # The protocol 2 client (data-src/js) against the recorded fixtures. + - name: Run web UI tests + run: node --test "test/js/*.test.js" + build: # Don't build or publish firmware that fails its tests. - needs: test + needs: [test, webui] permissions: write-all name: ${{ matrix.name }} runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index c4089ab..55a0dbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ - Fix any test or type errors until the whole suite is green. - Add or update tests for the code you change, even if nobody asked. - to test run `pio test -e native` +- the web UI's protocol 2 client (`data-src/js/`): `node --test "test/js/*.test.js"`. The minify step joins those files into `index.js`, so each one ends with a `module.exports` footer for Node and adds nothing global but its classes. ## Building - Firmware builds need pioarduino's Core (`pip install pioarduino`, or the pioarduino IDE extension). Stock PlatformIO Core refuses the pinned platform, see [ADR 0003](docs/adr/0003-pioarduino-idf55.md). diff --git a/data-src/index.html b/data-src/index.html index d52572d..4772b99 100644 --- a/data-src/index.html +++ b/data-src/index.html @@ -8,9 +8,7 @@ - - - + @@ -181,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 new file mode 100644 index 0000000..533e1ae --- /dev/null +++ b/data-src/js/client.js @@ -0,0 +1,352 @@ +// A socket protocol 2 client (docs/PROTOCOL.md): ws://host/ws, hello, the sync +// with next after each frame, ops answered by their reply, events and feeds. +// It keeps the shade model (ProtoModel) up to date and connects again when the +// socket closes. The WebSocket, the timers and the schema's storage can be +// handed in, so the tests (test/js) run it without a device. The minify step +// joins this file into index.js after schema.js and model.js. +class ProtoError extends Error { + // code is an error name from the schema (badRequest, busy ...), or + // notConnected, closed or timeout from the client itself. + constructor(code, desc, data) { + super(desc || code); + this.code = code; + this.desc = desc || code; + this.data = data || {}; + } +} +class ProtoClient { + // opts: host, token, WebSocket, storage (getItem/setItem for the schema; + // localStorage when there is one), setTimeout/clearTimeout, opTimeoutMs. + constructor(opts) { + const deps = ProtoClient.deps(); + this.Schema = deps.ProtoSchema; + this.host = opts.host; + this.token = opts.token || ''; + this.WebSocket = opts.WebSocket || (typeof WebSocket !== 'undefined' ? WebSocket : null); + this.storage = typeof opts.storage !== 'undefined' ? opts.storage : ProtoClient.browserStorage(); + this.setTimeout = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms)); + this.clearTimeout = opts.clearTimeout || ((t) => clearTimeout(t)); + this.opTimeoutMs = opts.opTimeoutMs || 10000; + this.schema = this.loadSchema() || new this.Schema(); + this.model = new deps.ProtoModel(this.schema); + this.model.onChange((kind, id, item) => this.emit('change', { kind: kind, id: id, item: item })); + this.listeners = {}; + this.pending = new Map(); + this.feeds = new Set(); + this.nextId = 1; + this.ws = null; + this.ready = false; // synced: ops may go + this.schemaReady = false; // frames can be read + this.helloHash = null; + this.opened = false; + this.stopped = true; + this.retries = 0; + this.reconnectTimer = null; + this.held = []; + this.fw = null; + } + // In the browser the classes are the ones index.js was joined with; in + // Node they are loaded. + static deps() { + if (typeof require === 'function' && typeof module !== 'undefined' && module.exports) + return { ProtoSchema: require('./schema.js').ProtoSchema, ProtoModel: require('./model.js').ProtoModel }; + return { ProtoSchema: ProtoSchema, ProtoModel: ProtoModel }; + } + static browserStorage() { + try { + return typeof localStorage !== 'undefined' ? localStorage : null; + } catch (err) { + return null; + } + } + + // cb(d) for a device event by its name (shadeState, frame ...), and for + // the client's own: open, hello, synced, change, closed, versionMismatch, + // error (a refusal the device couldn't tie to an op) and refused. + // refused: the socket closed before it opened. A browser can't tell a + // refused token from a device that is down, so the client tries again + // 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); } + 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; + this.stopped = false; + this.open(); + } + close() { + this.stopped = true; + this.cancelReconnect(); + if (this.ws) this.ws.close(); + } + // A new token, from a login: the device checks it as the socket opens, so + // the client connects again with it at once. + setToken(token) { + this.token = token || ''; + this.stopped = false; + this.retries = 0; + this.open(); + } + url() { + const params = []; + if (this.token) params.push(`apikey=${encodeURIComponent(this.token)}`); + if (this.schema.hash) params.push(`schema=${encodeURIComponent(this.schema.hash)}`); + return `ws://${this.host}/ws${params.length ? '?' + params.join('&') : ''}`; + } + cancelReconnect() { + if (this.reconnectTimer) this.clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + // One socket at a time: one being replaced is let go without a word. + open() { + this.cancelReconnect(); + if (this.ws) { + const old = this.ws; + this.ws = null; + this.lost(); + old.close(); + } + this.ready = false; + this.schemaReady = false; + this.opened = false; + this.held = []; + const ws = new this.WebSocket(this.url()); + this.ws = ws; + ws.onopen = () => { + if (this.ws !== ws) return; + this.opened = true; + this.retries = 0; + this.emit('open'); + }; + ws.onmessage = (evt) => { + if (this.ws === ws) this.receive(evt.data); + }; + ws.onclose = () => { + if (this.ws !== ws) return; + this.ws = null; + this.lost(); + this.emit(this.opened ? 'closed' : 'refused'); + if (this.stopped) return; + // Soon after a close, slower after each failure, at most 10 s. + const ms = Math.min(500 * Math.pow(2, this.retries++), 10000); + this.reconnectTimer = this.setTimeout(() => this.open(), ms); + }; + } + // The socket has gone: nothing waiting gets its reply. + lost() { + this.ready = false; + for (const [, p] of this.pending) { + this.clearTimeout(p.timer); + p.reject(new ProtoError('closed', 'The connection closed')); + } + this.pending.clear(); + } + frame(type, d, id) { + const frame = { v: ProtoClient.VERSION, t: this.schema.typeNumbers[type] }; + if (typeof frame.t === 'undefined') throw new ProtoError('unknownOp', `The device has no message ${type}`); + if (id) frame.id = id; + if (d) frame.d = d; + return JSON.stringify(frame); + } + + // Sends an op, or a control message that wants a reply, by its name with + // its fields by name. Resolves with the reply's fields, rejects with a + // ProtoError. + op(name, fields) { + return new Promise((resolve, reject) => { + if (!this.ready || !this.ws) { + reject(new ProtoError('notConnected', 'Not connected to the device')); + return; + } + const id = this.nextId++; + let text; + try { + text = this.frame(name, this.schema.pack(fields || {}), id); + } catch (err) { + reject(err instanceof ProtoError ? err : new ProtoError('badRequest', err.message)); + return; + } + this.ws.send(text); + const timer = this.setTimeout(() => { + this.pending.delete(id); + reject(new ProtoError('timeout', `No reply to ${name}`)); + }, this.opTimeoutMs); + this.pending.set(id, { resolve: resolve, reject: reject, timer: timer, text: text, resends: 0 }); + }); + } + // Feeds stay subscribed across reconnects. + subscribe(feed) { + if (typeof this.schema.feedNumbers[feed] === 'undefined') return Promise.reject(new ProtoError('badRequest', `The device has no feed ${feed}`)); + this.feeds.add(feed); + return this.op('subscribe', { feed: this.schema.feedNumbers[feed] }); + } + unsubscribe(feed) { + this.feeds.delete(feed); + if (typeof this.schema.feedNumbers[feed] === 'undefined') return Promise.resolve(); + return this.op('unsubscribe', { feed: this.schema.feedNumbers[feed] }); + } + // The shade model and status again, without the schema. + resync() { + this.model.beginSync(); + return this.op('sync').catch((err) => { + this.model.cancelSync(); + throw err; + }); + } + + receive(text) { + let frame; + try { + frame = JSON.parse(text); + } catch (err) { + return; + } + if (frame.v !== ProtoClient.VERSION) { + this.emit('versionMismatch', { device: frame.v, client: ProtoClient.VERSION }); + return; + } + const d = frame.d || {}; + // These have fixed numbers: they come before the schema. + switch (frame.t) { + case ProtoClient.HELLO: this.hello(d); return; + case ProtoClient.SYNC: this.syncFrame(d); return; + case ProtoClient.SYNC_END: this.syncEnd(); return; + default: break; + } + // A frame the client can't read yet waits for the schema. + if (!this.schemaReady) this.held.push(frame); + else this.dispatch(frame); + } + hello(d) { + this.fw = d.fw; + this.helloHash = d.sh; + // A schema the client kept for this one needn't come again; any other + // is no use, and the device sends the one it has. + if (this.schema.hash !== d.sh) { + this.schema = new this.Schema(); + this.model.schema = this.schema; + } + this.model.beginSync(); + this.emit('hello', d); + if (this.schema.hash === d.sh) this.readable(); + } + syncFrame(d) { + this.schema.add(d); + const device = this.Schema.withoutRows(d); + // The schema's rows come first, so a frame with the device's sections + // has the whole of it. + if (Object.keys(device).length) { + this.readable(); + this.model.applySync(this.schema.expand(device)); + } + this.ws.send(JSON.stringify({ v: ProtoClient.VERSION, t: ProtoClient.NEXT })); + } + // The schema is whole: frames that waited for it go, in the order they + // came and ahead of the sync frames formatted after them. + readable() { + if (this.schemaReady) return; + this.schemaReady = true; + const held = this.held; + this.held = []; + for (const frame of held) this.dispatch(frame); + } + syncEnd() { + this.readable(); + this.model.endSync(); + if (!this.ready) { + this.ready = true; + // Only a whole schema is kept, so a sync cut short gets it again. + this.schema.hash = this.helloHash; + this.saveSchema(); + for (const feed of this.feeds) this.op('subscribe', { feed: this.schema.feedNumbers[feed] }).catch(() => {}); + } + this.emit('synced', this.model); + } + dispatch(frame) { + const d = this.schema.expand(frame.d || {}); + if (frame.t === ProtoClient.REPLY) this.reply(frame.id, d); + else { + const name = this.schema.types[frame.t]; + if (!name) return; + this.model.applyEvent(name, d); + this.emit(name, d); + } + } + reply(id, d) { + const err = d.ok ? null : new ProtoError(this.schema.errors[d.code] || 'internal', d.desc, d); + const p = typeof id !== 'undefined' ? this.pending.get(id) : null; + if (p) { + this.pending.delete(id); + this.clearTimeout(p.timer); + if (err) p.reject(err); + else p.resolve(d); + return; + } + if (!err) return; + // A long message the device couldn't take while it put another one + // together is refused without its id: the long ones waiting go again. + if (err.code === 'busy' && this.resendLong()) return; + this.emit('error', err); + } + resendLong() { + let any = false; + for (const [, p] of this.pending) { + if (p.text.length < ProtoClient.LONG_MESSAGE || p.resends >= 3) continue; + p.resends++; + any = true; + this.setTimeout(() => { + if (this.ws && [...this.pending.values()].includes(p)) this.ws.send(p.text); + }, 200 * p.resends); + } + return any; + } + + loadSchema() { + if (!this.storage) return null; + try { + return this.Schema.load(JSON.parse(this.storage.getItem(ProtoClient.SCHEMA_KEY))); + } catch (err) { + return null; + } + } + saveSchema() { + if (!this.storage) return; + try { + this.storage.setItem(ProtoClient.SCHEMA_KEY, JSON.stringify(this.schema.save())); + } catch (err) { /* Private windows may have no storage: the device sends the schema each time. */ } + } +} +ProtoClient.VERSION = 2; +// The types a client reads before it has the schema, and next, which it sends +// during the sync. +ProtoClient.HELLO = 1; +ProtoClient.REPLY = 2; +ProtoClient.NEXT = 3; +ProtoClient.SYNC = 4; +ProtoClient.SYNC_END = 5; +// A message longer than a TCP segment reaches the device in pieces +// (docs/PROTOCOL.md). +ProtoClient.LONG_MESSAGE = 1400; +ProtoClient.SCHEMA_KEY = 'espsomfyProtoSchema'; +if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoClient, ProtoError }; diff --git a/data-src/js/model.js b/data-src/js/model.js new file mode 100644 index 0000000..2411996 --- /dev/null +++ b/data-src/js/model.js @@ -0,0 +1,159 @@ +// The shade model as socket protocol 2 keeps a client up to date with it +// (docs/PROTOCOL.md): rooms, shades, groups and repeaters from the sync, then +// changed by the device's events. Items are inserted or updated by id, so an +// event during the sync does no harm. It takes frames already expanded to +// names (ProtoSchema.expand) and fills in what a frame leaves out at its +// default. The minify step joins this file into index.js; Node loads it on +// its own for the tests (test/js). +class ProtoModel { + constructor(schema) { + this.schema = schema; + this.rooms = new Map(); + this.shades = new Map(); + this.groups = new Map(); + this.repeaters = []; + this.header = {}; + this.status = {}; + this.listeners = []; + this.seen = null; + } + // cb(kind, id, item): kind is room, shade or group for an item added or + // changed; roomRemoved, shadeRemoved or groupRemoved; roomOrder, + // shadeOrder or groupOrder (item: the ids in order); repeaters, header or + // status. + onChange(cb) { this.listeners.push(cb); } + changed(kind, id, item) { for (const cb of this.listeners) cb(kind, id, item); } + + // A sync: what it doesn't list is gone once it ends, the repeaters too, + // which it leaves out when there are none. + beginSync() { this.seen = { rooms: new Set(), shades: new Set(), groups: new Set(), repeaters: false }; } + // A sync that won't come after all. + cancelSync() { this.seen = null; } + applySync(d) { + if (d.header) { + this.header = this.schema.fill(Object.assign({}, d.header), ProtoModel.FIELDS.header); + this.changed('header', 0, this.header); + } + for (const kind of ProtoModel.ITEMS) for (const item of d[kind.list] || []) this.put(kind, item); + if (d.repeaters) this.putRepeaters(d.repeaters); + if (d.status) { + this.putStatus(d.status, ProtoModel.FIELDS.memory); + this.putStatus(d.status, this.header.connType === 'Ethernet' ? ProtoModel.FIELDS.ethernet : ProtoModel.FIELDS.wifi); + } + } + endSync() { + if (!this.seen) return; + for (const kind of ProtoModel.ITEMS) { + const map = this[kind.list]; + for (const id of [...map.keys()]) { + if (this.seen[kind.list].has(id)) continue; + map.delete(id); + this.changed(kind.removed, id); + } + } + if (!this.seen.repeaters && this.repeaters.length) this.putRepeaters([]); + this.seen = null; + } + // An event by its type's name; false for one that isn't about the model, + // or that changed nothing. + applyEvent(type, d) { + for (const kind of ProtoModel.ITEMS) { + const id = d[kind.id] || 0; + switch (type) { + case kind.name: this.put(kind, d); return true; + case kind.state: return this.mergeState(kind, id, d); + case kind.removed: return this.remove(kind, id); + case kind.order: return this.order(kind, d.ids || []); + default: break; + } + } + switch (type) { + case 'repeaters': this.putRepeaters(d.repeaters || []); return true; + case 'memory': this.putStatus(d, ProtoModel.FIELDS.memory); return true; + case 'wifi': this.putStatus(d, ProtoModel.FIELDS.wifi); return true; + case 'ethernet': this.putStatus(d, ProtoModel.FIELDS.ethernet); return true; + default: return false; + } + } + + // The lists in their sort order. + roomList() { return ProtoModel.sorted(this.rooms); } + shadeList() { return ProtoModel.sorted(this.shades); } + groupList() { return ProtoModel.sorted(this.groups); } + static sorted(map) { return [...map.values()].sort((a, b) => a.sortOrder - b.sortOrder); } + + // A whole item: what it had before is replaced. + put(kind, item) { + const id = item[kind.id] || 0; + const whole = this.schema.fill(Object.assign({}, item), ProtoModel.FIELDS[kind.name]); + // A shade's linked remotes leave out their defaults too. + if (kind.name === 'shade') + whole.linkedRemotes = whole.linkedRemotes.map((r) => this.schema.fill(Object.assign({}, r), ProtoModel.FIELDS.linkedRemote)); + this[kind.list].set(id, whole); + if (this.seen) this.seen[kind.list].add(id); + this.changed(kind.name, id, whole); + } + // Part of an item: only its state. A state of an item not synced yet + // waits for the item. + mergeState(kind, id, d) { + const item = this[kind.list].get(id); + if (!item) return false; + Object.assign(item, this.schema.fill(Object.assign({}, d), ProtoModel.FIELDS[kind.state])); + this.changed(kind.name, id, item); + return true; + } + remove(kind, id) { + if (!this[kind.list].delete(id)) return false; + this.changed(kind.removed, id); + return true; + } + order(kind, ids) { + ids.forEach((id, i) => { + const item = this[kind.list].get(id); + if (item) item.sortOrder = i; + }); + this.changed(kind.order, 0, ids); + return true; + } + putRepeaters(list) { + this.repeaters = list.slice(); + if (this.seen) this.seen.repeaters = true; + this.changed('repeaters', 0, this.repeaters); + } + putStatus(d, fields) { + const part = {}; + for (const name of fields) part[name] = typeof d[name] === 'undefined' ? this.schema.defaultOf(name) : d[name]; + Object.assign(this.status, part); + this.changed('status', 0, this.status); + } +} +// Rooms, shades and groups: their list, id, and the events about them. Rooms +// have no state of their own. +ProtoModel.ITEMS = [ + { name: 'room', list: 'rooms', id: 'roomId', state: null, removed: 'roomRemoved', order: 'roomOrder' }, + { name: 'shade', list: 'shades', id: 'shadeId', state: 'shadeState', removed: 'shadeRemoved', order: 'shadeOrder' }, + { name: 'group', list: 'groups', id: 'groupId', state: 'groupState', removed: 'groupRemoved', order: 'groupOrder' } +]; +// The fields of each kind of item, as the device writes them +// (src/ProtoSync.cpp, src/outputs/ProtoSocket.cpp): what a frame that leaves +// one out means by it. The schema has each field's default, not which items +// have it. +ProtoModel.FIELDS = { + header: ['maxRooms', 'maxShades', 'maxGroups', 'maxGroupedShades', 'maxLinkedRemotes', 'startingAddress', 'bitLength', + 'proto', 'serverId', 'hostname', 'model', 'chipModel', 'fw', 'connType'], + room: ['roomId', 'name', 'sortOrder'], + shadeState: ['shadeId', 'position', 'target', 'direction', 'myPos', 'tiltPosition', 'tiltTarget', 'tiltDirection', + 'myTiltPos', 'flags', 'lastRollingCode'], + groupState: ['groupId', 'flags', 'direction'], + linkedRemote: ['remoteAddress', 'lastRollingCode'], + group: ['groupId', 'roomId', 'name', 'remoteAddress', 'lastRollingCode', 'bitLength', 'proto', 'sunSensor', + 'flipCommands', 'flags', 'repeats', 'sortOrder', 'linkedShades'], + memory: ['freeHeap', 'maxHeap', 'minHeap', 'totalHeap', 'uptime', 'loopRate', 'loopMaxUs'], + wifi: ['ssid', 'strength', 'channel'], + ethernet: ['connected', 'speed', 'fullDuplex'] +}; +ProtoModel.FIELDS.shade = ProtoModel.FIELDS.shadeState.concat(['roomId', 'name', 'remoteAddress', 'upTime', 'downTime', + 'tiltTime', 'stepSize', 'paired', 'tiltType', 'shadeType', 'bitLength', 'proto', 'flipCommands', 'flipPosition', + 'inGroup', 'sunSensor', 'light', 'simMy', 'repeats', 'sortOrder', 'gpioUp', 'gpioDown', 'gpioMy', 'gpioLLTrigger', + 'linkedRemotes']); +if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoModel }; diff --git a/data-src/js/schema.js b/data-src/js/schema.js new file mode 100644 index 0000000..f0cf0f2 --- /dev/null +++ b/data-src/js/schema.js @@ -0,0 +1,101 @@ +// Socket protocol 2's schema (docs/PROTOCOL.md): the device's table of message +// types, fields, error codes and feeds, as the sync sends it. It turns the +// short keys of a frame into names and back. The minify step joins this file +// into index.js; Node loads it on its own for the tests (test/js). +class ProtoSchema { + constructor() { + this.hash = null; + this.types = {}; // number -> name + this.typeNumbers = {}; // name -> number + this.fields = {}; // key -> { name, kind, def } + this.keys = {}; // name -> key + this.errors = {}; + this.errorNumbers = {}; + this.feedNumbers = {}; + this.rows = { T: [], F: [], E: [], Q: [] }; + } + // A sync frame's payload without the schema's rows: the device's sections. + static withoutRows(d) { + const out = {}; + for (const key of Object.keys(d)) if (!ProtoSchema.ROWS.includes(key)) out[key] = d[key]; + return out; + } + // The schema's rows in a sync frame's payload: T types, F fields, E error + // codes, Q feeds. They may span several frames. + add(d) { + for (const [number, name] of d.T || []) { + this.types[number] = name; + this.typeNumbers[name] = number; + } + for (const [key, name, kind, def] of d.F || []) { + this.fields[key] = { name: name, kind: kind, def: def }; + this.keys[name] = key; + } + for (const [number, name] of d.E || []) { + this.errors[number] = name; + this.errorNumbers[name] = number; + } + for (const [number, name] of d.Q || []) this.feedNumbers[name] = number; + for (const k of ProtoSchema.ROWS) if (d[k]) this.rows[k].push(...d[k]); + } + get empty() { return Object.keys(this.fields).length === 0; } + // What a field left out of a frame is: its default. + defaultOf(name) { + const f = this.fields[this.keys[name]]; + if (!f) return undefined; + switch (f.kind) { + case 'b': return f.def !== 0; + case 's': return ''; + case 'l': return []; + case 'o': return {}; + default: return f.def; + } + } + // Short keys to names, and 1/0 to true/false, all the way down. A field + // left out stays out; fill() puts the defaults in. + expand(value) { + if (Array.isArray(value)) return value.map((v) => this.expand(v)); + if (value === null || typeof value !== 'object') return value; + const out = {}; + for (const key of Object.keys(value)) { + const f = this.fields[key]; + let v = value[key]; + if (f && f.kind === 'b' && typeof v === 'number') v = v !== 0; + out[f ? f.name : key] = this.expand(v); + } + return out; + } + // Names to short keys, and booleans to 1/0, for an op's payload. A name + // the schema doesn't have is an error: the device would refuse it anyway. + pack(value) { + if (Array.isArray(value)) return value.map((v) => this.pack(v)); + if (value === null || typeof value !== 'object') return typeof value === 'boolean' ? (value ? 1 : 0) : value; + const out = {}; + for (const name of Object.keys(value)) { + if (typeof value[name] === 'undefined') continue; + const key = this.keys[name]; + if (typeof key === 'undefined') throw new Error(`The device has no field ${name}`); + out[key] = this.pack(value[name]); + } + return out; + } + // Sets each of names that obj lacks to its default, as a frame leaves out + // a field at its default. Returns obj. + fill(obj, names) { + for (const name of names) if (typeof obj[name] === 'undefined') obj[name] = this.defaultOf(name); + return obj; + } + // What a cache keeps, and the schema back from it. + save() { return { hash: this.hash, rows: this.rows }; } + static load(saved) { + if (!saved || typeof saved.hash !== 'string' || !saved.rows) return null; + const s = new ProtoSchema(); + s.add(saved.rows); + s.hash = saved.hash; + return s.empty ? null : s; + } +} +// The keys of the schema's rows in a sync frame, fixed as a client needs them +// before it has the schema. +ProtoSchema.ROWS = ['T', 'F', 'E', 'Q']; +if (typeof module !== 'undefined' && module.exports) module.exports = { ProtoSchema }; 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 32623f1..0000000 --- a/data-src/login.html +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - -