/**
* hst is a development convenience variable: it's a hardcoded IP address used when the HTML file is opened directly
* from the filesystem (i.e., file:// protocol) rather than served from the ESP32.
* Adapt the IP accordingly based on your router configuration.
**/
var hst = '192.168.178.20';
var _rooms = [{ roomId: 0, name: 'Home' }];
var errors = [
{ code: -10, desc: "Pin setting in use for Transceiver. Output pins cannot be re-used." },
{ code: -11, desc: "Pin setting in use for Ethernet Adapter. Output pins cannot be re-used." },
{ code: -12, desc: "Pin setting in use on another motor. Output pins cannot be re-used." },
{ code: -21, desc: "Git Update: Flash write failed." },
{ code: -22, desc: "Git Update: Flash erase failed." },
{ code: -23, desc: "Git Update: Flash read failed." },
{ code: -24, desc: "Git Update: Not enough space." },
{ code: -25, desc: "Git Update: Invalid file size given." },
{ code: -26, desc: "Git Update: Stream read timeout." },
{ code: -27, desc: "Git Update: MD5 check failed." },
{ code: -28, desc: "Git Update: Wrong Magic Byte." },
{ code: -29, desc: "Git Update: Could not activate firmware." },
{ code: -30, desc: "Git Update: Partition could not be found." },
{ code: -31, desc: "Git Update: Bad Argument." },
{ code: -32, desc: "Git Update: Aborted." },
{ code: -40, desc: "Git Download: Http Error." },
{ code: -41, desc: "Git Download: Buffer Allocation Error." },
{ code: -42, desc: "Git Download: Download Connection Error." },
{ code: -43, desc: 'Git Download: Timeout Error.' }
]
document.oncontextmenu = (event) => {
if (event.target && event.target.tagName.toLowerCase() === 'input' && (event.target.type.toLowerCase() === 'text' || event.target.type.toLowerCase() === 'password'))
return;
else {
event.preventDefault(); event.stopPropagation(); return false;
}
};
Date.prototype.toJSON = function () {
let tz = this.getTimezoneOffset();
let sign = tz > 0 ? '-' : '+';
let tzHrs = Math.floor(Math.abs(tz) / 60).fmt('00');
let tzMin = (Math.abs(tz) % 60).fmt('00');
return `${this.getFullYear()}-${(this.getMonth() + 1).fmt('00')}-${this.getDate().fmt('00')}T${this.getHours().fmt('00')}:${this.getMinutes().fmt('00')}:${this.getSeconds().fmt('00')}.${this.getMilliseconds().fmt('000')}${sign}${tzHrs}${tzMin}`;
};
Date.prototype.fmt = function (fmtMask, emptyMask) {
if (fmtMask.match(/[hHmt]/g) !== null) { if (this.isDateTimeEmpty()) return typeof emptyMask !== 'undefined' ? emptyMask : ''; }
if (fmtMask.match(/[Mdy]/g) !== null) { if (this.isDateEmpty()) return typeof emptyMask !== 'undefined' ? emptyMask : ''; }
let formatted = typeof fmtMask !== 'undefined' && fmtMask !== null ? fmtMask : 'MM-dd-yyyy HH:mm:ss';
let letters = 'dMyHhmst'.split('');
let temp = [];
let count = 0;
let regexA;
let regexB = /\[(\d+)\]/;
let year = this.getFullYear().toString();
let formats = {
d: this.getDate().toString(),
dd: this.getDate().toString().padStart(2, '00'),
ddd: this.getDay() >= 0 ? formatType.DAYS[this.getDay()].substring(0, 3) : '',
dddd: this.getDay() >= 0 ? formatType.DAYS[this.getDay()] : '',
M: (this.getMonth() + 1).toString(),
MM: (this.getMonth() + 1).toString().padStart(2, '00'),
MMM: this.getMonth() >= 0 ? formatType.MONTHS[this.getMonth()].substring(0, 3) : '',
MMMM: this.getMonth() >= 0 ? formatType.MONTHS[this.getMonth()] : '',
y: year.charAt(2) === '0' ? year.charAt(4) : year.substring(2, 4),
yy: year.substring(2, 4),
yyyy: year,
H: this.getHours().toString(),
HH: this.getHours().toString().padStart(2, '00'),
h: this.getHours() === 0 ? '12' : this.getHours() > 12 ? Math.abs(this.getHours() - 12).toString() : this.getHours().toString(),
hh: this.getHours() === 0 ? '12' : this.getHours() > 12 ? Math.abs(this.getHours() - 12).toString().padStart(2, '00') : this.getHours().toString().padStart(2, '00'),
m: this.getMinutes().toString(),
mm: this.getMinutes().toString().padStart(2, '00'),
s: this.getSeconds().toString(),
ss: this.getSeconds().toString().padStart(2, '00'),
t: this.getHours() < 12 || this.getHours() === 24 ? 'a' : 'p',
tt: this.getHours() < 12 || this.getHours() === 24 ? 'am' : 'pm'
};
for (let i = 0; i < letters.length; i++) {
regexA = new RegExp('(' + letters[i] + '+)');
while (regexA.test(formatted)) {
temp[count] = RegExp.$1;
formatted = formatted.replace(RegExp.$1, '[' + count + ']');
count++;
}
}
while (regexB.test(formatted))
formatted = formatted.replace(regexB, formats[temp[RegExp.$1]]);
//console.log({ formatted: formatted, fmtMask: fmtMask });
return formatted;
};
Number.prototype.round = function (dec) { return Number(Math.round(this + 'e' + dec) + 'e-' + dec); };
Number.prototype.fmt = function (format, empty) {
if (isNaN(this)) return empty || '';
if (typeof format === 'undefined') return this.toString();
let isNegative = this < 0;
let tok = ['#', '0'];
let pfx = '', sfx = '', fmt = format.replace(/[^#\.0\,]/g, '');
let dec = fmt.lastIndexOf('.') > 0 ? fmt.length - (fmt.lastIndexOf('.') + 1) : 0,
fw = '', fd = '', vw = '', vd = '', rw = '', rd = '';
let val = String(Math.abs(this).round(dec));
let ret = '', commaChar = ',', decChar = '.';
for (var i = 0; i < format.length; i++) {
let c = format.charAt(i);
if (c === '#' || c === '0' || c === '.' || c === ',')
break;
pfx += c;
}
for (let i = format.length - 1; i >= 0; i--) {
let c = format.charAt(i);
if (c === '#' || c === '0' || c === '.' || c === ',')
break;
sfx = c + sfx;
}
if (dec > 0) {
let dp = val.lastIndexOf('.');
if (dp === -1) {
val += '.'; dp = 0;
}
else
dp = val.length - (dp + 1);
while (dp < dec) {
val += '0';
dp++;
}
fw = fmt.substring(0, fmt.lastIndexOf('.'));
fd = fmt.substring(fmt.lastIndexOf('.') + 1);
vw = val.substring(0, val.lastIndexOf('.'));
vd = val.substring(val.lastIndexOf('.') + 1);
let ds = val.substring(val.lastIndexOf('.'), val.length);
for (let i = 0; i < fd.length; i++) {
if (fd.charAt(i) === '#' && vd.charAt(i) !== '0') {
rd += vd.charAt(i);
continue;
} else if (fd.charAt(i) === '#' && vd.charAt(i) === '0') {
var np = vd.substring(i);
if (np.match('[1-9]')) {
rd += vd.charAt(i);
continue;
}
else
break;
}
else if (fd.charAt(i) === '0' || fd.charAt(i) === '#')
rd += vd.charAt(i);
}
if (rd.length > 0) rd = decChar + rd;
}
else {
fw = fmt;
vw = val;
}
var cg = fw.lastIndexOf(',') >= 0 ? fw.length - fw.lastIndexOf(',') - 1 : 0;
var nw = Math.abs(Math.floor(this.round(dec)));
if (!(nw === 0 && fw.substr(fw.length - 1) === '#') || fw.substr(fw.length - 1) === '0') {
var gc = 0;
for (let i = vw.length - 1; i >= 0; i--) {
rw = vw.charAt(i) + rw;
gc++;
if (gc === cg && i !== 0) {
rw = commaChar + rw;
gc = 0;
}
}
if (fw.length > rw.length) {
var pstart = fw.indexOf('0');
if (pstart >= 0) {
var plen = fw.length - pstart;
var pos = fw.length - rw.length - 1;
while (rw.length < plen) {
let pc = fw.charAt(pos);
if (pc === ',') pc = commaChar;
rw = pc + rw;
pos--;
}
}
}
}
if (isNegative) rw = '-' + rw;
if (rd.length === 0 && rw.length === 0) return '';
return pfx + rw + rd + sfx;
};
var baseUrl = window.location.protocol === 'file:' ? `http://${hst}` : '';
//var baseUrl = '';
function makeBool(val) {
if (typeof val === 'boolean') return val;
if (typeof val === 'undefined') return false;
if (typeof val === 'number') return val >= 1;
if (typeof val === 'string') {
if (val === '') return false;
switch (val.toLowerCase().trim()) {
case 'on':
case 'true':
case 'yes':
case 'y':
return true;
case 'off':
case 'false':
case 'no':
case 'n':
return false;
}
if (!isNaN(parseInt(val, 10))) return parseInt(val, 10) >= 1;
}
return false;
}
var httpStatusText = {
'200': 'OK',
'201': 'Created',
'202': 'Accepted',
'203': 'Non-Authoritative Information',
'204': 'No Content',
'205': 'Reset Content',
'206': 'Partial Content',
'300': 'Multiple Choices',
'301': 'Moved Permanently',
'302': 'Found',
'303': 'See Other',
'304': 'Not Modified',
'305': 'Use Proxy',
'306': 'Unused',
'307': 'Temporary Redirect',
'400': 'Bad Request',
'401': 'Unauthorized',
'402': 'Payment Required',
'403': 'Forbidden',
'404': 'Not Found',
'405': 'Method Not Allowed',
'406': 'Not Acceptable',
'407': 'Proxy Authentication Required',
'408': 'Request Timeout',
'409': 'Conflict',
'410': 'Gone',
'411': 'Length Required',
'412': 'Precondition Required',
'413': 'Request Entry Too Large',
'414': 'Request-URI Too Long',
'415': 'Unsupported Media Type',
'416': 'Requested Range Not Satisfiable',
'417': 'Expectation Failed',
'418': 'I\'m a teapot',
'429': 'Too Many Requests',
'500': 'Internal Server Error',
'501': 'Not Implemented',
'502': 'Bad Gateway',
'503': 'Service Unavailable',
'504': 'Gateway Timeout',
'505': 'HTTP Version Not Supported'
};
function getJSON(url, cb) {
let xhr = new XMLHttpRequest();
console.log({ get: url });
xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true);
xhr.setRequestHeader('apikey', security.apiKey);
xhr.responseType = 'json';
xhr.onload = () => {
let status = xhr.status;
if (status !== 200) {
let err = xhr.response || {};
err.htmlError = status;
err.service = `GET ${url}`;
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(xhr.response, null);
}
else {
cb(null, xhr.response);
}
};
xhr.onerror = (evt) => {
let err = {
htmlError: xhr.status || 500,
service: `GET ${url}`
};
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(err, null);
};
xhr.send();
}
function getJSONSync(url, cb) {
let overlay = ui.waitMessage(document.getElementById('divContainer'));
let xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.onload = () => {
let status = xhr.status;
if (status !== 200) {
let err = xhr.response || {};
err.htmlError = status;
err.service = `GET ${url}`;
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(xhr.response, null);
}
else {
console.log({ get: url, obj:xhr.response });
cb(null, xhr.response);
}
if (typeof overlay !== 'undefined') overlay.remove();
};
xhr.onerror = (evt) => {
let err = {
htmlError: xhr.status || 500,
service: `GET ${url}`
};
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(err, null);
if (typeof overlay !== 'undefined') overlay.remove();
};
xhr.onabort = (evt) => {
console.log('Aborted');
if (typeof overlay !== 'undefined') overlay.remove();
};
xhr.open('GET', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true);
xhr.setRequestHeader('apikey', security.apiKey);
xhr.send();
}
function putJSONSync(url, data, cb) {
let overlay = ui.waitMessage(document.getElementById('divContainer'));
try {
let xhr = new XMLHttpRequest();
console.log({ put: url, data: data });
//xhr.open('PUT', url, true);
xhr.open('PUT', baseUrl.length > 0 ? `${baseUrl}${url}` : url, true);
xhr.responseType = 'json';
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('apikey', security.apiKey);
xhr.onload = () => {
let status = xhr.status;
if (status !== 200) {
let err = xhr.response || {};
err.htmlError = status;
err.service = `PUT ${url}`;
err.data = data;
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(err, null);
}
else {
cb(null, xhr.response);
}
overlay.remove();
};
xhr.onerror = (evt) => {
console.log(xhr);
let err = {
htmlError: xhr.status || 500,
service: `PUT ${url}`
};
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
cb(err, null);
overlay.remove();
};
xhr.send(JSON.stringify(data));
} catch (err) { ui.serviceError(document.getElementById('divContainer'), err); }
}
// 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;
// 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));
});
send();
});
}
// 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();
general.init();
wifi.init();
somfy.init();
mqtt.init();
firmware.init();
}
class UIBinder {
setValue(el, val) {
if (el instanceof HTMLInputElement) {
switch (el.type.toLowerCase()) {
case 'checkbox':
el.checked = makeBool(val);
break;
case 'range':
let dt = el.getAttribute('data-datatype');
let mult = parseInt(el.getAttribute('data-mult') || 1, 10);
switch (dt) {
// We always range with integers
case 'float':
el.value = Math.round(parseInt(val * mult, 10));
break;
case 'index':
let ivals = JSON.parse(el.getAttribute('data-values'));
for (let i = 0; i < ivals.length; i++) {
if (ivals[i].toString() === val.toString()) {
el.value = i;
break;
}
}
break;
default:
el.value = parseInt(val, 10) * mult;
break;
}
break;
default:
el.value = val;
break;
}
}
else if (el instanceof HTMLSelectElement) {
let ndx = 0;
for (let i = 0; i < el.options.length; i++) {
let opt = el.options[i];
if (opt.value === val.toString()) {
ndx = i;
break;
}
}
el.selectedIndex = ndx;
}
else if (el instanceof HTMLElement) el.innerHTML = val;
}
getValue(el, defVal) {
let val = defVal;
if (el instanceof HTMLInputElement) {
switch (el.type.toLowerCase()) {
case 'checkbox':
val = el.checked;
break;
case 'range':
let dt = el.getAttribute('data-datatype');
let mult = parseInt(el.getAttribute('data-mult') || 1, 10);
switch (dt) {
// We always range with integers
case 'float':
val = parseInt(el.value, 10) / mult;
break;
case 'index':
let ivals = JSON.parse(el.getAttribute('data-values'));
val = ivals[parseInt(el.value, 10)];
break;
default:
val = parseInt(el.value / mult, 10);
break;
}
break;
default:
val = el.value;
break;
}
}
else if (el instanceof HTMLSelectElement) val = el.value;
else if (el instanceof HTMLElement) val = el.innerHTML;
return val;
}
toElement(el, val) {
let flds = el.querySelectorAll('*[data-bind]');
flds.forEach((fld) => {
let prop = fld.getAttribute('data-bind');
let arr = prop.split('.');
let tval = val;
for (let i = 0; i < arr.length; i++) {
var s = arr[i];
if (typeof s === 'undefined' || !s) continue;
let ndx = s.indexOf('[');
if (ndx !== -1) {
ndx = parseInt(s.substring(ndx + 1, s.indexOf(']') - 1), 10);
s = s.substring(0, ndx - 1);
}
tval = tval[s];
if (typeof tval === 'undefined') break;
if (ndx >= 0) tval = tval[ndx];
}
if (typeof tval !== 'undefined') {
if (typeof fld.val === 'function') this.val(tval);
else {
switch (fld.getAttribute('data-fmttype')) {
case 'time':
{
var dt = new Date();
dt.setHours(0, 0, 0);
dt.addMinutes(tval);
tval = dt.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || '');
}
break;
case 'date':
case 'datetime':
{
let dt = new Date(tval);
tval = dt.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || '');
}
break;
case 'number':
if (typeof tval !== 'number') tval = parseFloat(tval);
tval = tval.fmt(fld.getAttribute('data-fmtmask'), fld.getAttribute('data-fmtempty') || '');
break;
case 'duration':
tval = ui.formatDuration(tval, $this.attr('data-fmtmask'));
break;
}
this.setValue(fld, tval);
}
}
});
}
fromElement(el, obj, arrayRef) {
if (typeof arrayRef === 'undefined' || arrayRef === null) arrayRef = [];
if (typeof obj === 'undefined' || obj === null) obj = {};
if (typeof el.getAttribute('data-bind') !== 'undefined') this._bindValue(obj, el, this.getValue(el), arrayRef);
let flds = el.querySelectorAll('*[data-bind]');
flds.forEach((fld) => {
if (!makeBool(fld.getAttribute('data-setonly')))
this._bindValue(obj, fld, this.getValue(fld), arrayRef);
});
return obj;
}
parseNumber(val) {
if (val === null) return;
if (typeof val === 'undefined') return val;
if (typeof val === 'number') return val;
if (typeof val.getMonth === 'function') return val.getTime();
var tval = val.replace(/[^0-9\.\-]+/g, '');
return tval.indexOf('.') !== -1 ? parseFloat(tval) : parseInt(tval, 10);
}
_bindValue(obj, el, val, arrayRef) {
var binding = el.getAttribute('data-bind');
var dataType = el.getAttribute('data-datatype');
if (binding && binding.length > 0) {
var sRef = '';
var arr = binding.split('.');
var t = obj;
for (var i = 0; i < arr.length - 1; i++) {
let s = arr[i];
if (typeof s === 'undefined' || s.length === 0) continue;
sRef += '.' + s;
var ndx = s.lastIndexOf('[');
if (ndx !== -1) {
var v = s.substring(0, ndx);
var ndxEnd = s.lastIndexOf(']');
var ord = parseInt(s.substring(ndx + 1, ndxEnd), 10);
if (isNaN(ord)) ord = 0;
if (typeof arrayRef[sRef] === 'undefined') {
if (typeof t[v] === 'undefined') {
t[v] = new Array();
t[v].push(new Object());
t = t[v][0];
arrayRef[sRef] = ord;
}
else {
k = arrayRef[sRef];
if (typeof k === 'undefined') {
a = t[v];
k = a.length;
arrayRef[sRef] = k;
a.push(new Object());
t = a[k];
}
else
t = t[v][k];
}
}
else {
k = arrayRef[sRef];
if (typeof k === 'undefined') {
a = t[v];
k = a.length;
arrayRef[sRef] = k;
a.push(new Object());
t = a[k];
}
else
t = t[v][k];
}
}
else if (typeof t[s] === 'undefined') {
t[s] = new Object();
t = t[s];
}
else
t = t[s];
}
if (typeof dataType === 'undefined') dataType = 'string';
t[arr[arr.length - 1]] = this.parseValue(val, dataType);
}
}
parseValue(val, dataType) {
switch (dataType) {
case 'int':
return Math.floor(this.parseNumber(val));
case 'uint':
return Math.abs(this.parseNumber(val));
case 'float':
case 'real':
case 'double':
case 'decimal':
case 'number':
return this.parseNumber(val);
case 'date':
if (typeof val === 'string') return Date.parseISO(val);
else if (typeof val === 'number') return new Date(number);
else if (typeof val.getMonth === 'function') return val;
return undefined;
case 'time':
var dt = new Date();
if (typeof val === 'number') {
dt.setHours(0, 0, 0);
dt.addMinutes(tval);
return dt;
}
else if (typeof val === 'string' && val.indexOf(':') !== -1) {
var n = val.lastIndexOf(':');
var min = this.parseNumber(val.substring(n));
var nsp = val.substring(0, n).lastIndexOf(' ') + 1;
var hrs = this.parseNumber(val.substring(nsp, n));
dt.setHours(0, 0, 0);
if (hrs <= 12 && val.substring(n).indexOf('p')) hrs += 12;
dt.addMinutes(hrs * 60 + min);
return dt;
}
break;
case 'duration':
if (typeof val === 'number') return val;
return Math.floor(this.parseNumber(val));
default:
return val;
}
}
formatValue(val, dataType, fmtMask, emptyMask) {
var v = this.parseValue(val, dataType);
if (typeof v === 'undefined') return emptyMask || '';
switch (dataType) {
case 'int':
case 'uint':
case 'float':
case 'real':
case 'double':
case 'decimal':
case 'number':
return v.fmt(fmtMask, emptyMask || '');
case 'time':
case 'date':
case 'dateTime':
return v.fmt(fmtMask, emptyMask || '');
}
return v;
}
waitMessage(el) {
let div = document.createElement('div');
div.innerHTML = '
';
div.classList.add('wait-overlay');
if (typeof el === 'undefined') el = document.getElementById('divContainer');
el.appendChild(div);
return div;
}
serviceError(el, err) {
let title = 'Service Error'
if (arguments.length === 1) {
err = el;
el = document.getElementById('divContainer');
}
let msg = '';
if (typeof err === 'string' && err.startsWith('{')) {
let e = JSON.parse(err);
if (typeof e !== 'undefined' && typeof e.desc === 'string') msg = e.desc;
else msg = err;
}
else if (typeof err === 'string') msg = err;
else if (typeof err === 'number') {
switch (err) {
case 404:
msg = `404: Service not found`;
break;
default:
msg = `${err}: Service Error`;
break;
}
}
else if (typeof err !== 'undefined') {
if (typeof err.desc === 'string') {
msg = typeof err.desc !== 'undefined' ? err.desc : err.message;
if (typeof err.code === 'number') {
let e = errors.find(x => x.code === err.code) || { code: err.code, desc: 'Unspecified error' };
msg = e.desc;
title = err.desc;
}
}
}
console.log(err);
let div = this.errorMessage(`${err.htmlError || 500}:${title}`);
let sub = div.querySelector('.sub-message');
sub.innerHTML = `
${err.service}
${msg}
`;
return div;
}
socketError(el, msg) {
if (arguments.length === 1) {
msg = el;
el = document.getElementById('divContainer');
}
let div = document.createElement('div');
div.innerHTML = '
Attempts:
Could not connect to server
' + msg + '
';
div.classList.add('error-message');
div.classList.add('socket-error');
div.classList.add('message-overlay');
el.appendChild(div);
return div;
}
errorMessage(el, msg) {
if (arguments.length === 1) {
msg = el;
el = document.getElementById('divContainer');
}
let div = document.createElement('div');
div.innerHTML = '
' + msg + '
';
div.classList.add('error-message');
div.classList.add('message-overlay');
el.appendChild(div);
return div;
}
promptMessage(el, msg, onYes) {
if (arguments.length === 2) {
onYes = msg;
msg = el;
el = document.getElementById('divContainer');
}
let div = document.createElement('div');
div.innerHTML = '
' + msg + '
';
div.classList.add('prompt-message');
div.classList.add('message-overlay');
el.appendChild(div);
div.querySelector('#btnYes').addEventListener('click', onYes);
return div;
}
infoMessage(el, msg, onOk) {
if (arguments.length === 1) {
onOk = msg;
msg = el;
el = document.getElementById('divContainer');
}
let div = document.createElement('div');
div.innerHTML = '
' + msg + '
';
div.classList.add('info-message');
div.classList.add('message-overlay');
el.appendChild(div);
if (typeof onOk === 'function') div.querySelector('#btnOk').addEventListener('click', onOk);
else div.querySelector('#btnOk').addEventListener('click', (e) => { div.remove() });
//div.querySelector('#btnYes').addEventListener('click', onYes);
return div;
}
clearErrors() {
let errors = document.querySelectorAll('div.message-overlay');
if (errors && errors.length > 0) errors.forEach((el) => { el.remove(); });
}
selectTab(elTab) {
for (let tab of elTab.parentElement.children) {
if (tab.classList.contains('selected')) tab.classList.remove('selected');
document.getElementById(tab.getAttribute('data-grpid')).style.display = 'none';
}
if (!elTab.classList.contains('selected')) elTab.classList.add('selected');
document.getElementById(elTab.getAttribute('data-grpid')).style.display = '';
}
wizSetPrevStep(el) { this.wizSetStep(el, Math.max(this.wizCurrentStep(el) - 1, 1)); }
wizSetNextStep(el) { this.wizSetStep(el, this.wizCurrentStep(el) + 1); }
wizSetStep(el, step) {
let curr = this.wizCurrentStep(el);
let max = parseInt(el.getAttribute('data-maxsteps'), 10);
if (!isNaN(max)) {
let next = el.querySelector(`#btnNextStep`);
if (next) next.style.display = max < step ? 'inline-block' : 'none';
}
let prev = el.querySelector(`#btnPrevStep`);
if (prev) prev.style.display = step <= 1 ? 'none' : 'inline-block';
if (curr !== step) {
el.setAttribute('data-stepid', step);
let evt = new CustomEvent('stepchanged', { detail: { oldStep: curr, newStep: step }, bubbles: true, cancelable: true, composed: false });
el.dispatchEvent(evt);
}
}
wizCurrentStep(el) { return parseInt(el.getAttribute('data-stepid') || 1, 10); }
pinKeyPressed(evt) {
let parent = evt.srcElement.parentElement;
let digits = parent.querySelectorAll('.pin-digit');
switch (evt.key) {
case 'Backspace':
setTimeout(() => {
// Focus to the previous element.
for (let i = 0; i < digits.length; i++) {
if (digits[i] === evt.srcElement && i > 0) {
digits[i - 1].focus();
break;
}
}
}, 0);
return;
case 'ArrowLeft':
setTimeout(() => {
for (let i = 0; i < digits.length; i++) {
if (digits[i] === evt.srcElement && i > 0) {
digits[i - 1].focus();
}
}
});
return;
case 'CapsLock':
case 'Control':
case 'Shift':
case 'Enter':
case 'Tab':
return;
case 'ArrowRight':
if (evt.srcElement.value !== '') {
setTimeout(() => {
for (let i = 0; i < digits.length; i++) {
if (digits[i] === evt.srcElement && i < digits.length - 1) {
digits[i + 1].focus();
}
}
});
}
return;
default:
if (evt.srcElement.value !== '') evt.srcElement.value = '';
setTimeout(() => {
let e = new CustomEvent('digitentered', { detail: {}, bubbles: true, cancelable: true, composed: false });
evt.srcElement.dispatchEvent(e);
}, 100);
break;
}
setTimeout(() => {
// Focus to the first empty element.
for (let i = 0; i < digits.length; i++) {
if (digits[i].value === '') {
if (digits[i] !== evt.srcElement) digits[i].focus();
break;
}
}
}, 0);
}
pinDigitFocus(evt) {
// Find the first empty digit and place the cursor there.
if (evt.srcElement.value !== '') return;
let parent = evt.srcElement.parentElement;
let digits = parent.querySelectorAll('.pin-digit');
for (let i = 0; i < digits.length; i++) {
if (digits[i].value === '') {
if (digits[i] !== evt.srcElement) digits[i].focus();
break;
}
}
}
isConfigOpen() { return window.getComputedStyle(document.getElementById('divConfigPnl')).display !== 'none'; }
setConfigPanel() {
if (this.isConfigOpen()) return;
let divCfg = document.getElementById('divConfigPnl');
let divHome = document.getElementById('divHomePnl');
divHome.style.display = 'none';
divCfg.style.display = '';
document.getElementById('icoConfig').className = 'icss-home';
subscribeConfigFeeds(true);
general.loadSecurity();
}
setHomePanel() {
if (!this.isConfigOpen()) return;
let divCfg = document.getElementById('divConfigPnl');
let divHome = document.getElementById('divHomePnl');
divHome.style.display = '';
divCfg.style.display = 'none';
document.getElementById('icoConfig').className = 'icss-gear';
subscribeConfigFeeds(false);
general.setSecurityConfig({ securityType: 0, username: '' });
}
toggleConfig() {
if (this.isConfigOpen())
this.setHomePanel();
else {
if (!security.authenticated && security.type !== 0) {
document.getElementById('divContainer').addEventListener('afterlogin', (evt) => {
if (security.authenticated) this.setConfigPanel();
}, { once: true });
security.authUser();
}
else this.setConfigPanel();
}
somfy.showEditShade(false);
somfy.showEditGroup(false);
}
}
var ui = new UIBinder();
class Security {
type = 0;
authenticated = false;
apiKey = '';
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() {
document.getElementById('divUnauthenticated').querySelector('.pin-digit[data-bind="login.pin.d3"]').addEventListener('digitentered', (evt) => {
security.login();
});
await this.loadContext();
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';
pnl.querySelector('#divLoginPassword').style.display = 'none';
pnl.querySelector('#divLoginPin').style.display = 'none';
await new Promise((resolve, reject) => {
getJSONSync('/loginContext', (err, ctx) => {
pnl.querySelector('#loginButtons').style.display = '';
resolve();
if (err) ui.serviceError(err);
else {
console.log(ctx);
document.getElementById('divContainer').setAttribute('data-securitytype', ctx.type);
this.type = ctx.type;
switch (ctx.type) {
case 1:
pnl.querySelector('#divLoginPin').style.display = '';
pnl.querySelector('#divLoginPassword').style.display = 'none';
pnl.querySelector('.pin-digit[data-bind="login.pin.d0"]').focus();
break;
case 2:
pnl.querySelector('#divLoginPassword').style.display = '';
pnl.querySelector('#divLoginPin').style.display = 'none';
pnl.querySelector('#fldLoginUsername').focus();
break;
}
pnl.querySelector('#fldLoginType').value = ctx.type;
}
});
});
}
authUser() {
document.getElementById('divAuthenticated').style.display = 'none';
document.getElementById('divUnauthenticated').style.display = '';
this.loadContext();
document.getElementById('btnCancelLogin').style.display = 'inline-block';
}
cancelLogin() {
let evt = new CustomEvent('afterlogin', { detail: { authenticated: this.authenticated } });
document.getElementById('divAuthenticated').style.display = '';
document.getElementById('divUnauthenticated').style.display = 'none';
document.getElementById('divContainer').dispatchEvent(evt);
}
login(evt) {
if (evt && evt.preventDefault) evt.preventDefault();
console.log('Logging in...');
let pnl = document.getElementById('divUnauthenticated');
let msg = pnl.querySelector('#spanLoginMessage');
msg.innerHTML = '';
let sec = ui.fromElement(pnl).login;
console.log(sec);
let pin = '';
switch (sec.type) {
case 1:
for (let i = 0; i < 4; i++) {
pin += sec.pin[`d${i}`];
}
if (pin.length !== 4) return false;
break;
case 2:
break;
}
sec.pin = pin;
putJSONSync('/login', sec, (err, log) => {
// Wrong credentials (401) or too many of them (429) say why in
// the panel, as an old 200 with success false did.
if (err && (err.htmlError === 401 || err.htmlError === 429) && err.msg) msg.innerHTML = err.msg;
else if (err) ui.serviceError(err);
else {
console.log(log);
if (log.success) {
if (sec.type === 2 && window.PasswordCredential && navigator.credentials) {
try {
const cred = new PasswordCredential({
id: sec.username,
password: sec.password,
name: sec.username
});
navigator.credentials.store(cred);
} catch (e) { /* ignore; browsers without support fall back to the form-submit heuristic */ }
}
this.setToken(log.apiKey);
this.authenticated = true;
this.showApp();
startSocket();
let evt2 = new CustomEvent('afterlogin', { detail: { authenticated: true } });
document.getElementById('divContainer').dispatchEvent(evt2);
}
else
msg.innerHTML = log.msg;
}
});
return false;
}
}
var security = new Security();
// let appVersion = 'v4.0.0.beta'; // Default placeholder
async function getAppVersion() {
try {
const response = await fetch('/appversion?v='+Date.now());
if (!response.ok) throw new Error('File not found');
const data = await response.text();
appVersion = `v${data.trim()}`;
console.log("Loaded Version:", appVersion);
// Trigger any UI updates here
} catch (error) {
console.error("Error loading App version:", error);
appVersion = 'v4.0.0.beta'; // Default placeholder
}
return appVersion;
}
class General {
initialized = false;
appVersion = '';
reloadApp = false;
init() {
if (this.initialized) return;
this.setAppVersion();
this.setTimeZones();
ui.toElement(document.getElementById('divSystemSettings'), { general: { hostname: 'ESPSomfyRTS', username: '', password: '', posixZone: 'UTC0', ntpServer: 'pool.ntp.org' } });
this.initialized = true;
}
getCookie(cname) {
let n = cname + '=';
let cookies = document.cookie.split(';');
console.log(cookies);
for (let i = 0; i < cookies.length; i++) {
let c = cookies[i];
while (c.charAt(0) === ' ') c = c.substring(0);
if (c.indexOf(n) === 0) return c.substring(n.length, c.length);
}
return '';
}
reload() {
let addMetaTag = (name, content) => {
let meta = document.createElement('meta');
meta.httpEquiv = name;
meta.content = content;
document.getElementsByTagName('head')[0].appendChild(meta);
};
addMetaTag('pragma', 'no-cache');
addMetaTag('expires', '0');
addMetaTag('cache-control', 'no-cache');
document.location.reload();
}
timeZones = [
{ city: 'Africa/Cairo', code: 'EET-2' },
{ city: 'Africa/Johannesburg', code: 'SAST-2' },
{ city: 'Africa/Juba', code: 'CAT-2' },
{ city: 'Africa/Lagos', code: 'WAT-1' },
{ city: 'Africa/Mogadishu', code: 'EAT-3' },
{ city: 'Africa/Tunis', code: 'CET-1' },
{ city: 'America/Adak', code: 'HST10HDT,M3.2.0,M11.1.0' },
{ city: 'America/Anchorage', code: 'AKST9AKDT,M3.2.0,M11.1.0' },
{ city: 'America/Asuncion', code: '<-04>4<-03>,M10.1.0/0,M3.4.0/0' },
{ city: 'America/Bahia_Banderas', code: 'CST6CDT,M4.1.0,M10.5.0' },
{ city: 'America/Barbados', code: 'AST4' },
{ city: 'America/Bermuda', code: 'AST4ADT,M3.2.0,M11.1.0' },
{ city: 'America/Cancun', code: 'EST5' },
{ city: 'America/Central_Time', code: 'CST6CDT,M3.2.0,M11.1.0' },
{ city: 'America/Chihuahua', code: 'MST7MDT,M4.1.0,M10.5.0' },
{ city: 'America/Eastern_Time', code: 'EST5EDT,M3.2.0,M11.1.0' },
{ city: 'America/Godthab', code: '<-03>3<-02>,M3.5.0/-2,M10.5.0/-1' },
{ city: 'America/Havana', code: 'CST5CDT,M3.2.0/0,M11.1.0/1' },
{ city: 'America/Mexico_City', code: 'CST6' },
{ city: 'America/Miquelon', code: '<-03>3<-02>,M3.2.0,M11.1.0' },
{ city: 'America/Mountain_Time', code: 'MST7MDT,M3.2.0,M11.1.0' },
{ city: 'America/Pacific_Time', code: 'PST8PDT,M3.2.0,M11.1.0' },
{ city: 'America/Phoenix', code: 'MST7' },
{ city: 'America/Santiago', code: '<-04>4<-03>,M9.1.6/24,M4.1.6/24' },
{ city: 'America/St_Johns', code: 'NST3:30NDT,M3.2.0,M11.1.0' },
{ city: 'Antarctica/Troll', code: '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3' },
{ city: 'Asia/Amman', code: 'EET-2EEST,M2.5.4/24,M10.5.5/1' },
{ city: 'Asia/Beirut', code: 'EET-2EEST,M3.5.0/0,M10.5.0/0' },
{ city: 'Asia/Colombo', code: '<+0530>-5:30' },
{ city: 'Asia/Damascus', code: 'EET-2EEST,M3.5.5/0,M10.5.5/0' },
{ city: 'Asia/Gaza', code: 'EET-2EEST,M3.4.4/50,M10.4.4/50' },
{ city: 'Asia/Hong_Kong', code: 'HKT-8' },
{ city: 'Asia/Jakarta', code: 'WIB-7' },
{ city: 'Asia/Jayapura', code: 'WIT-9' },
{ city: 'Asia/Jerusalem', code: 'IST-2IDT,M3.4.4/26,M10.5.0' },
{ city: 'Asia/Kabul', code: '<+0430>-4:30' },
{ city: 'Asia/Karachi', code: 'PKT-5' },
{ city: 'Asia/Kathmandu', code: '<+0545>-5:45' },
{ city: 'Asia/Kolkata', code: 'IST-5:30' },
{ city: 'Asia/Makassar', code: 'WITA-8' },
{ city: 'Asia/Manila', code: 'PST-8' },
{ city: 'Asia/Seoul', code: 'KST-9' },
{ city: 'Asia/Shanghai', code: 'CST-8' },
{ city: 'Asia/Tehran', code: '<+0330>-3:30' },
{ city: 'Asia/Tokyo', code: 'JST-9' },
{ city: 'Atlantic/Azores', code: '<-01>1<+00>,M3.5.0/0,M10.5.0/1' },
{ city: 'Australia/Adelaide', code: 'ACST-9:30ACDT,M10.1.0,M4.1.0/3' },
{ city: 'Australia/Brisbane', code: 'AEST-10' },
{ city: 'Australia/Darwin', code: 'ACST-9:30' },
{ city: 'Australia/Eucla', code: '<+0845>-8:45' },
{ city: 'Australia/Lord_Howe', code: '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0' },
{ city: 'Australia/Melbourne', code: 'AEST-10AEDT,M10.1.0,M4.1.0/3' },
{ city: 'Australia/Perth', code: 'AWST-8' },
{ city: 'Etc/GMT-1', code: '<+01>-1' },
{ city: 'Etc/GMT-2', code: '<+02>-2' },
{ city: 'Etc/GMT-3', code: '<+03>-3' },
{ city: 'Etc/GMT-4', code: '<+04>-4' },
{ city: 'Etc/GMT-5', code: '<+05>-5' },
{ city: 'Etc/GMT-6', code: '<+06>-6' },
{ city: 'Etc/GMT-7', code: '<+07>-7' },
{ city: 'Etc/GMT-8', code: '<+08>-8' },
{ city: 'Etc/GMT-9', code: '<+09>-9' },
{ city: 'Etc/GMT-10',code: '<+10>-10' },
{ city: 'Etc/GMT-11', code: '<+11>-11' },
{ city: 'Etc/GMT-12', code: '<+12>-12' },
{ city: 'Etc/GMT-13', code: '<+13>-13' },
{ city: 'Etc/GMT-14', code: '<+14>-14' },
{ city: 'Etc/GMT+0', code: 'GMT0' },
{ city: 'Etc/GMT+1', code: '<-01>1' },
{ city: 'Etc/GMT+2', code: '<-02>2' },
{ city: 'Etc/GMT+3', code: '<-03>3' },
{ city: 'Etc/GMT+4', code: '<-04>4' },
{ city: 'Etc/GMT+5', code: '<-05>5' },
{ city: 'Etc/GMT+6', code: '<-06>6' },
{ city: 'Etc/GMT+7', code: '<-07>7' },
{ city: 'Etc/GMT+8', code: '<-08>8' },
{ city: 'Etc/GMT+9', code: '<-09>9' },
{ city: 'Etc/GMT+10', code: '<-10>10' },
{ city: 'Etc/GMT+11', code: '<-11>11' },
{ city: 'Etc/GMT+12', code: '<-12>12' },
{ city: 'Etc/UTC', code: 'UTC0' },
{ city: 'Europe/Athens', code: 'EET-2EEST,M3.5.0/3,M10.5.0/4' },
{ city: "Europe/Berlin", code: "CEST-1CET,M3.2.0/2:00:00,M11.1.0/2:00:00" },
{ city: 'Europe/Brussels', code: 'CET-1CEST,M3.5.0,M10.5.0/3' },
{ city: 'Europe/Chisinau', code: 'EET-2EEST,M3.5.0,M10.5.0/3' },
{ city: 'Europe/Dublin', code: 'IST-1GMT0,M10.5.0,M3.5.0/1' },
{ city: 'Europe/Lisbon', code: 'WET0WEST,M3.5.0/1,M10.5.0' },
{ city: 'Europe/London', code: 'GMT0BST,M3.5.0/1,M10.5.0' },
{ city: 'Europe/Moscow', code: 'MSK-3' },
{ city: 'Europe/Paris', code: 'CET-1CEST-2,M3.5.0/02:00:00,M10.5.0/03:00:00' },
{ city: 'Indian/Cocos', code: '<+0630>-6:30' },
{ city: 'Pacific/Auckland', code: 'NZST-12NZDT,M9.5.0,M4.1.0/3' },
{ city: 'Pacific/Chatham', code: '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45' },
{ city: 'Pacific/Easter', code: '<-06>6<-05>,M9.1.6/22,M4.1.6/22' },
{ city: 'Pacific/Fiji', code: '<+12>-12<+13>,M11.2.0,M1.2.3/99' },
{ city: 'Pacific/Guam', code: 'ChST-10' },
{ city: 'Pacific/Honolulu', code: 'HST10' },
{ city: 'Pacific/Marquesas', code: '<-0930>9:30' },
{ city: 'Pacific/Midway', code: 'SST11' },
{ city: 'Pacific/Norfolk', code: '<+11>-11<+12>,M10.1.0,M4.1.0/3' }
];
loadGeneral() {
let pnl = document.getElementById('divSystemOptions');
// 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) });
});
}
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 general.setSecurityConfig(ProtoViews.settings(proto.schema, 'security', security));
});
}
async setAppVersion() {
this.appVersion = await getAppVersion();
document.getElementById('spanAppVersion').innerText = this.appVersion;
}
setTimeZones() {
let dd = document.getElementById('selTimeZone');
dd.length = 0;
let maxLength = 0;
for (let i = 0; i < this.timeZones.length; i++) {
let opt = document.createElement('option');
opt.text = this.timeZones[i].city;
opt.value = this.timeZones[i].code;
maxLength = Math.max(maxLength, this.timeZones[i].code.length);
dd.add(opt);
}
dd.value = 'UTC0';
console.log(`Max TZ:${maxLength}`);
}
setGeneral() {
let valid = true;
let pnl = document.getElementById('divSystemSettings');
let obj = ui.fromElement(pnl).general;
if (typeof obj.hostname === 'undefined' || !obj.hostname || obj.hostname === '') {
ui.errorMessage('Invalid Host Name').querySelector('.sub-message').innerHTML = 'You must supply a valid Host Name.';
valid = false;
}
if (valid && !/^[a-zA-Z0-9-]+$/.test(obj.hostname)) {
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 > 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) {
ui.errorMessage('Invalid NTP Server').querySelector('.sub-message').innerHTML = 'The maximum NTP Server length is 64 characters.';
valid = false;
}
if (valid) {
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.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.securityType, username: security.username, password: '', repeatpassword: '',
pin: { d0: '', d1: '', d2: '', d3: '' }
}
};
let pnl = document.getElementById('divSecurityOptions');
ui.toElement(pnl, obj);
let keepHint = 'Saved (leave blank to keep)';
pnl.querySelector('#fldPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Password';
pnl.querySelector('#fldReenterPassword').placeholder = this.savedSecurity.hasPassword ? keepHint : 'Re-enter Password';
pnl.querySelector('label[for="fldPinEntry"]').innerHTML = this.savedSecurity.hasPin ? 'Enter Pin (leave blank to keep)' : 'Enter Pin';
this.onSecurityTypeChanged();
}
rebootDevice() {
ui.promptMessage(document.getElementById('divContainer'), 'Are you sure you want to reboot the device?', () => {
sendOp('reboot', {}, (err, response) => {
document.getElementById('btnSaveGeneral').classList.remove('disabled');
if (err) ui.serviceError(err);
});
ui.clearErrors();
});
}
onSecurityTypeChanged() {
let pnl = document.getElementById('divSecurityOptions');
let sec = ui.fromElement(pnl).security;
switch (sec.type) {
case 0:
pnl.querySelector('#divPinSecurity').style.display = 'none';
pnl.querySelector('#divPasswordSecurity').style.display = 'none';
break;
case 1:
pnl.querySelector('#divPinSecurity').style.display = '';
pnl.querySelector('#divPasswordSecurity').style.display = 'none';
break;
case 2:
pnl.querySelector('#divPinSecurity').style.display = 'none';
pnl.querySelector('#divPasswordSecurity').style.display = '';
break;
}
}
saveSecurity() {
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 += form.pin[`d${i}`];
}
// Blank secret fields keep the stored value while the type (and username) stay the same.
let saved = this.savedSecurity || {};
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 (form.type !== 1 || keepPin) delete sec.pin;
if (form.type !== 2 || keepPassword) delete sec.password;
let confirm = '';
console.log(sec);
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.';
return;
}
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 (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;
}
if (sec.username.length > 32) {
ui.errorMessage('Invalid Username').querySelector('.sub-message').innerHTML = 'The maximum username length is 32 characters.';
return;
}
}
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;
}
if (sec.password.length > 32) {
ui.errorMessage('Invalid Password').querySelector('.sub-message').innerHTML = 'The maximum password length is 32 characters.';
return;
}
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 !== 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', () => {
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);
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;
}
}
var general = new General();
class Wifi {
initialized = false;
ethBoardTypes = [{ val: 0, label: 'Custom Config' },
{ val: 7, label: 'EST-PoE-32 - Everything Smart', clk: 3, ct: 0, addr: 0, pwr: 12, mdc: 23, mdio: 18 },
{ val: 3, label: 'ESP32-EVB - Olimex', clk: 0, ct: 0, addr: 0, pwr: -1, mdc: 23, mdio: 18 },
{ val: 2, label: 'ESP32-POE - Olimex', clk: 3, ct: 0, addr: 0, pwr: 12, mdc: 23, mdio: 18 },
{ val: 4, label: 'T-Internet POE - LILYGO', clk: 3, ct: 0, addr: 0, pwr: 16, mdc: 23, mdio: 18 },
{ val: 5, label: 'wESP32 v7+ - Silicognition', clk: 0, ct: 2, addr: 0, pwr: -1, mdc: 16, mdio: 17 },
{ val: 6, label: 'wESP32 < v7 - Silicognition', clk: 0, ct: 0, addr: 0, pwr: -1, mdc: 16, mdio: 17 },
{ val: 1, label: 'WT32-ETH01 - Wireless Tag', clk: 0, ct: 0, addr: 1, pwr: 16, mdc: 23, mdio: 18 },
{ val: 8, label: 'ESP32-P4-WIFI6-POE-ETH - Waveshare', clk: 0, ct: 1, addr: 1, pwr: 51, mdc: 31, mdio: 52, p4: true }
];
ethClockModes = [{ val: 0, label: 'GPIO0 IN' }, { val: 1, label: 'GPIO0 OUT' }, { val: 2, label: 'GPIO16 OUT' }, { val: 3, label: 'GPIO17 OUT' }];
// The P4 takes the clock in on GPIO 50 only (eth_clock_id_t in EthPhy.h).
ethClockModesP4 = [{ val: 0, label: 'GPIO50 IN' }];
// The ids the firmware stores (eth_phy_id_t in EthPhy.h). 4 is the DM9051, an SPI chip the ESP32's MAC can't drive.
ethPhyTypes = [{ val: 0, label: 'LAN8720' }, { val: 1, label: 'TLK110' }, { val: 2, label: 'RTL8201' }, { val: 3, label: 'DP83848' }, { val: 5, label: 'KSZ8041' }, { val: 6, label: 'KSZ8081' }];
init() {
document.getElementById("divNetworkStrength").innerHTML = this.displaySignal(-100);
if (this.initialized) return;
let addr = [];
this.loadETHDropdown(document.getElementById('selETHPhyType'), this.ethPhyTypes);
for (let i = 0; i < 32; i++) addr.push({ val: i, label: `PHY ${i}` });
this.loadETHDropdown(document.getElementById('selETHAddress'), addr);
this.loadETHOptions();
ui.toElement(document.getElementById('divNetAdapter'), {
wifi: {ssid:'', passphrase:''},
ethernet: { boardType: 1, wirelessFallback: false, dhcp: true, dns1: '', dns2: '', ip: '', gateway: '' }
});
this.onETHBoardTypeChanged(document.getElementById('selETHBoardType'));
this.initialized = true;
}
isP4() { return (document.getElementById('divContainer').getAttribute('data-chipmodel') || '').startsWith('p4'); }
clockModes() { return this.isP4() ? this.ethClockModesP4 : this.ethClockModes; }
// The board presets, clock modes and pins of the chip: the ESP32's or the
// P4's. The chip comes with the sync, after init().
loadETHOptions() {
let p4 = this.isP4();
this.loadETHDropdown(document.getElementById('selETHClkMode'), this.clockModes());
this.loadETHDropdown(document.getElementById('selETHBoardType'), this.ethBoardTypes.filter(b => b.val === 0 || (b.p4 === true) === p4));
let maxPin = p4 ? 54 : 35;
this.loadETHPins(document.getElementById('selETHPWRPin'), 'power', undefined, maxPin);
this.loadETHPins(document.getElementById('selETHMDCPin'), 'mdc', p4 ? 31 : 23, maxPin);
this.loadETHPins(document.getElementById('selETHMDIOPin'), 'mdio', p4 ? 52 : 18, maxPin);
}
loadETHPins(sel, type, selected, maxPin) {
let arr = [];
switch (type) {
case 'power':
arr.push({ val: -1, label: 'None' });
break;
}
for (let i = 0; i <= maxPin; i++) {
arr.push({ val: i, label: `GPIO ${i}` });
}
this.loadETHDropdown(sel, arr, selected);
}
loadETHDropdown(sel, arr, selected) {
while (sel.firstChild) sel.removeChild(sel.firstChild);
for (let i = 0; i < arr.length; i++) {
let elem = arr[i];
sel.options[sel.options.length] = new Option(elem.label, elem.val, elem.val === selected, elem.val === selected);
}
}
onETHBoardTypeChanged(sel) {
let type = this.ethBoardTypes.find(elem => parseInt(sel.value, 10) === elem.val);
if (typeof type !== 'undefined') {
// Change the values to represent what the board type says.
if(typeof type.ct !== 'undefined') document.getElementById('selETHPhyType').value = type.ct;
if (typeof type.clk !== 'undefined') document.getElementById('selETHClkMode').value = type.clk;
if (typeof type.addr !== 'undefined') document.getElementById('selETHAddress').value = type.addr;
if (typeof type.pwr !== 'undefined') document.getElementById('selETHPWRPin').value = type.pwr;
if (typeof type.mdc !== 'undefined') document.getElementById('selETHMDCPin').value = type.mdc;
if (typeof type.mdio !== 'undefined') document.getElementById('selETHMDIOPin').value = type.mdio;
document.getElementById('divETHSettings').style.display = type.val === 0 ? '' : 'none';
}
}
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');
sendOp('getSettings', { section: 'network' }, (err, reply) => {
if (err) {
ui.serviceError(err);
}
else {
let settings = ProtoViews.network(proto.schema, reply);
console.log(settings);
this.loadETHOptions();
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;
ui.toElement(pnl, settings);
// The device never returns the stored passphrase; leave the field blank to keep it.
this.savedSsid = settings.wifi.ssid;
let fldPassphrase = document.getElementById('fldPassphrase');
fldPassphrase.value = '';
fldPassphrase.placeholder = settings.wifi.hasPassphrase ? 'Saved (leave blank to keep)' : 'Passphrase';
/*
if (settings.connType >= 2) {
document.getElementById('divWiFiMode').style.display = 'none';
document.getElementById('divEthernetMode').style.display = '';
document.getElementById('divRoaming').style.display = 'none';
document.getElementById('divFallbackWireless').style.display = 'inline-block';
}
else {
document.getElementById('divWiFiMode').style.display = '';
document.getElementById('divEthernetMode').style.display = 'none';
document.getElementById('divFallbackWireless').style.display = 'none';
document.getElementById('divRoaming').style.display = 'inline-block';
}
*/
ui.toElement(document.getElementById('divDHCP'), settings);
document.getElementById('divETHSettings').style.display = settings.ethernet.boardType === 0 ? '' : 'none';
document.getElementById('divStaticIP').style.display = settings.ip.dhcp ? 'none' : '';
document.getElementById('spanCurrentIP').innerHTML = settings.ip.ip;
this.useEthernetClicked();
this.hiddenSSIDClicked();
}
});
}
useEthernetClicked() {
let useEthernet = document.getElementById('cbHardwired').checked;
document.getElementById('divWiFiMode').style.display = useEthernet ? 'none' : '';
document.getElementById('divEthernetMode').style.display = useEthernet ? '' : 'none';
document.getElementById('divFallbackWireless').style.display = useEthernet ? 'inline-block' : 'none';
document.getElementById('divRoaming').style.display = useEthernet ? 'none' : 'inline-block';
document.getElementById('divHiddenSSID').style.display = useEthernet ? 'none' : 'inline-block';
}
hiddenSSIDClicked() {
let hidden = document.getElementById('cbHiddenSSID').checked;
if (hidden) document.getElementById('cbRoaming').checked = false;
document.getElementById('cbRoaming').disabled = hidden;
}
async loadAPs() {
if (document.getElementById('btnScanAPs').classList.contains('disabled')) return;
document.getElementById('divAps').innerHTML = '
';
document.getElementById('btnScanAPs').classList.add('disabled');
let done = (networks) => {
document.getElementById('btnScanAPs').classList.remove('disabled');
this.displayAPs({ accessPoints: networks.map((n) => ({ name: n.ssid, channel: n.channel || 0, strength: n.strength || 0, macAddress: n.macAddress || '' })) });
};
// The scan takes a few seconds; its networks come as an event.
sendOp('scanWifi', {}, (err) => {
if (err) done([]);
else proto.waitFor('wifiNetworks', null, 20000).then((d) => done(d && d.networks ? d.networks : []));
});
}
displayAPs(aps) {
let div = '';
let nets = [];
for (let i = 0; i < aps.accessPoints.length; i++) {
let ap = aps.accessPoints[i];
let p = nets.find(elem => elem.name === ap.name);
if (typeof p !== 'undefined' && p) {
p.channel = p.strength > ap.strength ? p.channel : ap.channel;
p.macAddress = p.strength > ap.strength ? p.macAddress : ap.macAddress;
p.strength = Math.max(p.strength, ap.strength);
}
else
nets.push(ap);
}
// Sort by the best signal strength.
nets.sort((a, b) => b.strength - a.strength);
for (let i = 0; i < nets.length; i++) {
let ap = nets[i];
div += `
${ap.name}${this.displaySignal(ap.strength)}
`;
}
let divAps = document.getElementById('divAps');
divAps.setAttribute('data-lastloaded', new Date().getTime());
divAps.innerHTML = div;
//document.getElementsByName('ssid')[0].value = aps.connected.name;
//document.getElementsByName('passphrase')[0].value = aps.connected.passphrase;
//this.procWifiStrength(aps.connected);
}
selectSSID(el) {
let obj = {
name: el.querySelector('span.ssid').innerHTML,
encryption: el.getAttribute('data-encryption'),
strength: parseInt(el.getAttribute('data-strength'), 10),
channel: parseInt(el.getAttribute('data-channel'), 10)
};
console.log(obj);
document.getElementsByName('ssid')[0].value = obj.name;
}
calcWaveStrength(sig) {
let wave = 0;
if (sig > -90) wave++;
if (sig > -80) wave++;
if (sig > -70) wave++;
if (sig > -67) wave++;
if (sig > -30) wave++;
return wave;
}
displaySignal(sig) {
return `
`;
}
saveIPSettings() {
let pnl = document.getElementById('divDHCP');
let obj = ui.fromElement(pnl).ip;
console.log(obj);
if (!obj.dhcp) {
let fnValidateIP = (addr) => { return /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(addr); };
if (typeof obj.ip !== 'string' || obj.ip.length === 0 || obj.ip === '0.0.0.0') {
ui.errorMessage('You must supply a valid IP address for the Static IP Address');
return;
}
else if (!fnValidateIP(obj.ip)) {
ui.errorMessage('Invalid Static IP Address. IP addresses are in the form XXX.XXX.XXX.XXX');
return;
}
if (typeof obj.subnet !== 'string' || obj.subnet.length === 0 || obj.subnet === '0.0.0.0') {
ui.errorMessage('You must supply a valid IP address for the Subnet Mask');
return;
}
else if (!fnValidateIP(obj.subnet)) {
ui.errorMessage('Invalid Subnet IP Address. IP addresses are in the form XXX.XXX.XXX.XXX');
return;
}
if (typeof obj.gateway !== 'string' || obj.gateway.length === 0 || obj.gateway === '0.0.0.0') {
ui.errorMessage('You must supply a valid Gateway IP address');
return;
}
else if (!fnValidateIP(obj.gateway)) {
ui.errorMessage('Invalid Gateway IP Address. IP addresses are in the form XXX.XXX.XXX.XXX');
return;
}
if (obj.dns1.length !== 0 && !fnValidateIP(obj.dns1)) {
ui.errorMessage('Invalid Domain Name Server 1 IP Address. IP addresses are in the form XXX.XXX.XXX.XXX');
return;
}
if (obj.dns2.length !== 0 && !fnValidateIP(obj.dns2)) {
ui.errorMessage('Invalid Domain Name Server 2 IP Address. IP addresses are in the form XXX.XXX.XXX.XXX');
return;
}
}
// With DHCP the static address stays as it was.
if (obj.dhcp) this.saveNetworkSection({ dhcp: true });
else this.saveNetworkSection({ dhcp: false, ip: obj.ip, subnet: obj.subnet, gateway: obj.gateway, dns1: obj.dns1, dns2: obj.dns2 });
}
saveNetwork() {
let pnl = document.getElementById('divNetAdapter');
let obj = ui.fromElement(pnl);
if (this.keepSavedPassphrase(obj.wifi.ssid, obj.wifi.passphrase)) delete obj.wifi.passphrase;
obj.connType = obj.ethernet.hardwired ? (obj.ethernet.wirelessFallback ? 3 : 2) : 1;
console.log(obj);
if (obj.connType >= 2) {
let boardType = this.ethBoardTypes.find(elem => obj.ethernet.boardType === elem.val);
let phyType = this.ethPhyTypes.find(elem => obj.ethernet.phyType === elem.val);
let clkMode = this.clockModes().find(elem => obj.ethernet.CLKMode === elem.val);
let div = document.createElement('div');
let html = `
`;
html += '
BEWARE ... WARNING ... DANGER
';
html += '
Incorrect Ethernet settings can damage your ESP32. Please verify the settings below and ensure they match the manufacturer spec sheet.
';
html += '
If you are unsure do not press the Red button and press the Green button. If any of the settings are incorrect please use the Custom Board type and set them to the correct values.';
html += '
';
let opt = document.createElement('option');
opt.innerHTML = group.name;
opt.setAttribute('data-address', group.remoteAddress);
opt.setAttribute('data-type', 'group');
opt.setAttribute('data-groupid', group.groupId);
opt.setAttribute('data-bitlength', group.bitLength);
optGroup.appendChild(opt);
}
}
let sopt = vrList.options[vrList.selectedIndex];
document.getElementById('divVirtualRemote').setAttribute('data-bitlength', sopt ? sopt.getAttribute('data-bitlength') : 'none');
document.getElementById('divGroupList').innerHTML = divCfg;
let groupControls = document.getElementById('divGroupControls');
groupControls.innerHTML = divCtl;
// Attach the timer for setting the My Position for the Group.
let btns = groupControls.querySelectorAll('div.cmd-button');
for (let i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', (event) => {
console.log(this);
console.log(event);
let groupId = parseInt(event.currentTarget.getAttribute('data-groupid'), 10);
let cmd = event.currentTarget.getAttribute('data-cmd');
if (cmd === 'sunflag') {
if (makeBool(event.currentTarget.getAttribute('data-on')))
this.sendGroupCommand(groupId, 'flag');
else
this.sendGroupCommand(groupId, 'sunflag');
}
else
this.sendGroupCommand(groupId, cmd);
}, true);
}
this.setListDraggable(document.getElementById('divGroupList'), '.group-draggable', (list) => {
// Get the shade order
let items = list.querySelectorAll('.group-draggable');
let order = [];
for (let i = 0; i < items.length; i++) {
order.push(parseInt(items[i].getAttribute('data-groupid'), 10));
// Reorder the shades on the main page.
}
sendEdit('groupSortOrder', { ids: order }, null, (err) => {
if (err) ui.serviceError(err);
});
});
}
closeShadePositioners() {
let ctls = document.querySelectorAll('.shade-positioner');
for (let i = 0; i < ctls.length; i++) {
console.log('Closing shade positioner');
ctls[i].remove();
}
}
openSetMyPosition(shadeId) {
if (typeof shadeId === 'undefined') return;
else {
let shade = document.querySelector(`div.somfyShadeCtl[data-shadeid="${shadeId}"]`);
if (shade) {
this.closeShadePositioners();
let currPos = parseInt(shade.getAttribute('data-position'), 10);
let currTiltPos = parseInt(shade.getAttribute('data-tiltposition'), 10);
let myPos = parseInt(shade.getAttribute('data-mypos'), 10);
let tiltType = parseInt(shade.getAttribute('data-tilt'), 10);
let myTiltPos = parseInt(shade.getAttribute('data-mytiltpos'), 10);
let elname = shade.querySelector(`.shadectl-name`);
let shadeName = elname.innerHTML;
let html = `
${shadeName}
`;
if (myPos >= 0 && tiltType !== 3)
html += `
Current:${myPos}%
`;
if (myTiltPos >= 0 && tiltType > 0)
html += `
Tilt:${myTiltPos}%
`;
html += `
`;
html += `
`
html += ``;
html += ``;
html += `
`
html += '
';
html += ``;
html += ``;
html += '
';
html += ``;
html += '
';
html += ``;
html += ``;
html += `
`;
let div = document.createElement('div');
div.setAttribute('class', 'shade-positioner shade-my-positioner');
div.setAttribute('data-shadeid', shadeId);
div.style.height = 'auto';
div.innerHTML = html;
shade.appendChild(div);
let elTarget = div.querySelector('input#slidShadeTarget');
let elTiltTarget = div.querySelector('input#slidShadeTiltTarget');
elTarget.value = currPos;
elTiltTarget.value = currTiltPos;
let elBtn = div.querySelector('button#btnSetMyPosition');
if (tiltType === 3) {
div.querySelector('div#divTiltTarget').style.display = '';
div.querySelector('div#divShadeTarget').style.display = 'none';
}
else if (tiltType > 0) div.querySelector('div#divTiltTarget').style.display = '';
let fnProcessChange = () => {
let pos = parseInt(elTarget.value, 10);
let tilt = parseInt(elTiltTarget.value, 10);
if (tiltType === 3 && tilt === myTiltPos) {
elBtn.innerHTML = 'Clear My Position';
elBtn.style.background = 'orangered';
}
else if (pos === myPos && (tiltType === 0 || tilt === myTiltPos)) {
elBtn.innerHTML = 'Clear My Position';
elBtn.style.background = 'orangered';
}
else {
elBtn.innerHTML = 'Set My Position';
elBtn.style.background = '';
}
document.getElementById('spanShadeTiltTarget').innerHTML = tilt;
document.getElementById('spanShadeTarget').innerHTML = pos;
};
let fnSetMyPosition = () => {
let pos = parseInt(elTarget.value, 10);
let tilt = parseInt(elTiltTarget.value, 10);
somfy.sendShadeMyPosition(shadeId, pos, tilt);
};
fnProcessChange();
elTarget.addEventListener('change', (event) => { fnProcessChange(); });
elTiltTarget.addEventListener('change', (event) => { fnProcessChange(); });
elBtn.addEventListener('click', (event) => { fnSetMyPosition(); });
}
}
}
sendShadeMyPosition(shadeId, pos, tilt) {
console.log(`Sending My Position for shade id ${shadeId} to ${pos} and ${tilt}`);
sendOp('setMyPosition', { shadeId: shadeId, myPos: pos, myTiltPos: tilt }, (err) => {
this.closeShadePositioners();
if (err) ui.serviceError(err);
}, true);
}
setLinkedRemotesList(shade) {
let divCfg = '';
for (let i = 0; i < shade.linkedRemotes.length; i++) {
let remote = shade.linkedRemotes[i];
divCfg += `
';
}
document.getElementById('divLinkedShadeList').innerHTML = divCfg;
}
pinMaps = [
{ name: '', maxPins: 39, inputs: [], outputs: [] },
{ name: 's2', maxPins: 46, inputs: [0, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 45], outputs: [0, 19, 20, 26, 27, 28, 29, 30, 31, 32, 45, 46]},
{ name: 's3', maxPins: 48, inputs: [19, 20, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32], outputs: [19, 20, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32] },
{ name: 'c3', maxPins: 21, inputs: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20], outputs: [11, 12, 13, 14, 15, 16, 17, 21] },
{ name: 'c5', maxPins: 28, inputs: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22], outputs: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22] },
{ name: 'c6', maxPins: 23, inputs: [], outputs: [] },
// Without the C6's SDIO link (14-19, 54), USB Serial/JTAG (24, 25),
// Ethernet (28-31, 34, 35, 49-52), UART0 (37, 38) and the strapping
// pins (34-38).
{ name: 'p4', maxPins: 54, inputs: [14, 15, 16, 17, 18, 19, 24, 25, 28, 29, 30, 31, 34, 35, 36, 37, 38, 49, 50, 51, 52, 54], outputs: [14, 15, 16, 17, 18, 19, 24, 25, 28, 29, 30, 31, 34, 35, 36, 37, 38, 49, 50, 51, 52, 54] }
];
loadPins(type, sel, opt) {
while (sel.firstChild) sel.removeChild(sel.firstChild);
let cm = document.getElementById('divContainer').getAttribute('data-chipmodel');
// The pre-v3 P4 build has the same pins.
if (cm === 'p4es') cm = 'p4';
let pm = this.pinMaps.find(x => x.name === cm) || { name: '', maxPins: 39, inputs: [], outputs: [] };
//console.log({ cm: cm, pm: pm });
for (let i = 0; i <= pm.maxPins; i++) {
if (type.includes('in') && pm.inputs.includes(i)) continue;
if (type.includes('out') && pm.outputs.includes(i)) continue;
sel.options[sel.options.length] = new Option(`GPIO-${i > 9 ? i.toString() : '0' + i.toString()}`, i, typeof opt !== 'undefined' && opt === i);
}
}
procGroupState(state) {
console.log(state);
let flags = document.querySelectorAll(`.button-sunflag[data-groupid="${state.groupId}"]`);
for (let i = 0; i < flags.length; i++) {
flags[i].style.display = state.sunSensor ? '' : 'none';
flags[i].setAttribute('data-on', state.flags & 0x20 === 0x20 ? 'true' : 'false');
}
}
procShadeState(state) {
console.log(state);
let icons = document.querySelectorAll(`.somfy-shade-icon[data-shadeid="${state.shadeId}"]`);
for (let i = 0; i < icons.length; i++) {
icons[i].style.setProperty('--shade-position', `${state.flipPosition ? 100 - state.position : state.position}%`);
icons[i].style.setProperty('--fpos', `${state.position}%`);
//icons[i].style.setProperty('--shade-position', `${state.position}%`);
}
if (state.tiltType !== 0) {
let tilts = document.querySelectorAll(`.icss-window-tilt[data-shadeid="${state.shadeId}"]`);
for (let i = 0; i < tilts.length; i++) {
tilts[i].setAttribute('data-tiltposition', `${state.tiltPosition}`);
}
}
let flags = document.querySelectorAll(`.button-sunflag[data-shadeid="${state.shadeId}"]`);
for (let i = 0; i < flags.length; i++) {
flags[i].style.display = state.sunSensor ? '' : 'none';
flags[i].setAttribute('data-on', state.flags & 0x01 === 0x01 ? 'true' : 'false');
}
let divs = document.querySelectorAll(`.somfyShadeCtl[data-shadeid="${state.shadeId}"]`);
for (let i = 0; i < divs.length; i++) {
divs[i].setAttribute('data-direction', state.direction);
divs[i].setAttribute('data-position', state.position);
divs[i].setAttribute('data-target', state.target);
divs[i].setAttribute('data-mypos', state.myPos);
divs[i].setAttribute('data-windy', (state.flags & 0x10) === 0x10 ? 'true' : 'false');
divs[i].setAttribute('data-sunny', (state.flags & 0x20) === 0x20 ? 'true' : 'false');
if (typeof state.myTiltPos !== 'undefined') divs[i].setAttribute('data-mytiltpos', state.myTiltPos);
else divs[i].setAttribute('data-mytiltpos', -1);
if (state.tiltType !== 0) {
divs[i].setAttribute('data-tiltdirection', state.tiltDirection);
divs[i].setAttribute('data-tiltposition', state.tiltPosition);
divs[i].setAttribute('data-tilttarget', state.tiltTarget);
}
let span = divs[i].querySelector('span.my-pos');
if (span) span.innerHTML = typeof state.myPos !== 'undefined' && state.myPos >= 0 ? `${state.myPos}%` : '---';
span = divs[i].querySelector('span.my-pos-tilt');
if (span) span.innerHTML = typeof state.myTiltPos !== 'undefined' && state.myTiltPos >= 0 ? `${state.myTiltPos}%` : '---';
}
}
procRemoteFrame(frame) {
//console.log(frame);
document.getElementById('spanRssi').innerHTML = frame.rssi;
document.getElementById('spanFrameCount').innerHTML = parseInt(document.getElementById('spanFrameCount').innerHTML, 10) + 1;
let lnk = document.getElementById('divLinking');
if (lnk) {
let obj = {
shadeId: parseInt(lnk.dataset.shadeid, 10),
remoteAddress: frame.address,
lastRollingCode: frame.rcode
};
// The frames keep coming while the edit runs: link it once.
lnk.remove();
sendEdit('linkRemote', obj, editTarget('shade', () => obj.shadeId), (err, shade) => {
if (err) ui.serviceError(err);
else if (shade) this.setLinkedRemotesList(shade);
});
}
else {
lnk = document.getElementById('divLinkRepeater');
if (lnk) {
lnk.remove();
sendEdit('linkRepeater', { remoteAddress: frame.address }, null, (err) => {
if (err) ui.serviceError(err);
});
}
}
let frames = document.getElementById('divFrames');
let row = document.createElement('div');
row.classList.add('frame-row');
row.setAttribute('data-valid', frame.valid);
// The socket is not sending the current date so we will snag the current receive date from
// the browser.
let fnFmtDate = (dt) => {
return `${(dt.getMonth() + 1).fmt('00')}/${dt.getDate().fmt('00')} ${dt.getHours().fmt('00')}:${dt.getMinutes().fmt('00')}:${dt.getSeconds().fmt('00')}.${dt.getMilliseconds().fmt('000')}`;
};
let fnFmtTime = (dt) => {
return `${dt.getHours().fmt('00')}:${dt.getMinutes().fmt('00')}:${dt.getSeconds().fmt('00')}.${dt.getMilliseconds().fmt('000')}`;
};
frame.time = new Date();
let proto = '-S';
switch (frame.proto) {
case 1:
proto = '-W';
break;
case 2:
proto = '-V';
break;
}
let rawCmdHex = (typeof frame.rawCmd === 'number') ? `0x${frame.rawCmd.toString(16).toUpperCase()}` : '';
let html = `${frame.encKey}${frame.address}${frame.command}${frame.stepSize ? frame.stepSize : ''}${frame.rcode}${frame.rssi}dBm${frame.bits}${proto}${fnFmtTime(frame.time)}${rawCmdHex}
`;
for (let i = 0; i < frame.pulses.length; i++) {
if (i !== 0) html += ',';
html += `${frame.pulses[i]}`;
}
html += '
';
row.innerHTML = html;
frames.prepend(row);
this.frames.push(frame);
}
JSONPretty(obj, indent = 2) {
if (Array.isArray(obj)) {
let output = '[';
for (let i = 0; i < obj.length; i++) {
if (i !== 0) output += ',\n';
output += this.JSONPretty(obj[i], indent);
}
output += ']';
return output;
}
else {
let output = JSON.stringify(obj, function (k, v) {
if (Array.isArray(v)) return JSON.stringify(v);
return v;
}, indent).replace(/\\/g, '')
.replace(/\"\[/g, '[')
.replace(/\]\"/g, ']')
.replace(/\"\{/g, '{')
.replace(/\}\"/g, '}')
.replace(/\{\n\s+/g, '{');
return output;
}
}
framesToClipboard() {
if (typeof navigator.clipboard !== 'undefined')
navigator.clipboard.writeText(this.JSONPretty(this.frames, 2));
else {
let dummy = document.createElement('textarea');
document.body.appendChild(dummy);
dummy.value = this.JSONPretty(this.frames, 2);
dummy.focus();
dummy.select();
document.execCommand('copy');
document.body.removeChild(dummy);
}
}
onShadeTypeChanged(el) {
let sel = document.getElementById('selShadeType');
let tilt = parseInt(document.getElementById('selTiltType').value, 10);
let ico = document.getElementById('icoShade');
let type = parseInt(sel.value, 10);
document.getElementById('somfyShade').setAttribute('data-shadetype', type);
document.getElementById('divSomfyButtons').setAttribute('data-shadetype', type);
let st = this.shadeTypes.find(x => x.type === type) || { type: type };
for (let i = 0; i < this.shadeTypes.length; i++) {
let t = this.shadeTypes[i];
if (t.type !== type) {
if (ico.classList.contains(t.ico) && t.ico !== st.ico) ico.classList.remove(t.ico);
}
else {
if (!ico.classList.contains(st.ico)) ico.classList.add(st.ico);
document.getElementById('divTiltSettings').style.display = st.tilt !== false ? '' : 'none';
let lift = st.lift || false;
if (lift && tilt == 3) lift = false;
if (!st.tilt) tilt = 0;
document.getElementById('fldTiltTime').parentElement.style.display = tilt ? 'inline-block' : 'none';
document.getElementById('divLiftSettings').style.display = lift ? '' : 'none';
document.getElementById('divTiltSettings').style.display = st.tilt ? '' : 'none';
document.querySelector('#divSomfyButtons i.icss-window-tilt').style.display = tilt ? '' : 'none';
document.getElementById('divSunSensor').style.display = st.sun ? '' : 'none';
document.getElementById('divLightSwitch').style.display = st.light ? '' : 'none';
document.getElementById('divFlipPosition').style.display = st.fpos ? '' : 'none';
document.getElementById('divFlipCommands').style.display = st.fcmd ? '' : 'none';
if (!st.light) document.getElementById('cbHasLight').checked = false;
if (!st.sun) document.getElementById('cbHasSunsensor').checked = false;
}
}
}
onShadeBitLengthChanged(el) {
document.getElementById('somfyShade').setAttribute('data-bitlength', el.value);
//document.getElementById('divStepSettings').style.display = parseInt(el.value, 10) === 80 ? '' : 'none';
}
onShadeProtoChanged(el) {
document.getElementById('somfyShade').setAttribute('data-proto', el.value);
}
openEditRoom(roomId) {
if (typeof roomId === 'undefined') {
document.getElementById('btnSaveRoom').innerText = 'Add Room';
sendOp('getNextRoom', {}, (err, room) => {
document.getElementById('spanRoomId').innerText = '*';
if (err) ui.serviceError(err);
else {
// What the reply leaves out is a new room's default.
room = proto.schema.fill(room, ProtoModel.FIELDS.room);
let elRoom = document.getElementById('somfyRoom');
room.name = '';
ui.toElement(elRoom, room);
this.showEditRoom(true);
}
});
}
else {
document.getElementById('btnSaveRoom').innerText = 'Save Room';
let room = proto.model.rooms.get(roomId);
if (!room) gone('room');
else {
document.getElementById('spanRoomId').innerText = roomId;
ui.toElement(document.getElementById('somfyRoom'), room);
this.showEditRoom(true);
document.getElementById('btnSaveRoom').style.display = 'inline-block';
}
}
}
openEditShade(shadeId) {
if (typeof shadeId === 'undefined') {
sendOp('getNextShade', {}, (err, shade) => {
document.getElementById('btnPairShade').style.display = 'none';
document.getElementById('btnUnpairShade').style.display = 'none';
document.getElementById('btnLinkRemote').style.display = 'none';
document.getElementById('btnSaveShade').innerText = 'Add Shade';
document.getElementById('spanShadeId').innerText = '*';
document.getElementById('divLinkedRemoteList').innerHTML = '';
document.getElementById('btnSetRollingCode').style.display = 'none';
//document.getElementById('selShadeBitLength').value = 56;
//document.getElementById('cbFlipCommands').value = false;
//document.getElementById('cbFlipPosition').value = false;
if (err) {
ui.serviceError(err);
}
else {
// What the reply leaves out is a new shade's default.
shade = proto.schema.fill(shade, ProtoModel.FIELDS.shade);
let elShade = document.getElementById('somfyShade');
shade.name = '';
shade.downTime = shade.upTime = 10000;
shade.tiltTime = 7000;
shade.bitLength = 56;
shade.flipCommands = shade.flipPosition = false;
ui.toElement(elShade, shade);
this.showEditShade(true);
elShade.setAttribute('data-bitlength', shade.bitLength);
}
});
}
else {
// Load up an exist shade.
document.getElementById('btnSaveShade').style.display = 'none';
document.getElementById('btnPairShade').style.display = 'none';
document.getElementById('btnUnpairShade').style.display = 'none';
document.getElementById('btnLinkRemote').style.display = 'none';
document.getElementById('btnSaveShade').innerText = 'Save Shade';
document.getElementById('spanShadeId').innerText = shadeId;
let fnShow = (err, shade) => {
if (err) {
ui.serviceError(err);
}
else {
console.log(shade);
ui.toElement(document.getElementById('somfyShade'), shade);
this.showEditShade(true);
document.getElementById('btnSaveShade').style.display = 'inline-block';
document.getElementById('btnLinkRemote').style.display = '';
this.onShadeTypeChanged(document.getElementById('selShadeType'));
let ico = document.getElementById('icoShade');
let tilt = ico.parentElement.querySelector('i.icss-window-tilt');
tilt.style.display = shade.tiltType !== 0 ? '' : 'none';
tilt.setAttribute('data-tiltposition', shade.tiltPosition);
tilt.setAttribute('data-shadeid', shade.shadeId);
ico.style.setProperty('--shade-position', `${shade.flipPosition ? 100 - shade.position : shade.position}%`);
ico.style.setProperty('--fpos', `${shade.position}%`);
ico.style.setProperty('--tilt-position', `${shade.flipPosition ? 100 - shade.tiltPosition : shade.tiltPosition}%`);
//ico.style.setProperty('--shade-position', `${shade.position}%`);
//ico.style.setProperty('--tilt-position', `${shade.tiltPosition}%`);
ico.setAttribute('data-shadeid', shade.shadeId);
somfy.onShadeBitLengthChanged(document.getElementById('selShadeBitLength'));
somfy.onShadeProtoChanged(document.getElementById('selShadeProto'));
document.getElementById('btnSetRollingCode').style.display = 'inline-block';
if (shade.paired) {
document.getElementById('btnUnpairShade').style.display = 'inline-block';
}
else {
document.getElementById('btnPairShade').style.display = 'inline-block';
}
this.setLinkedRemotesList(shade);
}
};
let shade = proto.model.shades.get(shadeId);
if (shade) fnShow(null, shade);
else gone('shade');
}
}
openEditGroup(groupId) {
document.getElementById('btnLinkShade').style.display = 'none';
if (typeof groupId === 'undefined') {
sendOp('getNextGroup', {}, (err, group) => {
document.getElementById('btnSaveGroup').innerText = 'Add Group';
document.getElementById('spanGroupId').innerText = '*';
document.getElementById('divLinkedShadeList').innerHTML = '';
//document.getElementById('btnSetRollingCode').style.display = 'none';
if (err) {
ui.serviceError(err);
}
else {
group = proto.schema.fill(group, ProtoModel.FIELDS.group);
group.name = '';
group.flipCommands = false;
ui.toElement(document.getElementById('somfyGroup'), group);
this.showEditGroup(true);
}
});
}
else {
// Load up an existing group.
document.getElementById('btnSaveGroup').style.display = 'none';
document.getElementById('btnSaveGroup').innerText = 'Save Group';
document.getElementById('spanGroupId').innerText = groupId;
let group = ProtoViews.group(proto.model, proto.model.groups.get(groupId));
if (!group) gone('group');
else {
ui.toElement(document.getElementById('somfyGroup'), group);
this.showEditGroup(true);
document.getElementById('btnSaveGroup').style.display = 'inline-block';
document.getElementById('btnLinkShade').style.display = '';
document.getElementById('btnSetRollingCode').style.display = 'inline-block';
this.setLinkedShadesList(group);
}
}
}
showEditRoom(bShow) {
let el = document.getElementById('divLinking');
if (el) el.remove();
el = document.getElementById('divLinkRepeater');
if (el) el.remove();
el = document.getElementById('divPairing');
if (el) el.remove();
el = document.getElementById('divRollingCode');
if (el) el.remove();
el = document.getElementById('somfyRoom');
if (el) el.style.display = bShow ? '' : 'none';
el = document.getElementById('divRoomListContainer');
if (el) el.style.display = bShow ? 'none' : '';
if (bShow) {
this.showEditGroup(false);
this.showEditShade(false);
}
}
showEditShade(bShow) {
let el = document.getElementById('divLinking');
if (el) el.remove();
el = document.getElementById('divLinkRepeater');
if (el) el.remove();
el = document.getElementById('divPairing');
if (el) el.remove();
el = document.getElementById('divRollingCode');
if (el) el.remove();
el = document.getElementById('somfyShade');
if (el) el.style.display = bShow ? '' : 'none';
el = document.getElementById('divShadeListContainer');
if (el) el.style.display = bShow ? 'none' : '';
if (bShow) {
this.showEditGroup(false);
this.showEditRoom(false);
}
}
showEditGroup(bShow) {
let el = document.getElementById('divLinking');
if (el) el.remove();
el = document.getElementById('divLinkRepeater');
if (el) el.remove();
el = document.getElementById('divPairing');
if (el) el.remove();
el = document.getElementById('divRollingCode');
if (el) el.remove();
el = document.getElementById('somfyGroup');
if (el) el.style.display = bShow ? '' : 'none';
el = document.getElementById('divGroupListContainer');
if (el) el.style.display = bShow ? 'none' : '';
if (bShow) {
this.showEditRoom(false);
this.showEditShade(false);
}
}
saveRoom() {
let roomId = parseInt(document.getElementById('spanRoomId').innerText, 10);
let obj = ui.fromElement(document.getElementById('somfyRoom'));
let valid = true;
if (valid && (typeof obj.name !== 'string' || obj.name === '' || obj.name.length > 20)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'You must provide a name for the room between 1 and 20 characters.');
valid = false;
}
if (valid) {
if (isNaN(roomId) || roomId === 0) {
// We are adding.
sendEdit('addRoom', obj, editTarget('room', (r) => r.roomId), (err, room) => {
if (err) {
ui.serviceError(err);
console.log(err);
}
else {
console.log(room);
document.getElementById('spanRoomId').innerText = room.roomId;
document.getElementById('btnSaveRoom').innerText = 'Save Room';
document.getElementById('btnSaveRoom').style.display = 'inline-block';
}
});
}
else {
obj.roomId = roomId;
sendEdit('saveRoom', obj, null, (err) => {
if (err) ui.serviceError(err);
});
}
}
}
saveShade() {
let shadeId = parseInt(document.getElementById('spanShadeId').innerText, 10);
let obj = ui.fromElement(document.getElementById('somfyShade'));
let valid = true;
if (valid && (isNaN(obj.remoteAddress) || obj.remoteAddress < 1 || obj.remoteAddress > 16777215)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'The remote address must be a number between 1 and 16777215. This number must be unique for all shades.');
valid = false;
}
if (valid && (typeof obj.name !== 'string' || obj.name === '' || obj.name.length > 20)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'You must provide a name for the shade between 1 and 20 characters.');
valid = false;
}
if (valid && (isNaN(obj.upTime) || obj.upTime < 1 || obj.upTime > 4294967295)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'Up Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full closed to full open.');
valid = false;
}
if (valid && (isNaN(obj.downTime) || obj.downTime < 1 || obj.downTime > 4294967295)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'Down Time must be a value between 0 and 4,294,967,295 milliseconds. This is the travel time to go from full open to full closed.');
valid = false;
}
if (obj.proto === 8 || obj.proto === 9) {
switch (obj.shadeType) {
case 5: // Garage 1-button
case 14: // Gate left 1-button
case 15: // Gate center 1-button
case 16: // Gate right 1-button
case 10: // Two button dry contact
if (obj.proto !== 9 && obj.gpioUp === obj.gpioDown) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'For GPIO controlled motors the up and down GPIO selections must be unique.');
valid = false;
}
break;
case 9: // Dry contact.
break;
default:
if (obj.gpioUp === obj.gpioDown) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'For GPIO controlled motors the up and down GPIO selections must be unique.');
valid = false;
}
else if (obj.proto === 9 && (obj.gpioMy === obj.gpioUp || obj.gpioMy === obj.gpioDown)) {
ui.errorMessage(document.getElementById('divSomfySettings'), 'For GPIO controlled motors the up and down and my GPIO selections must be unique.');
valid = false;
}
break;
}
}
if (valid) {
if (isNaN(shadeId) || shadeId >= 255) {
// We are adding.
sendEdit('addShade', obj, editTarget('shade', (r) => r.shadeId), (err, shade) => {
if (err) {
ui.serviceError(err);
console.log(err);
}
else {
console.log(shade);
document.getElementById('spanShadeId').innerText = shade.shadeId;
document.getElementById('btnSaveShade').innerText = 'Save Shade';
document.getElementById('btnSaveShade').style.display = 'inline-block';
document.getElementById('btnLinkRemote').style.display = '';
document.getElementById(shade.paired ? 'btnUnpairShade' : 'btnPairShade').style.display = 'inline-block';
document.getElementById('btnSetRollingCode').style.display = 'inline-block';
}
});
}
else {
obj.shadeId = shadeId;
sendEdit('saveShade', obj, editTarget('shade', () => shadeId), (err, shade) => {
if (err) {
ui.serviceError(err);
return;
}
if (!shade) return;
let ico = document.getElementById('icoShade');
let tilt = ico.parentElement.querySelector('i.icss-window-tilt');
tilt.style.display = shade.tiltType !== 0 ? '' : 'none';
tilt.setAttribute('data-tiltposition', shade.tiltPosition);
tilt.setAttribute('data-shadeid', shade.shadeId);
ico.style.setProperty('--shade-position', `${shade.flipPosition ? 100 - shade.position : shade.position}%`);
ico.style.setProperty('--fpos', `${shade.position}%`);
ico.style.setProperty('--tilt-position', `${shade.flipPosition ? 100 - shade.tiltPosition : shade.tiltPosition}%`);
});
}
}
}
saveGroup() {
let groupId = parseInt(document.getElementById('spanGroupId').innerText, 10);
let obj = ui.fromElement(document.getElementById('somfyGroup'));
let valid = true;
if (valid && (isNaN(obj.remoteAddress) || obj.remoteAddress < 1 || obj.remoteAddress > 16777215)) {
ui.errorMessage('The remote address must be a number between 1 and 16777215. This number must be unique for all shades.');
valid = false;
}
if (valid && (typeof obj.name !== 'string' || obj.name === '' || obj.name.length > 20)) {
ui.errorMessage('You must provide a name for the shade between 1 and 20 characters.');
valid = false;
}
if (valid) {
if (isNaN(groupId) || groupId >= 255) {
// We are adding.
sendEdit('addGroup', obj, editTarget('group', (r) => r.groupId), (err, group) => {
if (err) ui.serviceError(err);
else {
console.log(group);
document.getElementById('spanGroupId').innerText = group.groupId;
document.getElementById('btnSaveGroup').innerText = 'Save Group';
document.getElementById('btnSaveGroup').style.display = 'inline-block';
document.getElementById('btnLinkShade').style.display = '';
}
});
}
else {
obj.groupId = groupId;
sendEdit('saveGroup', obj, null, (err) => {
if (err) ui.serviceError(err);
});
}
}
}
deleteRoom(roomId) {
let valid = true;
if (isNaN(roomId) || roomId >= 255 || roomId <= 0) {
ui.errorMessage('A valid room id was not supplied.');
valid = false;
}
if (valid) {
let room = proto.model.rooms.get(roomId);
if (!room) gone('room');
else {
let prompt = ui.promptMessage(`Are you sure you want to delete this room?`, () => {
ui.clearErrors();
sendEdit('deleteRoom', { roomId: roomId }, null, (err) => {
prompt.remove();
if (err) ui.serviceError(err);
});
});
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) {
let valid = true;
if (isNaN(shadeId) || shadeId >= 255 || shadeId <= 0) {
ui.errorMessage('A valid shade id was not supplied.');
valid = false;
}
if (valid) {
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.
`;
}
}
}
deleteGroup(groupId) {
let valid = true;
if (isNaN(groupId) || groupId >= 255 || groupId <= 0) {
ui.errorMessage('A valid shade id was not supplied.');
valid = false;
}
if (valid) {
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.
`;
}
}
}
setRollingCode(shadeId, rollingCode) {
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 shade = proto.model.shades.get(shadeId);
if (!shade) {
gone('shade');
return;
}
console.log(shade);
let div = document.createElement('div');
div.setAttribute('id', 'divRollingCode');
let html = `
`;
html += '
BEWARE ... WARNING ... DANGER
';
html += '';
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 += '
';
html += ``;
html += '';
html += '
';
html += `
`;
html += ``;
html += ``;
html += `
`;
div.innerHTML = html;
document.getElementById('somfyShade').appendChild(div);
}
setPaired(shadeId, paired) {
let obj = { shadeId: shadeId, paired: paired || false };
let div = document.getElementById('divPairing');
sendEdit('setPaired', obj, editTarget('shade', () => shadeId), (err, shade) => {
if (err) {
console.log(err);
ui.errorMessage(err.desc);
}
else if (div && shade) {
console.log(shade);
this.showEditShade(true);
document.getElementById('btnSaveShade').style.display = 'inline-block';
document.getElementById('btnLinkRemote').style.display = '';
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);
div.remove();
}
});
}
pairShade(shadeId) {
let shadeType = parseInt(document.getElementById('somfyShade').getAttribute('data-shadetype'), 10);
let div = document.createElement('div');
let html = `
`;
if (shadeType === 5 || shadeType === 6) {
html += '
Follow the instructions below to pair ESPSomfy RTS with an RTS Garage Door motor
';
html += '';
html += '
';
html += '
Open the garage door motor memory per instructions for your motor.
';
html += '
Once the memory is opened, press the prog button below
';
html += '
For single button control ESPSomfy RTS will send a toggle command but for a 3 button control it will send a prog command.
';
html += '
';
html += `
`;
html += ``;
html += ``;
html += ``;
html += `
`;
}
else {
html += '
Follow the instructions below to pair this shade with a Somfy motor
';
html += '';
html += '
';
html += '
Open the shade memory using an existing remote by pressing the prog button on the back until the shade jogs.
';
html += '
After the shade jogs press the Prog button below
';
html += '
The shade should jog again indicating that the shade is paired. NOTE: On some motors you may need to press and hold the Prog button.
';
html += '
If the shade jogs, you can press the shade paired button.
';
html += '
If the shade does not jog, try pressing the prog button again.
Follow the instructions below to unpair this shade from a Somfy motor
';
html += '';
html += '
';
html += '
Open the shade memory using an existing remote
';
html += '
Press the prog button on the back of the remote until the shade jogs
';
html += '
After the shade jogs press the Prog button below
';
html += '
The shade should jog again indicating that the shade is unpaired
';
html += '
If the shade jogs, you can press the shade unpaired button.
';
html += '
If the shade does not jog, press the prog button again until the shade jogs.
';
html += '
';
html += `
`;
html += ``;
html += ``;
html += ``;
html += `
`;
div.innerHTML = html;
let fnRepeatProg = (err, shade) => {
if (this.btnTimer) {
clearTimeout(this.btnTimer);
this.btnTimer = null;
}
if (err) return;
if (mouseDown) {
somfy.sendCommandRepeat(shadeId, 'prog', null, fnRepeatProg);
}
}
document.getElementById('somfyShade').appendChild(div);
let btn = document.getElementById('btnSendUnpairing');
btn.addEventListener('mousedown', (event) => {
console.log(this);
console.log(event);
console.log('mousedown');
somfy.sendCommand(shadeId, 'prog', null, (err, shade) => { fnRepeatProg(err, shade); });
}, true);
btn.addEventListener('touchstart', (event) => {
console.log(this);
console.log(event);
console.log('touchstart');
somfy.sendCommand(shadeId, 'prog', null, (err, shade) => { fnRepeatProg(err, shade); });
}, true);
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 [cmd, done] = this.commandArgs(shadeId, command, repeat, cb);
let fields = { shadeId: cmd.shadeId, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize };
if (isNaN(parseInt(cmd.command, 10))) fields.command = cmd.command;
else fields.target = parseInt(cmd.command, 10);
sendOp('shadeCommand', fields, done);
}
// The last frame again when it was this command, otherwise the command.
sendCommandRepeat(shadeId, command, repeat, cb) {
let [cmd, done] = this.commandArgs(shadeId, command, repeat, cb);
sendOp('repeatCommand', { shadeId: cmd.shadeId, command: cmd.command, repeat: repeatOf(cmd.repeat), stepSize: cmd.stepSize }, done);
}
// (shadeId, command, repeat, cb), or (object, cb) as the virtual remote
// calls it: the command and the callback.
commandArgs(shadeId, command, repeat, cb) {
if (typeof shadeId === 'object') return [shadeId, command];
return [{ shadeId: shadeId, command: command, repeat: repeat }, cb];
}
sendGroupRepeat(groupId, command, repeat, cb) {
sendOp('repeatCommand', { groupId: groupId, command: command, repeat: repeatOf(repeat) }, cb);
}
sendVRCommand(el) {
let pnl = document.getElementById('divVirtualRemote');
let dd = pnl.querySelector('#selVRMotor');
let opt = dd.selectedOptions[0];
let o = {
type: opt.getAttribute('data-type'),
address: opt.getAttribute('data-address'),
cmd: el.getAttribute('data-cmd')
};
ui.fromElement(el.parentElement.parentElement, o);
switch (o.type) {
case 'shade':
o.shadeId = parseInt(opt.getAttribute('data-shadeId'), 10);
o.shadeType = parseInt(opt.getAttribute('data-shadeType'), 10);
break;
case 'group':
o.groupId = parseInt(opt.getAttribute('data-groupId'), 10);
break;
}
console.log(o);
let fnRepeatCommand = (err, shade) => {
if (this.btnTimer) {
clearTimeout(this.btnTimer);
this.btnTimer = null;
}
if (err) return;
if (mouseDown) {
if (o.cmd === 'Sensor')
somfy.sendSetSensor(o);
else if (o.type === 'group')
somfy.sendGroupRepeat(o.groupId, o.cmd, null, fnRepeatCommand);
else
somfy.sendCommandRepeat(o, fnRepeatCommand);
}
}
o.command = o.cmd;
if (o.cmd === 'Sensor') {
somfy.sendSetSensor(o);
}
else if (o.type === 'group')
somfy.sendGroupCommand(o.groupId, o.cmd, null, (err, group) => { fnRepeatCommand(err, group); });
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) {
let fields = { sunny: obj.sunny, windy: obj.windy, repeat: repeatOf(obj.repeat) };
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}`);
sendOp('groupCommand', { groupId: groupId, command: command, repeat: repeatOf(repeat) }, cb);
}
sendTiltCommand(shadeId, command, cb) {
console.log(`Sending Tilt command ${shadeId}-${command}`);
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');
let html = `
`;
html += '
Press any button on the remote to link it to this shade. This will not change the pairing for the remote and this screen will close when the remote is detected.
';
html += '';
html += `
`;
html += '
';
div.innerHTML = html;
document.getElementById('somfyShade').appendChild(div);
return div;
}
linkRepeatRemote() {
let div = document.createElement('div');
let html = `
`;
html += '
Press any button on the remote to repeat its signals.
';
html += '
When assigned, ESPSomfy RTS will act as a repeater and repeat any frames for the identified remotes.
'
html += '
Only assign a repeater when ESPSomfy RTS reliably hears a physical remote but the motor does not. Repeating unnecessary radio signals will degrade radio performance and never assign the same repeater to more than one ESPSomfy RTS device. You will have created an insidious echo chamber.
'
html += '
Once a signal is detected from the remote this window will close and the remote signals will be repeated.
'
html += '';
html += `
`;
html += '
';
div.innerHTML = html;
document.getElementById('divConfigPnl').appendChild(div);
return div;
}
linkGroupShade(groupId) {
let div = document.createElement('div');
let html = `
`;
html += '
ADD SHADE TO GROUP
';
html += '
';
html += '
This wizard will walk you through the steps required to add shades into a group. Follow all instructions at each step until the shade is added to the group.
';
html += '
During this process the shade should jog exactly two times. The first time indicates that the motor memory has been enabled and the second time adds the group to the motor memory
';
html += '
Each shade must be paired individually to the group. When you are ready to begin pairing your shade to the group press the NEXT button.
';
html += '
';
html += '
';
html += '
Choose a shade that you would like to include in this group. Once you have chosen the shade to include in the link press the NEXT button.
';
html += '
Only shades that have not already been included in this group are available the dropdown. Each shade can be included in multiple groups.
';
html += '';
html += `
`;
html += `
`;
html += '
';
html += '
';
html += '
Now that you have chosen a shade to pair. Open the memory for the shade by pressing the OPEN MEMORY button. The shade should jog to indicate the memory has been opened.
';
html += '
The motor should jog only once. If it jogs more than once then you have again closed the memory on the motor. Once the command is sent to the motor you will be asked if the motor successfully jogged.
If it did then press YES if not press no and click the OPEN MEMORY button again.
';
html += '';
html += '';
html += '';
html += '';
html += '
';
html += '
';
html += '
Now that the memory is opened on the motor you need to send the pairing command for the group.
';
html += '
To do this press the PAIR TO GROUP button below and once the motor jogs the process will be complete.
';
html += '';
html += '';
html += '';
html += '';
html += '
Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.
Once the shade has jogged the motor memory will be ready to add the shade to the group.
`;
}
});
});
let btnPairToGroup = div.querySelector('#btnPairToGroup');
let fnRepeatProgCommand = (err, o) => {
console.log(o);
if (this.btnTimer) {
clearTimeout(this.btnTimer);
this.btnTimer = null;
}
if (err) return;
if (mouseDown) {
if (o.cmd === 'Sensor')
somfy.sendSetSensor(o);
else if (typeof o.groupId !== 'undefined')
somfy.sendGroupRepeat(o.groupId, 'prog', null, fnRepeatProgCommand);
else
somfy.sendCommandRepeat(o.shadeId, 'prog', null, fnRepeatProgCommand);
}
}
btnPairToGroup.addEventListener('mousedown', (evt) => {
mouseDown = true;
somfy.sendGroupCommand(groupId, 'prog', null, fnRepeatProgCommand);
});
btnPairToGroup.addEventListener('mouseup', (evt) => {
mouseDown = false;
let obj = ui.fromElement(div);
let prompt = ui.promptMessage('Confirm Motor Response', () => {
sendEdit('linkToGroup', { groupId: groupId, shadeId: obj.shadeId }, editTarget('group', () => groupId), (err, group) => {
if (err) ui.serviceError(err);
else if (group) somfy.setLinkedShadesList(group);
});
prompt.remove();
div.remove();
});
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.
`;
});
sendOp('groupOptions', { groupId: groupId }, (err, reply) => {
if (err) {
div.remove();
ui.serviceError(err);
}
else {
// 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');
let grpName = div.querySelector('#divGroupName');
if (grpName) grpName.innerHTML = options.name;
for (let i = 0; i < options.availShades.length; i++) {
let shade = options.availShades[i];
selAvail.options.add(new Option(shade.name, shade.shadeId));
}
let divWizShadeName = div.querySelector('#divWizShadeName');
if (divWizShadeName) divWizShadeName.innerHTML = options.availShades[0].name;
}
else {
div.remove();
ui.errorMessage('There are no available shades to pair to this group.');
}
}
});
return div;
}
unlinkGroupShade(groupId, shadeId) {
let div = document.createElement('div');
let html = `
`;
html += '
REMOVE SHADE FROM GROUP
';
html += '
';
html += '
This wizard will walk you through the steps required to remove a shade from a group. Follow all instructions at each step until the shade is removed from the group.
';
html += '
During this process the shade should jog exactly two times. The first time indicates that the motor memory has been enabled and the second time removes the group from the motor memory
';
html += '
Each shade must be removed from the group individually. When you are ready to begin unpairing your shade from the group press the NEXT button to begin.
';
html += '
';
html += '
';
html += '
You must first open the memory for the shade by pressing the OPEN MEMORY button. The shade should jog to indicate the memory has been opened.
';
html += '
The motor should jog only once. If it jogs more than once then you have again closed the memory on the motor. Once the motor has jogged press the NEXT button to proceed.
';
html += '';
html += '';
html += '';
html += '';
html += '
';
html += '
';
html += '
Now that the memory is opened on the motor you need to send the un-pairing command for the group.
';
html += '
To do this press the UNPAIR FROM GROUP button below and once the motor jogs the process will be complete.
';
html += '';
html += '';
html += '';
html += '';
html += '
Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.
If you are having trouble getting the motor to jog on this step you may try to open the memory using a remote. Most often this is done by selecting the channel, then a long press on the prog button.
If you opened the memory using the alternate method press the NO button to close this message, then press NEXT button to skip the step.
Did the shade jog? If the shade jogged press the YES button if not then press the NO button and try again.
Once the shade has jogged the shade will be removed from the group and this process will be finished.
`;
}
});
});
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 {
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?', () => {
sendEdit('unlinkRepeater', { remoteAddress: address }, null, (err) => {
if (err) ui.serviceError(err);
prompt.remove();
});
});
}
unlinkRemote(shadeId, remoteAddress) {
let prompt = ui.promptMessage('Are you sure you want to unlink this remote from the shade?', () => {
let obj = {
shadeId: shadeId,
remoteAddress: remoteAddress
};
sendEdit('unlinkRemote', obj, editTarget('shade', () => shadeId), (err, shade) => {
prompt.remove();
if (err) ui.serviceError(err);
else if (shade) this.setLinkedRemotesList(shade);
});
});
}
deviationChanged(el) {
document.getElementById('spanDeviation').innerText = (el.value / 100).fmt('#,##0.00');
}
rxBandwidthChanged(el) {
document.getElementById('spanRxBandwidth').innerText = (el.value / 100).fmt('#,##0.00');
}
frequencyChanged(el) {
document.getElementById('spanFrequency').innerText = (el.value / 1000).fmt('#,##0.000');
}
txPowerChanged(el) {
console.log(el.value);
let lvls = [-30, -20, -15, -10, -6, 0, 5, 7, 10, 11, 12];
document.getElementById('spanTxPower').innerText = lvls[el.value];
}
stepSizeChanged(el) {
document.getElementById('spanStepSize').innerText = parseInt(el.value, 10).fmt('#,##0');
}
processShadeTarget(el, shadeId) {
let positioner = document.querySelector(`.shade-positioner[data-shadeid="${shadeId}"]`);
if (positioner) {
positioner.querySelector(`.shade-target`).innerHTML = el.value;
somfy.sendCommand(shadeId, el.value);
}
}
processShadeTiltTarget(el, shadeId) {
let positioner = document.querySelector(`.shade-positioner[data-shadeid="${shadeId}"]`);
if (positioner) {
positioner.querySelector(`.shade-tilt-target`).innerHTML = el.value;
somfy.sendTiltCommand(shadeId, el.value);
}
}
openSelectRoom() {
this.closeShadePositioners();
console.log('Opening rooms');
let list = document.getElementById('divRoomSelector-list');
list.style.display = 'block';
document.body.addEventListener('click', () => {
list.style.display = '';
}, { once: true });
}
openSetPosition(shadeId) {
console.log('Opening Shade Positioner');
if (typeof shadeId === 'undefined') {
return;
}
else {
let shade = document.querySelector(`div.somfyShadeCtl[data-shadeid="${shadeId}"]`);
if (shade) {
let ctls = document.querySelectorAll('.shade-positioner');
for (let i = 0; i < ctls.length; i++) {
console.log('Closing shade positioner');
ctls[i].remove();
}
switch (parseInt(shade.getAttribute('data-shadetype'), 10)) {
case 5:
case 9:
case 10:
case 14:
case 15:
case 16:
return;
}
let tiltType = parseInt(shade.getAttribute('data-tilt'), 10) || 0;
let currPos = parseInt(shade.getAttribute('data-target'), 0);
let elname = shade.querySelector(`.shadectl-name`);
let shadeName = elname.innerHTML;
let html = `
${shadeName}
`;
let lbl = makeBool(shade.getAttribute('data-flipposition')) ? '% Open' : '% Closed';
if (tiltType !== 3) {
html += ``;
html += ``;
}
if (tiltType > 0) {
let currTiltPos = parseInt(shade.getAttribute('data-tilttarget'), 10);
html += ``;
html += ``;
}
html += `
`;
let div = document.createElement('div');
div.setAttribute('class', 'shade-positioner');
div.setAttribute('data-shadeid', shadeId);
div.addEventListener('onclick', (event) => { event.stopPropagation(); });
div.innerHTML = html;
shade.appendChild(div);
document.body.addEventListener('click', () => {
let ctls = document.querySelectorAll('.shade-positioner');
for (let i = 0; i < ctls.length; i++) {
console.log('Closing shade positioner');
ctls[i].remove();
}
}, { once: true });
}
}
}
}
var somfy = new Somfy();
class MQTT {
initialized = false;
init() { this.initialized = true; }
loadMQTT() {
sendOp('getSettings', { section: 'mqtt' }, (err, reply) => {
if (err)
console.log(err);
else {
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;
let fldPassword = document.getElementById('fldMqttPassword');
fldPassword.value = '';
fldPassword.placeholder = settings.hasPassword ? 'Saved (leave blank to keep)' : 'Password';
document.getElementById('divDiscoveryTopic').style.display = settings.pubDisco ? '' : 'none';
}
});
}
connectMQTT() {
let obj = ui.fromElement(document.getElementById('divMQTT'));
// A blank password keeps the stored one unless the username changed.
if (obj.mqtt.password === '' && obj.mqtt.username === this.savedUsername) delete obj.mqtt.password;
console.log(obj);
if (obj.mqtt.enabled) {
if (typeof obj.mqtt.hostname !== 'string' || obj.mqtt.hostname.length === 0) {
ui.errorMessage('Invalid host name').querySelector('.sub-message').innerHTML = 'You must supply a host name to connect to MQTT.';
return;
}
if (obj.mqtt.hostname.length > 64) {
ui.errorMessage('Invalid host name').querySelector('.sub-message').innerHTML = 'The maximum length of the host name is 64 characters.';
return;
}
if (isNaN(obj.mqtt.port) || obj.mqtt.port < 0) {
ui.errorMessage('Invalid port number').querySelector('.sub-message').innerHTML = 'Likely ports are 1183, 8883 for MQTT/S or 80,443 for HTTP/S';
return;
}
if (typeof obj.mqtt.username === 'string' && obj.mqtt.username.length > 32) {
ui.errorMessage('Invalid Username').querySelector('.sub-message').innerHTML = 'The maximum length of the username is 32 characters.';
return;
}
if (typeof obj.mqtt.password === 'string' && obj.mqtt.password.length > 32) {
ui.errorMessage('Invalid Password').querySelector('.sub-message').innerHTML = 'The maximum length of the password is 32 characters.';
return;
}
if (typeof obj.mqtt.rootTopic === 'string' && obj.mqtt.rootTopic.length > 64) {
ui.errorMessage('Invalid Root Topic').querySelector('.sub-message').innerHTML = 'The maximum length of the root topic is 64 characters.';
return;
}
}
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);
}, true);
}
}
var mqtt = new MQTT();
class Firmware {
initialized = false;
init() { this.initialized = true; }
isMobile() {
let agt = navigator.userAgent.toLowerCase();
return /Android|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Kindle|Silk|Opera Mini/i.test(navigator.userAgent);
}
async backup() {
let overlay = ui.waitMessage(document.getElementById('divContainer'));
return await new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onreadystatechange = (evt) => {
if (xhr.readyState === 4 && xhr.status === 200) {
let obj = window.URL.createObjectURL(xhr.response);
var link = document.createElement('a');
document.body.appendChild(link);
let header = xhr.getResponseHeader('content-disposition');
let fname = 'backup';
if (typeof header !== 'undefined') {
let start = header.indexOf('filename="');
if (start >= 0) {
let length = header.length;
fname = header.substring(start + 10, length - 1);
}
}
console.log(fname);
link.setAttribute('download', fname);
link.setAttribute('href', obj);
link.click();
link.remove();
setTimeout(() => { window.URL.revokeObjectURL(obj); console.log('Revoked object'); }, 0);
}
};
xhr.onload = (evt) => {
if (typeof overlay !== 'undefined') overlay.remove();
let status = xhr.status;
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`;
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
console.log('Done');
reject(err);
}
else {
resolve();
}
};
xhr.onerror = (evt) => {
if (typeof overlay !== 'undefined') overlay.remove();
let err = {
htmlError: xhr.status || 500,
service: `GET /backup`
};
if (typeof err.desc === 'undefined') err.desc = xhr.statusText || httpStatusText[xhr.status || 500];
console.log(err);
reject(err);
};
xhr.onabort = (evt) => {
if (typeof overlay !== 'undefined') overlay.remove();
console.log('Aborted');
if (typeof overlay !== 'undefined') overlay.remove();
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();
});
}
restore() {
let div = this.createFileUploader('/restore');
let inst = div.querySelector('div[id=divInstText]');
let html = '
Select a backup file that you would like to restore and the options you would like to restore then press the Upload File button.
';
html += `
Restoring network settings from a different board than the original will ignore Ethernet chip settings. Security, MQTT and WiFi connection information will also not be restored since backup files do not contain passwords.
`;
html += '
';
html += ``;
html += ``;
html += ``;
html += ``
html += ``
html += ``;
html += '
';
inst.innerHTML = html;
document.getElementById('divContainer').appendChild(div);
}
createFileUploader(service) {
let div = document.createElement('div');
div.setAttribute('id', 'divUploadFile');
div.setAttribute('class', 'inst-overlay');
div.style.width = '100%';
div.style.alignContent = 'center';
let html = `
`;
div.innerHTML = html;
return div;
}
procMemoryStatus(mem) {
console.log(mem);
let sp = document.getElementById('spanFreeMemory');
if (sp) sp.innerHTML = mem.free.fmt("#,##0");
sp = document.getElementById('spanMaxMemory');
if (sp) sp.innerHTML = mem.max.fmt('#,##0');
sp = document.getElementById('spanMinMemory');
if (sp) sp.innerHTML = mem.min.fmt('#,##0');
sp = document.getElementById('spanUptime');
if (sp) {
let t = Math.floor(mem.uptime / 1000);
let d = Math.floor(t / 86400); t %= 86400;
let h = Math.floor(t / 3600); t %= 3600;
let m = Math.floor(t / 60);
let s = t % 60;
let parts = [];
if (d > 0) parts.push(d + 'd');
if (h > 0) parts.push(h + 'h');
if (m > 0) parts.push(m + 'm');
if (s > 0 || parts.length === 0) parts.push(s + 's');
sp.innerHTML = parts.join(' ');
}
}
procFwStatus(rel) {
let div = document.getElementById('divFirmwareUpdate');
div.style.color = 'black';
div.innerHTML = `Firmware ${rel.fwVersion.name} Installed`;
div.style.display = '';
}
updateFirmware() {
let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || '';
// Firmware from before the field leaves it out: undefined, not false.
if (proto.model.header.hasOtaSlot === false) {
ui.infoMessage(`This device has no OTA slot, so its firmware is flashed over USB with SomfyController.onboard.esp32${chip}.bin, from the release's SomfyController.onboard.esp32${chip}.bin.zip. That image holds the web UI and an empty configuration too, so take a backup first and restore it afterwards.`);
return;
}
let div = this.createFileUploader('/updateFirmware');
let inst = div.querySelector('div[id=divInstText]');
let html = `
Select a binary file [SomfyController.ino.esp32${chip}.bin] containing the device firmware then press the Upload File button.
`;
if (this.isMobile()) {
html += `
WARNING
`;
html += '
This browser does not support automatic backups. It is highly recommended that you back up your configuration using the backup button before proceeding.
';
}
else
html += '
A backup file for your configuration will be downloaded to your browser. If the firmware update process fails please restore this file using the restore button after going through the onboarding process.
'
inst.innerHTML = html;
document.getElementById('divContainer').appendChild(div);
if (this.isMobile()) document.getElementById('btnBackupCfg').style.display = 'inline-block';
}
updateApplication() {
let div = this.createFileUploader('/updateApplication');
general.reloadApp = true;
let inst = div.querySelector('div[id=divInstText]');
let chip = document.getElementById('divContainer').getAttribute('data-chipmodel') || '';
inst.innerHTML = `
Select a binary file [SomfyController.littlefs.esp32${chip}.bin] containing the littlefs data for the application then press the Upload File button.
`;
if (this.isMobile()) {
inst.innerHTML += `
WARNING
`;
inst.innerHTML += '
This browser does not support automatic backups. It is highly recommended that you back up your configuration using the backup button before proceeding.
';
}
else
inst.innerHTML += '
A backup file for your configuration will be downloaded to your browser. If the application update process fails please restore this file using the restore button
';
document.getElementById('divContainer').appendChild(div);
if(this.isMobile()) document.getElementById('btnBackupCfg').style.display = 'inline-block';
}
async uploadFile(service, el, data) {
let field = el.querySelector('input[type="file"]');
let filename = field.value;
console.log(filename);
let formData = new FormData();
formData.append('file', field.files[0]);
switch (service) {
case '/updateApplication':
if (typeof filename !== 'string' || filename.length === 0) {
ui.errorMessage('You must select a littleFS binary file to proceed.');
return;
}
else if (filename.indexOf('.littlefs') === -1 || !filename.endsWith('.bin')) {
ui.errorMessage('This file is not a valid littleFS binary file.');
return;
}
if (!this.isMobile()) {
console.log('Starting backup');
try {
await firmware.backup();
console.log('Backup Complete');
}
catch (err) {
ui.serviceError(el, err);
return;
}
}
break;
case '/updateFirmware':
if (typeof filename !== 'string' || filename.length === 0) {
ui.errorMessage('You must select a valid firmware binary file to proceed.');
return;
}
else if (filename.indexOf('.ino.') === -1 || !filename.endsWith('.bin')) {
ui.errorMessage(el, 'This file is not a valid firmware binary file.');
return;
}
if (!this.isMobile()) {
console.log('Starting backup');
try {
await firmware.backup();
console.log('Backup Complete');
}
catch(err) {
ui.serviceError(el, err);
return;
}
}
break;
case '/restore':
if (typeof filename !== 'string' || filename.length === 0) {
ui.errorMessage('You must select a valid backup file to proceed.');
return;
}
else if (field.files[0].size > 20480) {
ui.errorMessage(el, `This file is ${field.files[0].size.fmt("#,##0")} bytes in length. This file is too large to be a valid backup file.`);
return;
}
else if (!filename.endsWith('.backup')) {
ui.errorMessage(el, 'This file is not a valid backup file');
return;
}
if (!data.shades && !data.settings && !data.network && !data.transceiver && !data.repeaters && !data.mqtt) {
ui.errorMessage(el, 'No restore options have been selected');
return;
}
console.log(data);
formData.append('data', JSON.stringify(data));
console.log(formData.get('data'));
//return;
break;
}
let btnUpload = el.querySelector('button[id="btnUploadFile"]');
let btnCancel = el.querySelector('button[id="btnClose"]');
let btnBackup = el.querySelector('button[id="btnBackupCfg"]');
btnBackup.style.display = 'none';
btnUpload.style.display = 'none';
field.disabled = true;
let btnSelectFile = el.querySelector('div[id="btn-select-file"]');
let prog = el.querySelector('div[id="progFileUpload"]');
prog.style.display = '';
btnSelectFile.style.visibility = 'hidden';
let xhr = new XMLHttpRequest();
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}%`);
prog.setAttribute('data-progress', `${pct}%`);
console.log(evt);
};
xhr.onerror = function (err) {
console.log(err);
ui.serviceError(el, err);
};
xhr.onload = function () {
console.log('File upload load called');
btnCancel.innerText = 'Close';
if (xhr.status === 401) {
el.remove();
security.tokenRefused();
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
// The device's reason, when it sent one.
let desc = `The upload failed (${xhr.status}).`;
try { desc = JSON.parse(xhr.responseText).desc || desc; } catch (e) { }
ui.errorMessage(el, desc);
return;
}
switch (service) {
case '/restore':
(async () => {
await somfy.init();
if (document.getElementById('divUploadFile')) document.getElementById('divUploadFile').remove();
})();
break;
case '/updateApplication':
break;
}
};
xhr.send(formData);
btnCancel.addEventListener('click', (e) => {
console.log('Cancel clicked');
xhr.abort();
});
}
}
var firmware = new Firmware();
window.addEventListener('load', async () => {
// 1. Initialize your main application logic
// await init();
// 2. Fetch and display the app version
appVersion = await getAppVersion();
const spanAppVersion = document.getElementById('spanAppVersion');
spanAppVersion.innerText = `${appVersion.trim()}`;
console.log("Application fully loaded and version updated.");
});