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); - }; + let id = after.id(reply); + // Without the event the reply is all there is, an add's new id in it. + proto.waitFor(after.event, (d) => after.match(d, id), 3000).then(() => finish(null, after.read(id) || reply)); + }, (err) => { + if (err.code === 'busy' && ++attempts < 10) setTimeout(send, 300); + else finish(opError(name, err), null); }); }; send(); } -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]]; - } +// A group as the UI shows it: its shades whole, where the device sends ids. +function groupView(group) { + if (!group) return group; + let view = Object.assign({}, group); + view.linkedShades = group.linkedShades.map((id) => proto.model.shades.get(id) || { shadeId: id, name: `Shade ${id}`, remoteAddress: 0 }); + return view; } -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'); +// The lists redraw from the model after events, a burst of them at once. +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(groupView)); + if (p.repeaters) somfy.setRepeaterList(proto.model.repeaters); + }, 20); } -var socket; -var tConnect = null; -var sockIsOpen = false; -var connecting = false; -var connects = 0; -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 - }); - tConnect = setTimeout(async () => { await reopenSocket(); }, 5000); - } +function renderAll() { + renderPending = { rooms: true, shades: true, groups: true, repeaters: true }; + render('rooms'); } -async function reopenSocket() { - if (tConnect) clearTimeout(tConnect); - tConnect = null; - await initSockets(); +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. +function subscribeConfigFeeds(on) { + if (!proto || !protoSynced) return; + for (let feed of ['frames', 'frequencyScan']) (on ? proto.subscribe(feed) : proto.unsubscribe(feed)).catch((err) => console.log(err)); +} +// A received frame as the frames list and remote linking read it. +function frameView(d) { + let f = proto.schema.fill(Object.assign({}, d), ['encKey', 'remoteAddress', 'lastRollingCode', 'command', 'rawCmd', 'rssi', 'bits', 'proto', 'valid', 'stepSize', 'pulses']); + return { encKey: f.encKey, address: f.remoteAddress, rcode: f.lastRollingCode, command: f.command, rawCmd: f.rawCmd, rssi: f.rssi, + bits: f.bits, proto: f.proto, valid: f.valid, stepSize: d.stepSize, pulses: f.pulses }; +} +function scanView(d) { + let s = proto.schema.fill(Object.assign({}, d), ['scanning', 'testFreq', 'testRssi', 'frequency', 'rssi']); + return { scanning: s.scanning, testFreq: s.testFreq, testRSSI: s.testRssi, frequency: s.frequency, RSSI: s.rssi }; +} +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 }); + proto.on('synced', () => { + let first = !protoSynced; + protoSynced = true; + connectFailed = 0; + clearSocketWait(); + if (general.reloadApp) { + general.reload(); + return; + } + if (!first) return; + ui.clearErrors(); + general.loadGeneral(); + wifi.loadNetwork(); + somfy.loadSomfy(); + mqtt.loadMQTT(); + showStatus(proto.model.status); + if (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(frameView(d))); + proto.on('frequencyScan', (d) => somfy.procFrequencyScan(scanView(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 +1051,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 +1061,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 +1086,57 @@ 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; + proto.close(); + clearSocketWait(); + this.setToken(''); + this.authenticated = false; + this.type = ctx.type; + this.authUser(); + document.getElementById('btnCancelLogin').style.display = 'none'; + }); + } async loadContext() { let pnl = document.getElementById('divUnauthenticated'); pnl.querySelector('#loginButtons').style.display = 'none'; @@ -1286,7 +1151,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 +1215,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 +1257,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 +1387,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: 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(security); }); } async setAppVersion() { @@ -1607,20 +1447,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 +1475,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 +1487,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; @@ -1667,12 +1504,11 @@ 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 sec = { type: security.type, username: security.username, password: security.password, pin: '' }; // Pin entry. for (let i = 0; i < 4; i++) { sec.pin += security.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; @@ -1722,13 +1558,20 @@ class General { 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 once a save leaves it Secured; + // this one comes back with the token in the reply. + security.type = sec.type; + security.authenticated = sec.type !== 0; + security.setToken(reply.apiKey); + startSocket(); + }, true); }); prompt.querySelector('.sub-message').innerHTML = confirm; @@ -1801,16 +1644,38 @@ class Wifi { } } onDHCPClicked(cb) { document.getElementById('divStaticIP').style.display = cb.checked ? 'none' : ''; } + static NETWORK_FIELDS = ['connMode', 'hasEthernet', 'ssid', 'hasPassphrase', 'roaming', 'hidden', 'boardType', 'phyType', 'CLKMode', + 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin', 'dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2']; + static ETHERNET_FIELDS = ['boardType', 'phyType', 'CLKMode', 'phyAddress', 'PWRPin', 'MDCPin', 'MDIOPin']; + // The network section as the panel binds it: WiFi, Ethernet and the + // address each in an object of its own. + networkView(n) { + n = proto.schema.fill(Object.assign({}, n), Wifi.NETWORK_FIELDS); + let view = { connType: n.connMode, hasEthernet: n.hasEthernet, wifi: {}, ethernet: {}, ip: {} }; + for (let name of ['ssid', 'hasPassphrase', 'roaming', 'hidden']) view.wifi[name] = n[name]; + for (let name of Wifi.ETHERNET_FIELDS) view.ethernet[name] = n[name]; + for (let name of ['dhcp', 'ip', 'gateway', 'subnet', 'dns1', 'dns2']) view.ip[name] = n[name]; + return view; + } + // Saves part of the network section; the device may restart for it. + saveNetworkSection(fields, done) { + sendOp('saveSettings', Object.assign({ section: 'network' }, fields), (err, reply) => { + 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 = this.networkView(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 +1726,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 +3338,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) ui.errorMessage('The shade is no longer there.'); + 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,110 +3360,36 @@ 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) ui.errorMessage('The group is no longer there.'); + 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); + let shade = proto.model.shades.get(shadeId); + { + if (!shade) { + ui.errorMessage('The shade is no longer there.'); } else { console.log(shade); @@ -3740,19 +3411,17 @@ class Somfy { div.innerHTML = html; document.getElementById('somfyShade').appendChild(div); } - }); + } } setPaired(shadeId, paired) { let obj = { shadeId: shadeId, paired: paired || false }; let div = document.getElementById('divPairing'); - let overlay = typeof div === 'undefined' ? undefined : ui.waitMessage(div); - editJSON('/setPaired', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { - if (overlay) overlay.remove(); + sendEdit('setPaired', obj, editedItem('shade', () => shadeId), (err, shade) => { if (err) { console.log(err); - ui.errorMessage(err.message); + ui.errorMessage(err.desc); } - else if (div) { + else if (div && shade) { console.log(shade); this.showEditShade(true); document.getElementById('btnSaveShade').style.display = 'inline-block'; @@ -3887,55 +3556,26 @@ class Somfy { return div; } + // A command, or a position to move to. shadeId may instead be an object + // with shadeId, command, repeat and stepSize, as the virtual remote sends. + // cb(err, reply): the reply carries no state; the shade's state events + // follow as it moves. sendCommand(shadeId, command, repeat, cb) { - let obj = {}; - if (typeof shadeId.shadeId !== 'undefined') { - obj = shadeId; - cb = command; - shadeId = obj.shadeId; - repeat = obj.repeat; - command = obj.command; - } - else { - obj = { shadeId: shadeId }; - if (isNaN(parseInt(command, 10))) obj.command = command; - else obj.target = parseInt(command, 10); - if (typeof repeat === 'number') obj.repeat = parseInt(repeat); - } - putJSON('/shadeCommand', obj, (err, shade) => { - if (typeof cb === 'function') cb(err, shade); - }); + let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; + if (typeof shadeId === 'object') cb = command; + let fields = { shadeId: o.shadeId, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }; + if (isNaN(parseInt(o.command, 10))) fields.command = o.command; + else fields.target = parseInt(o.command, 10); + sendOp('shadeCommand', fields, cb); } + // The last frame again when it was this command, otherwise the command. sendCommandRepeat(shadeId, command, repeat, cb) { - //console.log(`Sending Shade command ${shadeId}-${command}`); - let obj = {}; - if (typeof shadeId.shadeId !== 'undefined') { - obj = shadeId; - cb = command; - shadeId = obj.shadeId; - repeat = obj.repeat; - command = obj.command; - } - else { - obj = { shadeId: shadeId, command: command }; - if (typeof repeat === 'number') obj.repeat = parseInt(repeat); - } - putJSON('/repeatCommand', obj, (err, shade) => { - if (typeof cb === 'function') cb(err, shade); - }); - - /* - putJSON(`/repeatCommand?shadeId=${shadeId}&command=${command}`, null, (err, shade) => { - if(typeof cb === 'function') cb(err, shade); - }); - */ + let o = typeof shadeId === 'object' ? shadeId : { shadeId: shadeId, command: command, repeat: repeat }; + if (typeof shadeId === 'object') cb = command; + sendOp('repeatCommand', { shadeId: o.shadeId, command: o.command, repeat: typeof o.repeat === 'number' ? o.repeat : undefined, stepSize: o.stepSize }, cb); } sendGroupRepeat(groupId, command, repeat, cb) { - let obj = { groupId: groupId, command: command }; - if (typeof repeat === 'number') obj.repeat = parseInt(repeat); - putJSON(`/repeatCommand?groupId=${groupId}&command=${command}`, null, (err, group) => { - if (typeof cb === 'function') cb(err, group); - }); + sendOp('repeatCommand', { groupId: groupId, command: command, repeat: typeof repeat === 'number' ? repeat : undefined }, cb); } sendVRCommand(el) { let pnl = document.getElementById('divVirtualRemote'); @@ -3981,30 +3621,21 @@ class Somfy { else somfy.sendCommand(o, (err, shade) => { fnRepeatCommand(err, shade); }); } + // obj: a shade's or a group's id, and sunny, windy or both. sendSetSensor(obj, cb) { - putJSON('/setSensor', obj, (err, device) => { - if (typeof cb === 'function') cb(err, device); - }); + let fields = { sunny: obj.sunny, windy: obj.windy, repeat: typeof obj.repeat === 'number' ? obj.repeat : undefined }; + if (typeof obj.groupId === 'number' && !isNaN(obj.groupId)) fields.groupId = obj.groupId; + else fields.shadeId = obj.shadeId; + sendOp('setSensor', fields, cb); } sendGroupCommand(groupId, command, repeat, cb) { console.log(`Sending Group command ${groupId}-${command}`); - let obj = { groupId: groupId }; - if (isNaN(parseInt(command, 10))) obj.command = command; - if (typeof repeat === 'number') obj.repeat = parseInt(repeat); - putJSON('/groupCommand', obj, (err, group) => { - if (typeof cb === 'function') cb(err, group); - }); + sendOp('groupCommand', { groupId: groupId, command: command, repeat: typeof repeat === 'number' ? repeat : undefined }, cb); } sendTiltCommand(shadeId, command, cb) { console.log(`Sending Tilt command ${shadeId}-${command}`); - if (isNaN(parseInt(command, 10))) - putJSON('/tiltCommand', { shadeId: shadeId, command: command }, (err, shade) => { - if (typeof cb === 'function') cb(err, shade); - }); - else - putJSON('/tiltCommand', { shadeId: shadeId, target: parseInt(command, 10) }, (err, shade) => { - if (typeof cb === 'function') cb(err, shade); - }); + if (isNaN(parseInt(command, 10))) sendOp('tiltCommand', { shadeId: shadeId, command: command }, cb); + else sendOp('tiltCommand', { shadeId: shadeId, target: parseInt(command, 10) }, cb); } linkRemote(shadeId) { let div = document.createElement('div'); @@ -4083,7 +3714,7 @@ class Somfy { btnOpenMemory.addEventListener('click', (evt) => { let obj = ui.fromElement(div); console.log(obj); - putJSONSync('/shadeCommand', { shadeId: obj.shadeId, command: 'prog', repeat: 40 }, (err, shade) => { + sendOp('shadeCommand', { shadeId: obj.shadeId, command: 'prog', repeat: 40 }, (err) => { if (err) ui.serviceError(err); else { let prompt = ui.promptMessage('Confirm Motor Response', () => { @@ -4119,11 +3750,9 @@ class Somfy { mouseDown = false; let obj = ui.fromElement(div); let prompt = ui.promptMessage('Confirm Motor Response', () => { - editJSON('/linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, (id) => `/group?groupId=${id}`, (err, group) => { - console.log(group); + sendEdit('linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, editedItem('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(); @@ -4131,13 +3760,15 @@ class Somfy { prompt.querySelector('.sub-message').innerHTML = `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 +3827,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 +3841,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 }, editedItem('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,14 +3856,13 @@ class Somfy { } }); }); - getJSONSync(`/group?groupId=${groupId}`, (err, group) => { - if (err) { + { + let group = groupView(proto.model.groups.get(groupId)); + if (!group) { div.remove(); - ui.serviceError(err); + ui.errorMessage('The group is no longer there.'); } 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. @@ -4248,14 +3876,13 @@ class Somfy { 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 +3895,10 @@ class Somfy { shadeId: shadeId, remoteAddress: remoteAddress }; - editJSON('/unlinkRemote', obj, (id) => `/shade?shadeId=${id}`, (err, shade) => { - console.log(shade); + sendEdit('unlinkRemote', obj, editedItem('shade', () => shadeId), (err, shade) => { prompt.remove(); if (err) ui.serviceError(err); - else this.setLinkedRemotesList(shade); + else if (shade) this.setLinkedRemotesList(shade); }); }); @@ -4378,12 +4004,13 @@ var somfy = new Somfy(); class MQTT { initialized = false; init() { this.initialized = true; } - async loadMQTT() { - getJSONSync('/mqttsettings', (err, settings) => { - if (err) + static FIELDS = ['enabled', 'pubDisco', 'protocol', 'hostname', 'port', 'username', 'hasPassword', 'rootTopic', 'discoTopic']; + loadMQTT() { + sendOp('getSettings', { section: 'mqtt' }, (err, reply) => { + if (err) console.log(err); else { - console.log(settings); + let settings = proto.schema.fill(Object.assign({}, reply), MQTT.FIELDS); 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 +4052,12 @@ class MQTT { return; } } - putJSONSync('/connectmqtt', obj.mqtt, (err, response) => { + let fields = { section: 'mqtt' }; + for (let name of MQTT.FIELDS) 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(); @@ -4498,6 +4127,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 +4315,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}%`); 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/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 @@ - - - - - - - - - - -