var Debug = false;
|
|
|
|
class UrlsBase {
|
|
constructor(root) {
|
|
this.root = root;
|
|
|
|
const paths = ["ws", "upgrade", "config", "auth"];
|
|
paths.forEach((path) => {
|
|
this[path] = new URL(path, root);
|
|
this[path].protocol = root.protocol;
|
|
});
|
|
|
|
if (this.root.protocol === "https:") {
|
|
this.ws.protocol = "wss:";
|
|
} else {
|
|
this.ws.protocol = "ws:";
|
|
}
|
|
}
|
|
}
|
|
|
|
var Urls = null;
|
|
|
|
var WebsockPingPong = null;
|
|
var Websock = {
|
|
send: function() {
|
|
},
|
|
close: function() {
|
|
}
|
|
};
|
|
|
|
class SettingsBase {
|
|
constructor() {
|
|
this.counters = {};
|
|
this.resetCounters();
|
|
this.saved = false;
|
|
}
|
|
|
|
resetCounters() {
|
|
this.counters.changed = 0;
|
|
this.counters.reboot = 0;
|
|
this.counters.reconnect = 0;
|
|
this.counters.reload = 0;
|
|
}
|
|
}
|
|
|
|
var Settings = new SettingsBase();
|
|
|
|
var Enumerable = {};
|
|
|
|
var FreeSize = 0;
|
|
var Now = 0;
|
|
var Ago = 0;
|
|
|
|
class CmdOutputBase {
|
|
constructor(elem) {
|
|
this.elem = elem;
|
|
this.lastScrollHeight = elem.scrollHeight;
|
|
this.lastScrollTop = elem.scrollTop;
|
|
this.followScroll = true;
|
|
|
|
elem.addEventListener("scroll", () => {
|
|
// in case we adjust the scroll manually
|
|
const current = this.elem.scrollHeight - this.elem.scrollTop;
|
|
const last = this.lastScrollHeight - this.lastScrollTop;
|
|
if ((current - last) > 16) {
|
|
this.followScroll = false;
|
|
}
|
|
|
|
// ...and, in case we return to the bottom row
|
|
const offset = current - this.elem.offsetHeight;
|
|
if (offset < 16) {
|
|
this.followScroll = true;
|
|
}
|
|
|
|
this.lastScrollHeight = this.elem.scrollHeight;
|
|
this.lastScrollTop = this.elem.scrollTop;
|
|
});
|
|
}
|
|
|
|
follow() {
|
|
if (this.followScroll) {
|
|
this.elem.scrollTop = this.elem.scrollHeight;
|
|
this.lastScrollHeight = this.elem.scrollHeight;
|
|
this.lastScrollTop = this.elem.scrollTop;
|
|
}
|
|
}
|
|
|
|
clear() {
|
|
this.elem.textContent = "";
|
|
this.followScroll = true;
|
|
}
|
|
|
|
push(line) {
|
|
this.elem.appendChild(new Text(line));
|
|
}
|
|
|
|
pushAndFollow(line) {
|
|
this.elem.appendChild(new Text(`${line}\n`));
|
|
this.followScroll = true
|
|
}
|
|
}
|
|
|
|
var CmdOutput = null;
|
|
|
|
//removeIf(!light)
|
|
var ColorPicker;
|
|
//endRemoveIf(!light)
|
|
|
|
//removeIf(!rfm69)
|
|
var Rfm69 = {
|
|
filters: {}
|
|
};
|
|
//endRemoveIf(!rfm69)
|
|
|
|
//removeIf(!sensor)
|
|
var Magnitudes = {
|
|
properties: {},
|
|
errors: {},
|
|
types: {},
|
|
units: {
|
|
names: {},
|
|
supported: {}
|
|
},
|
|
typePrefix: {},
|
|
prefixType: {}
|
|
};
|
|
|
|
function magnitudeTypedKey(magnitude, name) {
|
|
const prefix = Magnitudes.typePrefix[magnitude.type];
|
|
const index = magnitude.index_global;
|
|
return `${prefix}${name}${index}`;
|
|
}
|
|
|
|
//endRemoveIf(!sensor)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Utils
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function showErrorNotification(message) {
|
|
let container = document.getElementById("error-notification");
|
|
if (container.childElementCount > 0) {
|
|
return false;
|
|
}
|
|
|
|
container.style.display = "inherit";
|
|
container.style.whiteSpace = "pre-wrap";
|
|
|
|
let notification = document.createElement("div");
|
|
notification.classList.add("pure-u-1");
|
|
notification.classList.add("pure-u-lg-1");
|
|
notification.textContent = message;
|
|
|
|
container.appendChild(notification);
|
|
|
|
return false;
|
|
}
|
|
|
|
function notifyError(message, source, lineno, colno, error) {
|
|
let text = "";
|
|
if (error) {
|
|
text = error.stack;
|
|
} else {
|
|
text = message;
|
|
}
|
|
|
|
text += "\n\nFor more info see the Debug Log and / or Developer Tools console.";
|
|
|
|
return showErrorNotification(text);
|
|
}
|
|
|
|
window.onerror = notifyError;
|
|
|
|
// TODO: per https://www.chromestatus.com/feature/6140064063029248, using <base target="_blank"> should be enough with recent browsers
|
|
// but, side menu needs to be reworked for it to correctly handle panel switching, since it uses <a href="#" ...>
|
|
// TODO: also could be done in htmlparser2 + gulp (even, preferably)
|
|
|
|
function initExternalLinks() {
|
|
for (let elem of document.getElementsByClassName("external")) {
|
|
if (elem.tagName === "A") {
|
|
elem.setAttribute("target", "_blank");
|
|
elem.setAttribute("rel", "noopener");
|
|
elem.setAttribute("tabindex", "-1");
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: note that we also include kv schema as 'data-settings-schema' on the container.
|
|
// produce a 'set' and compare instead of just matching length?
|
|
function fromSchema(source, schema) {
|
|
if (schema.length !== source.length) {
|
|
throw `Schema mismatch! Expected length ${schema.length} vs. ${source.length}`;
|
|
}
|
|
|
|
var target = {};
|
|
schema.forEach((key, index) => {
|
|
target[key] = source[index];
|
|
});
|
|
|
|
return target;
|
|
}
|
|
|
|
function keepTime() {
|
|
document.querySelector("span[data-key='ago']").textContent = Ago;
|
|
++Ago;
|
|
|
|
if (0 === Now) {
|
|
return;
|
|
}
|
|
|
|
let text = (new Date(Now * 1000))
|
|
.toISOString().substring(0, 19)
|
|
.replace("T", " ");
|
|
|
|
document.querySelector("span[data-key='now']").textContent = text;
|
|
++Now;
|
|
}
|
|
|
|
function setUptime(value) {
|
|
let uptime = parseInt(value, 10);
|
|
|
|
let seconds = uptime % 60;
|
|
uptime = parseInt(uptime / 60, 10);
|
|
|
|
let minutes = uptime % 60;
|
|
uptime = parseInt(uptime / 60, 10);
|
|
|
|
let hours = uptime % 24;
|
|
uptime = parseInt(uptime / 24, 10);
|
|
|
|
let days = uptime;
|
|
|
|
let container = document.querySelector("span[data-key='uptime']");
|
|
container.textContent = days + "d " + zeroPad(hours, 2) + "h " + zeroPad(minutes, 2) + "m " + zeroPad(seconds, 2) + "s";
|
|
|
|
Ago = 0;
|
|
}
|
|
|
|
function zeroPad(number, positions) {
|
|
return number.toString().padStart(positions, "0");
|
|
}
|
|
|
|
function validatePassword(password) {
|
|
// http://www.the-art-of-web.com/javascript/validate-password/
|
|
// at least one lowercase and one uppercase letter or number
|
|
// at least eight characters (letters, numbers or special characters)
|
|
|
|
// MUST be 8..63 printable ASCII characters. See:
|
|
// https://en.wikipedia.org/wiki/Wi-Fi_Protected_Access#Target_users_(authentication_key_distribution)
|
|
// https://github.com/xoseperez/espurna/issues/1151
|
|
|
|
const Pattern = /^(?=.*[A-Z\d])(?=.*[a-z])[\w~!@#$%^&*()<>,.?;:{}[\]\\|]{8,63}/;
|
|
return (
|
|
(password !== undefined)
|
|
&& (typeof password === "string")
|
|
&& (password.length > 0)
|
|
&& Pattern.test(password));
|
|
}
|
|
|
|
// Try to validate 'adminPass{0,1}', searching the first form containing both.
|
|
// In case it's default webMode, avoid checking things when both fields are empty (`required === false`)
|
|
function validateFormsPasswords(forms, required) {
|
|
let [passwords] = Array.from(forms).filter(
|
|
form => form.elements.adminPass0 && form.elements.adminPass1);
|
|
|
|
if (passwords) {
|
|
let first = passwords.elements.adminPass0;
|
|
let second = passwords.elements.adminPass1;
|
|
|
|
if (!required && !first.value.length && !second.value.length) {
|
|
return true;
|
|
}
|
|
|
|
let firstValid = first.checkValidity() && validatePassword(first.value);
|
|
let secondValid = second.checkValidity() && validatePassword(second.value);
|
|
if (firstValid && secondValid) {
|
|
if (first.value === second.value) {
|
|
return true;
|
|
}
|
|
|
|
alert("Passwords are different!");
|
|
return false;
|
|
}
|
|
|
|
alert("The password you have entered is not valid, it must be 8..63 characters and have at least 1 lowercase and 1 uppercase / number!");
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Same as above, but only applies to the general settings page.
|
|
// Find the first available form that contains 'hostname' input
|
|
function validateFormsHostname(forms) {
|
|
// per. [RFC1035](https://datatracker.ietf.org/doc/html/rfc1035)
|
|
// Hostname may contain:
|
|
// - the ASCII letters 'a' through 'z' (case-insensitive),
|
|
// - the digits '0' through '9', and the hyphen.
|
|
// Hostname labels cannot begin or end with a hyphen.
|
|
// No other symbols, punctuation characters, or blank spaces are permitted.
|
|
let [hostname] = Array.from(forms).filter(form => form.elements.hostname);
|
|
if (!hostname) {
|
|
return true;
|
|
}
|
|
|
|
// Validation pattern is attached to the element itself, so just check that.
|
|
// (and, we also re-use the hostname for fallback SSID, thus limited to 1...32 chars instead of 1...63)
|
|
|
|
hostname = hostname.elements.hostname;
|
|
let result = hostname.value.length
|
|
&& (!isChangedElement(hostname) || hostname.checkValidity());
|
|
|
|
if (!result) {
|
|
alert("Hostname cannot be empty and may only contain the ASCII letters ('A' through 'Z' and 'a' through 'z'), the digits '0' through '9', and the hyphen ('-')! They can neither start or end with an hyphen.");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateForms(forms) {
|
|
return validateFormsPasswords(forms) && validateFormsHostname(forms);
|
|
}
|
|
|
|
// Right now, group additions happen from:
|
|
// - WebSocket, likely to happen exactly once per connection through processData handler(s). Specific keys trigger functions that append into the container element.
|
|
// - User input. Same functions are triggered, but with an additional event for the container element that causes most recent element to be marked as changed.
|
|
// Removal only happens from user input. MutationObserver will refresh checkboxes and cause everything to be marked as changed.
|
|
//
|
|
// TODO: distinguish 'current' state to avoid sending keys when adding and immediatly removing the latest node?
|
|
// TODO: previous implementation relied on defaultValue and / or jquery $(...).val(), but this does not really work where 'line' only has <select>
|
|
|
|
function groupElementInfo(target) {
|
|
const out = [];
|
|
|
|
const inputs = target.querySelectorAll("input,select");
|
|
inputs.forEach((elem) => {
|
|
const name = elem.dataset.settingsRealName || elem.name;
|
|
if (name === undefined) {
|
|
return;
|
|
}
|
|
|
|
out.push({
|
|
element: elem,
|
|
key: name,
|
|
value: elem.dataset["original"] || getDataForElement(elem)
|
|
});
|
|
});
|
|
|
|
return out;
|
|
}
|
|
|
|
const groupSettingsHandler = {
|
|
// to 'instantiate' a new element, we must explicitly set 'target' keys in kvs
|
|
// notice that the 'row' creation *should* be handled by the group-specific
|
|
// event listener, we already expect the dom element to exist at this point
|
|
add: function(event) {
|
|
const group = event.target;
|
|
const index = group.children.length - 1;
|
|
const last = group.children[index];
|
|
addGroupPending(group, index);
|
|
|
|
for (const target of settingsTargets(group)) {
|
|
const elem = last.querySelector(`[name='${target}']`);
|
|
if (elem) {
|
|
setChangedElement(elem);
|
|
}
|
|
}
|
|
},
|
|
// removing the element means we need to notify the kvs about the updated keys
|
|
// in case it's the last row, just remove those keys from the store
|
|
// in case we are in the middle, make sure to handle difference update
|
|
// in case change was 'ephemeral' (i.e. from the previous add that was not saved), do nothing
|
|
del: function(event) {
|
|
const group = event.currentTarget;
|
|
|
|
const elems = Array.from(group.children);
|
|
const shiftFrom = elems.indexOf(group);
|
|
|
|
const info = elems.map(groupElementInfo);
|
|
for (let index = -1; index < info.length; ++index) {
|
|
const prev = (index > 0)
|
|
? info[index - 1]
|
|
: null;
|
|
const current = info[index];
|
|
|
|
if ((index > shiftFrom) && prev && (prev.length === current.length)) {
|
|
for (let inner = 0; inner < prev.length; ++inner) {
|
|
const [lhs, rhs] = [prev[inner], current[inner]];
|
|
if (lhs.value !== rhs.value) {
|
|
setChangedElement(rhs.element);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateCheckboxes(group);
|
|
if (elems.length) {
|
|
popGroupPending(group, elems.length - 1);
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
event.target.remove();
|
|
}
|
|
};
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Settings groups & templates
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// Styled checkboxes require unique label for=... and input id=..., which are
|
|
// generated based on the current position of the element. When removing the node,
|
|
// elements must be re-enumerated and assigned new id's to remain unique
|
|
// (and avoid having one line's checkbox affecting some other one)
|
|
|
|
function createCheckbox(checkbox) {
|
|
checkbox.id = checkbox.name;
|
|
checkbox.parentElement.classList.add("toggleWrapper");
|
|
|
|
const label = document.createElement("label");
|
|
label.classList.add("toggle");
|
|
label.htmlFor = checkbox.id;
|
|
|
|
const span = document.createElement("span");
|
|
span.classList.add("toggle__handler");
|
|
label.appendChild(span);
|
|
|
|
checkbox.parentElement.appendChild(label);
|
|
}
|
|
|
|
function createCheckboxes(node) {
|
|
const checkboxes = node.querySelectorAll("input[type='checkbox']");
|
|
for (const checkbox of checkboxes) {
|
|
createCheckbox(checkbox);
|
|
}
|
|
}
|
|
|
|
function updateCheckboxes(node) {
|
|
for (let id = 0; id < node.childElementCount; ++id) {
|
|
let schedule = node.children[id];
|
|
for (let checkbox of schedule.querySelectorAll("input[type='checkbox']")) {
|
|
let realId = checkbox.name.concat(id);
|
|
checkbox.setAttribute("id", realId);
|
|
checkbox.nextElementSibling.setAttribute("for", realId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generic parts of the HTML are placed into <template> container, which requires
|
|
// us to 'import' it into the currently loaded page to actually use it.
|
|
// (and notice that document.querySelector(...) won't be able to read inside of these)
|
|
|
|
function loadTemplate(name) {
|
|
let template = document.getElementById(`template-${name}`);
|
|
return document.importNode(template.content, true);
|
|
}
|
|
|
|
function loadConfigTemplate(id) {
|
|
let template = loadTemplate(id);
|
|
for (let elem of template.querySelectorAll("input,select")) {
|
|
elem.dataset["settingsGroupElement"] = "true";
|
|
}
|
|
|
|
for (let elem of template.querySelectorAll("button.button-del-parent")) {
|
|
elem.addEventListener("click", delParent);
|
|
}
|
|
|
|
for (let elem of template.querySelectorAll("button.button-more-parent")) {
|
|
elem.addEventListener("click", moreParent);
|
|
}
|
|
|
|
for (let elem of template.querySelectorAll("select.enumerable")) {
|
|
initEnumerableSelect(elem, initSelect);
|
|
}
|
|
|
|
createCheckboxes(template);
|
|
return template;
|
|
}
|
|
|
|
function mergeTemplate(target, template) {
|
|
for (let child of Array.from(template.children)) {
|
|
target.appendChild(child);
|
|
}
|
|
}
|
|
|
|
function addFromTemplate(container, template, cfg) {
|
|
const line = loadConfigTemplate(template);
|
|
fillTemplateLineFromCfg(line, container.childElementCount, cfg);
|
|
mergeTemplate(container, line);
|
|
}
|
|
|
|
// 'settings-group' contain elements that represent kv list that is suffixed with an index in raw kvs
|
|
// 'button-add-settings-group' will trigger update on the specified 'data-settings-group' element id, which
|
|
// needs to have 'settings-group-add' event handler attached to it.
|
|
|
|
function groupSettingsOnAdd(elementId, listener) {
|
|
document.getElementById(elementId).addEventListener("settings-group-add", listener);
|
|
}
|
|
|
|
// handle addition to the group via the button
|
|
// (notice that since we still use the dataset for the elements, hyphens are just capitalized)
|
|
function groupSettingsAdd(event) {
|
|
const prefix = "settingsGroupDetail";
|
|
const elem = event.target;
|
|
|
|
let eventInit = {detail: null};
|
|
for (let key of Object.keys(elem.dataset)) {
|
|
if (!key.startsWith(prefix)) {
|
|
continue
|
|
}
|
|
if (eventInit.detail === null) {
|
|
eventInit.detail = {};
|
|
}
|
|
|
|
let eventKey = key.replace(prefix, "");
|
|
eventKey = eventKey[0].toLowerCase() + eventKey.slice(1);
|
|
eventInit.detail[eventKey] = elem.dataset[key];
|
|
}
|
|
|
|
const group = document.getElementById(elem.dataset["settingsGroup"]);
|
|
group.dispatchEvent(new CustomEvent("settings-group-add", eventInit));
|
|
}
|
|
|
|
// When receiving / returning data, <select multiple=true> <option> values are treated as bitset (u32) indexes (i.e. individual bits that are set)
|
|
// For example 0b101 is translated to ["0", "2"], or 0b1111 is translated to ["0", "1", "2", "3"]
|
|
// Right now only `hbReport` uses such format, but it is not yet clear how such select element should behave when value is not an integer
|
|
|
|
function bitsetToSelectedValues(bitset) {
|
|
let values = [];
|
|
for (let index = 0; index < 31; ++index) {
|
|
if (bitset & (1 << index)) {
|
|
values.push(index.toString());
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
|
|
function bitsetFromSelectedValues(select) {
|
|
let result = 0;
|
|
for (let option of select.selectedOptions) {
|
|
result |= 1 << parseInt(option.value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// <select data-original="..."> is read / saved as:
|
|
// - multiple=false -> value string of the selected option
|
|
// - multiple=true -> comma-separated values of all selected options
|
|
//
|
|
// If selectedIndex is -1, it means we never selected anything
|
|
// (TODO: could this actually happen with anything other than empty <select>?)
|
|
|
|
function stringifySelectedValues(select) {
|
|
if (select.multiple) {
|
|
return Array.from(select.selectedOptions)
|
|
.map(option => option.value)
|
|
.join(",");
|
|
} else if (select.selectedIndex >= 0) {
|
|
return select.options[select.selectedIndex].value;
|
|
}
|
|
|
|
return select.dataset["original"];
|
|
}
|
|
|
|
function elementSelectorListener(selector, event, listener) {
|
|
for (let elem of document.querySelectorAll(selector)) {
|
|
elem.addEventListener(event, listener);
|
|
}
|
|
}
|
|
|
|
function elementSelectorOnClick(selector, listener) {
|
|
elementSelectorListener(selector, "click", listener);
|
|
}
|
|
|
|
function isChangedElement(elem) {
|
|
return "true" === elem.dataset["changed"];
|
|
}
|
|
|
|
function setChangedElement(elem) {
|
|
elem.dataset["changed"] = "true";
|
|
}
|
|
|
|
function resetChangedElement(elem) {
|
|
elem.dataset["changed"] = "false";
|
|
}
|
|
|
|
function resetGroupPending(elem) {
|
|
elem.dataset["settingsGroupPending"] = "";
|
|
}
|
|
|
|
function resetSettingsGroup() {
|
|
const elems = document.getElementsByClassName("settings-group");
|
|
for (let elem of elems) {
|
|
resetChangedElement(elem);
|
|
resetGroupPending(elem);
|
|
}
|
|
}
|
|
|
|
function getGroupPending(elem) {
|
|
const raw = elem.dataset["settingsGroupPending"] || "";
|
|
if (!raw.length) {
|
|
return [];
|
|
}
|
|
|
|
return raw.split(",");
|
|
}
|
|
|
|
function addGroupPending(elem, index) {
|
|
const pending = getGroupPending(elem);
|
|
pending.push(`set:${index}`);
|
|
elem.dataset["settingsGroupPending"] = pending.join(",");
|
|
}
|
|
|
|
function popGroupPending(elem, index) {
|
|
const pending = getGroupPending(elem);
|
|
|
|
const added = pending.indexOf(`set:${index}`);
|
|
if (added >= 0) {
|
|
pending.splice(added, 1);
|
|
} else {
|
|
pending.push(`del:${index}`);
|
|
}
|
|
|
|
elem.dataset["settingsGroupPending"] = pending.join(",");
|
|
}
|
|
|
|
function isGroupElement(elem) {
|
|
return elem.dataset["settingsGroupElement"] !== undefined;
|
|
}
|
|
|
|
function isIgnoredElement(elem) {
|
|
return elem.dataset["settingsIngore"] !== undefined;
|
|
}
|
|
|
|
function settingsTargets(elem) {
|
|
let targets = elem.dataset["settingsTarget"];
|
|
if (!targets) {
|
|
return [];
|
|
}
|
|
|
|
return targets.split(" ");
|
|
}
|
|
|
|
function stringToBoolean(value) {
|
|
return (value === "1")
|
|
|| (value === "y")
|
|
|| (value === "yes")
|
|
|| (value === "true")
|
|
|| (value === "on");
|
|
}
|
|
|
|
function booleanToString(value) {
|
|
return value ? "true" : "false";
|
|
}
|
|
|
|
function setInputValue(input, value) {
|
|
switch (input.type) {
|
|
case "radio":
|
|
input.checked = (value === input.value);
|
|
break;
|
|
case "checkbox":
|
|
input.checked =
|
|
(typeof(value) === "boolean") ? value :
|
|
(typeof(value) === "string") ? stringToBoolean(value) :
|
|
(typeof(value) === "number") ? (value !== 0) : false;
|
|
break;
|
|
case "text":
|
|
case "password":
|
|
case "number":
|
|
input.value = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
function setSpanValue(span, value) {
|
|
if (Array.isArray(value)) {
|
|
value.forEach((text) => {
|
|
setSpanValue(span, text);
|
|
span.appendChild(document.createElement("br"));
|
|
});
|
|
} else {
|
|
let content = "";
|
|
if (span.attributes.pre) {
|
|
content += span.attributes.pre.value;
|
|
}
|
|
content += value;
|
|
if (span.attributes.post) {
|
|
content += span.attributes.post.value;
|
|
}
|
|
span.textContent = content;
|
|
}
|
|
}
|
|
|
|
function setSelectValue(select, value) {
|
|
const values = select.multiple
|
|
? bitsetToSelectedValues(value)
|
|
: [value.toString()];
|
|
|
|
Array.from(select.options)
|
|
.filter((option) => values.includes(option.value))
|
|
.forEach((option) => {
|
|
option.selected = true;
|
|
});
|
|
|
|
select.dataset["original"] = values.join(",");
|
|
}
|
|
|
|
// TODO: <input type="radio"> is a special beast, since the actual value is one of 'checked' elements with the same name=... attribute.
|
|
// Right now, WebUI does not use this kind of input, but in case it does this needs a once-over that the actual input value is picked up correctly through all of changed / original comparisons.
|
|
|
|
// Not all of available forms are used for settings:
|
|
// - terminal input, which is implemented with an input field. it is attributed with `action="none"`, so settings handler never treats it as 'changed'
|
|
// - initial setup. it is shown programatically, but is still available from the global list of forms
|
|
|
|
function getDataForElement(element) {
|
|
switch (element.tagName) {
|
|
case "INPUT":
|
|
switch (element.type) {
|
|
case "radio":
|
|
if (element.checked) {
|
|
return element.value;
|
|
}
|
|
return null;
|
|
case "checkbox":
|
|
return element.checked ? 1 : 0;
|
|
case "number":
|
|
case "text":
|
|
case "password":
|
|
case "hidden":
|
|
case "range":
|
|
// notice that we set directly to storage, thus strings are just fine
|
|
return element.value;
|
|
}
|
|
break;
|
|
case "SELECT":
|
|
if (element.multiple) {
|
|
return bitsetFromSelectedValues(element);
|
|
} else if (element.selectedIndex >= 0) {
|
|
return element.options[element.selectedIndex].value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getData(forms, options) {
|
|
// Populate two sets of data, ones that had been changed and ones that stayed the same
|
|
if (options === undefined) {
|
|
options = {};
|
|
}
|
|
|
|
const data = {};
|
|
const changed_data = [];
|
|
if (options.cleanup === undefined) {
|
|
options.cleanup = true;
|
|
}
|
|
|
|
if (options.changed === undefined) {
|
|
options.changed = true;
|
|
}
|
|
|
|
const group_counter = {};
|
|
|
|
// TODO: <input type="radio"> can be found as both individual elements and as a `RadioNodeList` view.
|
|
// matching will extract the specific radio element, but will ignore the list b/c it has no tagName
|
|
// TODO: actually use type="radio" in the WebUI to check whether this works
|
|
for (let form of forms) {
|
|
for (let elem of form.elements) {
|
|
if ((elem.tagName !== "SELECT") && (elem.tagName !== "INPUT")) {
|
|
continue;
|
|
}
|
|
|
|
if (isIgnoredElement(elem)) {
|
|
continue;
|
|
}
|
|
|
|
const name = elem.dataset.settingsRealName || elem.name;
|
|
if (name === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const group_element = isGroupElement(elem);
|
|
const group_index = group_counter[name] || 0;
|
|
const group_name = `${name}${group_index}`;
|
|
if (group_element) {
|
|
group_counter[name] = group_index + 1;
|
|
}
|
|
|
|
const value = getDataForElement(elem);
|
|
if (null !== value) {
|
|
const elem_indexed = changed_data.indexOf(name) >= 0;
|
|
if ((isChangedElement(elem) || !options.changed) && !elem_indexed) {
|
|
changed_data.push(group_element ? group_name : name);
|
|
}
|
|
|
|
data[group_element ? group_name : name] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finally, filter out only fields that *must* be assigned.
|
|
const resulting_data = {
|
|
set: {
|
|
},
|
|
del: [
|
|
]
|
|
};
|
|
|
|
for (const name in data) {
|
|
if (!options.changed || (changed_data.indexOf(name) >= 0)) {
|
|
resulting_data.set[name] = data[name];
|
|
}
|
|
}
|
|
|
|
// Make sure to remove dynamic group entries from the kvs
|
|
// Only group keys can be removed atm, so only process .settings-group
|
|
if (options.cleanup) {
|
|
for (let elem of document.getElementsByClassName("settings-group")) {
|
|
for (let pair of getGroupPending(elem)) {
|
|
const [action, index] = pair.split(":");
|
|
if (action === "del") {
|
|
const keysRaw = elem.dataset["settingsSchema"] || elem.dataset["settingsTarget"];
|
|
const keys = !keysRaw ? [] : keysRaw.split(" ");
|
|
keys.forEach((key) => {
|
|
resulting_data.del.push(`${key}${index}`);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return resulting_data;
|
|
}
|
|
|
|
function randomString(length, args) {
|
|
if (typeof args === "undefined") {
|
|
args = {
|
|
lowercase: true,
|
|
uppercase: true,
|
|
numbers: true,
|
|
special: true
|
|
}
|
|
}
|
|
|
|
var mask = "";
|
|
if (args.lowercase) { mask += "abcdefghijklmnopqrstuvwxyz"; }
|
|
if (args.uppercase) { mask += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; }
|
|
if (args.numbers || args.hex) { mask += "0123456789"; }
|
|
if (args.hex) { mask += "ABCDEF"; }
|
|
if (args.special) { mask += "~`!@#$%^&*()_+-={}[]:\";'<>?,./|\\"; }
|
|
|
|
var source = new Uint32Array(length);
|
|
var result = new Array(length);
|
|
|
|
window.crypto.getRandomValues(source).forEach((value, i) => {
|
|
result[i] = mask[value % mask.length];
|
|
});
|
|
|
|
return result.join("");
|
|
}
|
|
|
|
function generateApiKey() {
|
|
let elem = document.forms["form-admin"].elements.apiKey;
|
|
elem.value = randomString(16, {hex: true});
|
|
setChangedElement(elem);
|
|
}
|
|
|
|
function generatePassword() {
|
|
let password = "";
|
|
do {
|
|
password = randomString(10);
|
|
} while (!validatePassword(password));
|
|
|
|
return password;
|
|
}
|
|
|
|
function toggleVisiblePassword(event) {
|
|
let elem = event.target.previousElementSibling;
|
|
if (elem.type === "password") {
|
|
elem.type = "text";
|
|
} else {
|
|
elem.type = "password";
|
|
}
|
|
}
|
|
|
|
function generatePasswordsForForm(form) {
|
|
const value = generatePassword();
|
|
for (let elem of [form.elements.adminPass0, form.elements.adminPass1]) {
|
|
setChangedElement(elem);
|
|
elem.type = "text";
|
|
elem.value = value;
|
|
}
|
|
}
|
|
|
|
function initSetupPassword(form) {
|
|
elementSelectorOnClick(".button-setup-password", (event) => {
|
|
event.preventDefault();
|
|
const forms = [form];
|
|
if (validateFormsPasswords(forms, true)) {
|
|
applySettings(getData(forms, true, false));
|
|
}
|
|
});
|
|
elementSelectorOnClick(".button-generate-password", (event) => {
|
|
event.preventDefault();
|
|
generatePasswordsForForm(form);
|
|
});
|
|
}
|
|
|
|
function styleInject(rules) {
|
|
if (!rules.length) {
|
|
return;
|
|
}
|
|
|
|
const style = document.createElement("style");
|
|
style.setAttribute("type", "text/css");
|
|
document.head.appendChild(style);
|
|
|
|
let pos = style.sheet.rules.length;
|
|
for (let rule of rules) {
|
|
style.sheet.insertRule(rule, pos++);
|
|
}
|
|
}
|
|
|
|
function styleVisible(selector, value) {
|
|
return `${selector} { content-visibility: ${value ? "visible": "hidden"}; }`
|
|
}
|
|
|
|
function moduleVisible(module) {
|
|
if (module === "sch") {
|
|
styleInject([
|
|
`li.module-${module} { display: inherit; }`,
|
|
`div.module-${module} { display: flex; }`
|
|
]);
|
|
} else {
|
|
styleInject([`.module-${module} { display: inherit; }`]);
|
|
}
|
|
}
|
|
|
|
// Update <input> and <select> elements that were set externally. Generic change handler will allow to compare with user input,
|
|
// allowing to send configuration in parts instead of all of it at once.
|
|
|
|
function setOriginalsFromValuesForNode(node, elems) {
|
|
if (elems === undefined) {
|
|
elems = [...node.querySelectorAll("input,select")];
|
|
}
|
|
|
|
for (let elem of elems) {
|
|
switch (elem.tagName) {
|
|
case "INPUT":
|
|
if (elem.type === "checkbox") {
|
|
elem.dataset["original"] = booleanToString(elem.checked);
|
|
} else {
|
|
elem.dataset["original"] = elem.value;
|
|
}
|
|
break;
|
|
case "SELECT":
|
|
elem.dataset["original"] = stringifySelectedValues(elem);
|
|
break;
|
|
}
|
|
resetChangedElement(elem);
|
|
}
|
|
}
|
|
|
|
function setOriginalsFromValues(elems) {
|
|
setOriginalsFromValuesForNode(document, elems);
|
|
}
|
|
|
|
// <select> initialization from simple {id: ..., name: ...} that map as <option> value=... and textContent
|
|
// To avoid depending on order of incoming messages, always store real value inside of dataset["original"] and provide a way to re-initialize every 'enumerable' <select> element on the page
|
|
//
|
|
// Notice that <select multiple> input and output format is u32 number, but the 'original' string is comma-separated <option> value=... attributes
|
|
|
|
function initSelect(select, values) {
|
|
for (let value of values) {
|
|
let option = document.createElement("option");
|
|
option.setAttribute("value", value.id);
|
|
option.textContent = value.name;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
function initEnumerableSelect(select, callback) {
|
|
for (let className of select.classList) {
|
|
const prefix = "enumerable-";
|
|
if (className.startsWith(prefix)) {
|
|
const name = className.replace(prefix, "");
|
|
if ((Enumerable[name] !== undefined) && Enumerable[name].length) {
|
|
callback(select, Enumerable[name]);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function refreshEnumerableSelect(name) {
|
|
const selector = (name !== undefined)
|
|
? `select.enumerable.enumerable-${name}`
|
|
: "select.enumerable";
|
|
|
|
for (let select of document.querySelectorAll(selector)) {
|
|
initEnumerableSelect(select, (_, enumerable) => {
|
|
while (select.childElementCount) {
|
|
select.removeChild(select.firstElementChild);
|
|
}
|
|
|
|
initSelect(select, enumerable);
|
|
|
|
const original = select.dataset["original"];
|
|
if (typeof original === "string" && original.length) {
|
|
setSelectValue(select, original);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function addEnumerables(name, enumerables) {
|
|
Enumerable[name] = enumerables;
|
|
refreshEnumerableSelect(name);
|
|
}
|
|
|
|
function addSimpleEnumerables(name, prettyName, count) {
|
|
if (count) {
|
|
let enumerables = [];
|
|
for (let id = 0; id < count; ++id) {
|
|
enumerables.push({"id": id, "name": `${prettyName} #${id}`});
|
|
}
|
|
|
|
addEnumerables(name, enumerables);
|
|
}
|
|
}
|
|
|
|
// Handle plain kv pairs when they are already on the page, and don't need special template handlers
|
|
// Notice that <span> uses a custom data attribute data-key=..., instead of name=...
|
|
|
|
function initGenericKeyValueElement(key, value) {
|
|
for (const span of document.querySelectorAll(`span[data-key='${key}']`)) {
|
|
setSpanValue(span, value);
|
|
}
|
|
|
|
const inputs = [];
|
|
for (const elem of document.querySelectorAll(`[name='${key}'`)) {
|
|
switch (elem.tagName) {
|
|
case "INPUT":
|
|
setInputValue(elem, value);
|
|
inputs.push(elem);
|
|
break;
|
|
case "SELECT":
|
|
setSelectValue(elem, value);
|
|
inputs.push(elem);
|
|
break;
|
|
}
|
|
}
|
|
|
|
setOriginalsFromValues(inputs);
|
|
}
|
|
|
|
// Or, handle special id-counted 'line' that was created from template
|
|
// Optional cfg kv object that will be used to pull values for input / select / span
|
|
//
|
|
// Additional handler for checkbox elements, since we need a document-wide unique id=...
|
|
// (also see {create,update}Checkboxes())
|
|
|
|
function fillTemplateLineFromCfg(line, id, cfg) {
|
|
if (cfg === undefined) {
|
|
cfg = {};
|
|
}
|
|
|
|
for (let elem of line.querySelectorAll("input,select,span")) {
|
|
let key = elem.name || elem.dataset.key;
|
|
if (key && (key in cfg)) {
|
|
switch (elem.tagName) {
|
|
case "INPUT":
|
|
setInputValue(elem, cfg[key]);
|
|
break;
|
|
case "SELECT":
|
|
setSelectValue(elem, cfg[key]);
|
|
break;
|
|
case "SPAN":
|
|
setSpanValue(elem, cfg[key]);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (elem.tagName === "INPUT" && elem.type === "checkbox") {
|
|
const realId = elem.name.concat(id);
|
|
elem.id = realId;
|
|
elem.nextElementSibling.htmlFor = realId;
|
|
}
|
|
}
|
|
|
|
setOriginalsFromValuesForNode(line);
|
|
}
|
|
|
|
|
|
function delParent(event) {
|
|
event.target.parentElement.dispatchEvent(
|
|
new CustomEvent("settings-group-del", {bubbles: true}));
|
|
}
|
|
|
|
function moreElem(container) {
|
|
for (let elem of container.querySelectorAll(".more")) {
|
|
elem.style.display = (elem.style.display === "")
|
|
? "inherit" : "";
|
|
}
|
|
}
|
|
|
|
function moreParent(event) {
|
|
moreElem(event.target.parentElement.parentElement);
|
|
}
|
|
|
|
function idForTemplateContainer(container) {
|
|
let id = container.childElementCount;
|
|
|
|
let settingsMax = container.dataset["settingsMax"];
|
|
if (settingsMax === undefined) {
|
|
return id;
|
|
}
|
|
|
|
let max = parseInt(settingsMax, 10);
|
|
if (id < max) {
|
|
return id;
|
|
}
|
|
|
|
alert(`Max number of ${container.id} has been reached (${id} out of ${max})`);
|
|
return -1;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// WebSocket Actions
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function send(payload) {
|
|
if (Debug) {
|
|
console.log(payload);
|
|
}
|
|
Websock.send(payload);
|
|
}
|
|
|
|
function sendAction(action, data) {
|
|
if (data === undefined) {
|
|
data = {};
|
|
}
|
|
send(JSON.stringify({action, data}));
|
|
}
|
|
|
|
function askSaveSettings(ask) {
|
|
if (Settings.counters.changed > 0) {
|
|
return ask("There are pending changes to the settings, continue the operation without saving?");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function askDisconnect(ask) {
|
|
return ask("Are you sure you want to disconnect from the current WiFi network?");
|
|
}
|
|
|
|
function askReboot(ask) {
|
|
return ask("Are you sure you want to reboot the device?");
|
|
}
|
|
|
|
function askAndCall(questions, callback) {
|
|
for (let question of questions) {
|
|
if (!question(window.confirm)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
callback();
|
|
}
|
|
|
|
function askAndCallReconnect() {
|
|
askAndCall([askSaveSettings, askDisconnect], () => {
|
|
sendAction("reconnect");
|
|
});
|
|
}
|
|
|
|
function askAndCallReboot() {
|
|
askAndCall([askSaveSettings, askReboot], () => {
|
|
sendAction("reboot");
|
|
});
|
|
}
|
|
|
|
function askAndCallAction(event) {
|
|
askAndCall([(ask) => ask(`Confirm the action: "${event.target.textContent}"`)], () => {
|
|
sendAction(event.target.name);
|
|
});
|
|
}
|
|
|
|
// Settings kv as either {key: value} or {key: [value0, value1, ...etc...]}
|
|
|
|
function applySettings(settings) {
|
|
send(JSON.stringify({settings}));
|
|
}
|
|
|
|
function resetOriginals() {
|
|
setOriginalsFromValues();
|
|
resetSettingsGroup();
|
|
Settings.resetCounters();
|
|
Settings.saved = false;
|
|
}
|
|
|
|
function pageReloadIn(milliseconds) {
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, parseInt(milliseconds, 10));
|
|
}
|
|
|
|
// Check whether the file object contains some known bytes
|
|
// - handle magic numbers at the start
|
|
// - handle SDK flash modes (and compare with the current one)
|
|
//
|
|
// Result is handled through callback:
|
|
// - success as callback(true)
|
|
// - failure as callback(false)
|
|
|
|
function checkFirmware(file, callback) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = function(event) {
|
|
if (FileReader.DONE === event.target.readyState) {
|
|
const magic = event.target.result.charCodeAt(0);
|
|
if ((0x1F === magic) && (0x8B === event.target.result.charCodeAt(1))) {
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
if (0xE9 !== magic) {
|
|
alert("Binary image does not start with a magic byte");
|
|
callback(false);
|
|
return;
|
|
}
|
|
|
|
const flash_mode = event.target.result.charCodeAt(2);
|
|
if (0x03 !== flash_mode) {
|
|
const modes = ['QIO', 'QOUT', 'DIO', 'DOUT'];
|
|
callback(window.confirm(`Binary image is using ${modes[flash_mode]} flash mode! Make sure that the device supports it before proceeding.`));
|
|
} else {
|
|
callback(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const blob = file.slice(0, 3);
|
|
reader.readAsBinaryString(blob);
|
|
}
|
|
|
|
function handleFirmwareUpgrade(event) {
|
|
event.preventDefault();
|
|
|
|
let upgrade = document.querySelector("input[name='upgrade']");
|
|
let file = upgrade.files[0];
|
|
if (typeof file === "undefined") {
|
|
alert("First you have to select a file from your computer.");
|
|
return;
|
|
}
|
|
|
|
if (file.size > FreeSize) {
|
|
alert("Image it too large to fit in the available space for OTA. Consider doing a two-step update.");
|
|
return;
|
|
}
|
|
|
|
checkFirmware(file, (ok) => {
|
|
if (!ok) {
|
|
return;
|
|
}
|
|
|
|
var data = new FormData();
|
|
data.append("upgrade", file, file.name);
|
|
|
|
var xhr = new XMLHttpRequest();
|
|
|
|
var msg_ok = "Firmware image uploaded, board rebooting. This page will be refreshed in 5 seconds.";
|
|
var msg_err = "There was an error trying to upload the new image, please try again: ";
|
|
|
|
var network_error = function(e) {
|
|
alert(msg_err + " xhr request " + e.type);
|
|
};
|
|
xhr.addEventListener("error", network_error, false);
|
|
xhr.addEventListener("abort", network_error, false);
|
|
|
|
let progress = document.getElementById("upgrade-progress");
|
|
xhr.addEventListener("load", () => {
|
|
progress.style.display = "none";
|
|
if ("OK" === xhr.responseText) {
|
|
alert(msg_ok);
|
|
} else {
|
|
alert(msg_err + xhr.status.toString() + " " + xhr.statusText + ", " + xhr.responseText);
|
|
}
|
|
}, false);
|
|
|
|
xhr.upload.addEventListener("progress", (event) => {
|
|
progress.style.display = "inherit";
|
|
if (event.lengthComputable) {
|
|
progress.value = event.loaded;
|
|
progress.max = event.total;
|
|
}
|
|
}, false);
|
|
|
|
xhr.open("POST", Urls.upgrade.href);
|
|
xhr.send(data);
|
|
});
|
|
}
|
|
|
|
function afterSaved() {
|
|
var response;
|
|
|
|
if (Settings.counters.reboot > 0) {
|
|
response = window.confirm("You have to reboot the board for the changes to take effect, do you want to do it now?");
|
|
if (response) {
|
|
sendAction("reboot");
|
|
}
|
|
} else if (Settings.counters.reconnect > 0) {
|
|
response = window.confirm("You have to reconnect to the WiFi for the changes to take effect, do you want to do it now?");
|
|
if (response) {
|
|
sendAction("reconnect");
|
|
}
|
|
} else if (Settings.counters.reload > 0) {
|
|
response = window.confirm("You have to reload the page to see the latest changes, do you want to do it now?");
|
|
if (response) {
|
|
pageReloadIn(0);
|
|
}
|
|
}
|
|
|
|
resetOriginals();
|
|
}
|
|
|
|
function waitForSaved(){
|
|
if (!Settings.saved) {
|
|
setTimeout(waitForSaved, 1000);
|
|
} else {
|
|
afterSaved();
|
|
}
|
|
}
|
|
|
|
function applySettingsFromAllForms() {
|
|
// Since we have 2-page config, make sure we select the active one
|
|
let forms = document.getElementsByClassName("form-settings");
|
|
if (validateForms(forms)) {
|
|
applySettings(getData(forms));
|
|
Settings.counters.changed = 0;
|
|
waitForSaved();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleSettingsFile(event) {
|
|
event.preventDefault();
|
|
|
|
const inputFiles = event.target.files;
|
|
if (typeof inputFiles === "undefined" || inputFiles.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const inputFile = inputFiles[0];
|
|
event.target.value = "";
|
|
|
|
if (!window.confirm("Previous settings will be overwritten. Are you sure you want to restore from this file?")) {
|
|
return false;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(event) {
|
|
try {
|
|
var data = JSON.parse(event.target.result);
|
|
sendAction("restore", data);
|
|
} catch (e) {
|
|
notifyError(null, null, 0, 0, e);
|
|
}
|
|
};
|
|
reader.readAsText(inputFile);
|
|
}
|
|
|
|
function resetToFactoryDefaults(event) {
|
|
event.preventDefault();
|
|
|
|
let response = window.confirm("Are you sure you want to erase all settings from the device?");
|
|
if (response) {
|
|
sendAction("factory_reset");
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Visualization
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// ref. vendor/side-menu.css
|
|
|
|
function toggleMenu(event) {
|
|
event.preventDefault();
|
|
event.target.parentElement.classList.toggle("active");
|
|
}
|
|
|
|
function showPanelByName(name) {
|
|
// only a single panel is shown on the 'layout'
|
|
const target = document.getElementById(`panel-${name}`);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
for (const panel of document.querySelectorAll(".panel")) {
|
|
panel.style.display = "none";
|
|
}
|
|
target.style.display = "inherit";
|
|
|
|
const layout = document.getElementById("layout");
|
|
layout.classList.remove("active");
|
|
|
|
// TODO: sometimes, switching view causes us to scroll past
|
|
// the header (e.g. emon ratios panel on small screen)
|
|
// layout itself stays put, but the root element seems to scroll,
|
|
// at least can be reproduced with Chrome
|
|
if (document.documentElement) {
|
|
document.documentElement.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
function showPanel(event) {
|
|
event.preventDefault();
|
|
showPanelByName(event.target.dataset["panel"]);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Relays & magnitudes mapping
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function createRelayList(containerId, values, keyPrefix) {
|
|
const target = document.getElementById(containerId);
|
|
if (target.childElementCount > 0) {
|
|
return;
|
|
}
|
|
|
|
// TODO: let schema set the settings key
|
|
const template = loadConfigTemplate("number-input");
|
|
values.forEach((value, index) => {
|
|
const line = template.cloneNode(true);
|
|
line.querySelector("label").textContent = (Enumerable.relay)
|
|
? Enumerable.relay[index].name : `Switch #${index}`;
|
|
|
|
const input = line.querySelector("input");
|
|
input.name = keyPrefix;
|
|
input.value = value;
|
|
input.dataset["original"] = value;
|
|
|
|
mergeTemplate(target, line);
|
|
});
|
|
}
|
|
|
|
//removeIf(!sensor)
|
|
|
|
function initModuleMagnitudes(data) {
|
|
const targetId = `${data.prefix}-magnitudes`;
|
|
|
|
let target = document.getElementById(targetId);
|
|
if (target.childElementCount > 0) { return; }
|
|
|
|
data.values.forEach((values) => {
|
|
const entry = fromSchema(values, data.schema);
|
|
|
|
let line = loadConfigTemplate("module-magnitude");
|
|
line.querySelector("label").textContent =
|
|
`${Magnitudes.types[entry.type]} #${entry.index_global}`;
|
|
line.querySelector("span").textContent =
|
|
Magnitudes.properties[entry.index_global].description;
|
|
|
|
let input = line.querySelector("input");
|
|
input.name = `${data.prefix}Magnitude`;
|
|
input.value = entry.index_module;
|
|
input.dataset["original"] = input.value;
|
|
|
|
mergeTemplate(target, line);
|
|
});
|
|
}
|
|
|
|
//endRemoveIf(!sensor)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// RPN Rules
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function rpnAddRule(cfg) {
|
|
addFromTemplate(document.getElementById("rpn-rules"), "rpn-rule", cfg);
|
|
}
|
|
|
|
function rpnAddTopic(cfg) {
|
|
addFromTemplate(document.getElementById("rpn-topics"), "rpn-topic", cfg);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// RFM69
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!rfm69)
|
|
|
|
function rfm69AddMapping(cfg) {
|
|
addFromTemplate(document.getElementById("rfm69-mapping"), "rfm69-node", cfg);
|
|
}
|
|
|
|
function rfm69Messages() {
|
|
let [body] = document.getElementById("rfm69-messages").tBodies;
|
|
return body;
|
|
}
|
|
|
|
function rfm69AddMessage(message) {
|
|
let timestamp = (new Date()).toLocaleTimeString("en-US", {hour12: false});
|
|
|
|
let container = rfm69Messages();
|
|
let row = container.insertRow();
|
|
for (let value of [timestamp, ...message]) {
|
|
let cell = row.insertCell();
|
|
cell.appendChild(document.createTextNode(value));
|
|
rfm69FilterRow(Rfm69.filters, row);
|
|
}
|
|
}
|
|
|
|
function rfm69ClearCounters() {
|
|
sendAction("rfm69Clear");
|
|
return false;
|
|
}
|
|
|
|
function rfm69ClearMessages() {
|
|
let container = rfm69Messages();
|
|
while (container.rows.length) {
|
|
container.deleteRow(0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function rfm69FilterRow(filters, row) {
|
|
row.style.display = "table-row";
|
|
for (const [cell, filter] of Object.entries(filters)) {
|
|
if (row.cells[cell].textContent !== filter) {
|
|
row.style.display = "none";
|
|
}
|
|
}
|
|
}
|
|
|
|
function rfm69Filter(filters, rows) {
|
|
for (let row of rows) {
|
|
rfm69FilterRow(filters, row);
|
|
}
|
|
}
|
|
|
|
function rfm69FilterEvent(event) {
|
|
if (event.target.classList.contains("filtered")) {
|
|
delete Rfm69.filters[event.target.cellIndex];
|
|
} else {
|
|
Rfm69.filters[event.target.cellIndex] = event.target.textContent;
|
|
}
|
|
event.target.classList.toggle("filtered");
|
|
|
|
rfm69Filter(Rfm69.filters, rfm69Messages().rows);
|
|
}
|
|
|
|
function rfm69ClearFilters() {
|
|
let container = rfm69Messages();
|
|
for (let elem of container.querySelectorAll("filtered")) {
|
|
elem.classList.remove("filtered");
|
|
}
|
|
|
|
Rfm69.filters = {};
|
|
rfm69Filter(Rfm69.filters, container.rows);
|
|
}
|
|
|
|
//endRemoveIf(!rfm69)
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Wifi
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function wifiNetworkAdd(cfg, showMore) {
|
|
let container = document.getElementById("networks");
|
|
|
|
let id = idForTemplateContainer(container);
|
|
if (id < 0) {
|
|
return;
|
|
}
|
|
|
|
if (showMore === undefined) {
|
|
showMore = true;
|
|
}
|
|
|
|
let line = loadConfigTemplate("network-config");
|
|
fillTemplateLineFromCfg(line, id, cfg);
|
|
if (showMore) {
|
|
moreElem(line);
|
|
}
|
|
|
|
mergeTemplate(container, line);
|
|
return;
|
|
}
|
|
|
|
function wifiScanResult(values) {
|
|
let loading = document.querySelector("div.scan.loading");
|
|
loading.style.display = "none";
|
|
|
|
for (let button of document.querySelectorAll(".button-wifi-scan")) {
|
|
button.disabled = false;
|
|
}
|
|
|
|
let table = document.getElementById("scanResult");
|
|
table.style.display = "table";
|
|
|
|
let [results] = table.tBodies;
|
|
let row = results.insertRow();
|
|
for (let value of values) {
|
|
let cell = row.insertCell();
|
|
cell.appendChild(document.createTextNode(value));
|
|
}
|
|
}
|
|
|
|
function startWifiScan(event) {
|
|
event.preventDefault();
|
|
|
|
let [results] = document.getElementById("scanResult").tBodies;
|
|
while (results.rows.length) {
|
|
results.deleteRow(0);
|
|
}
|
|
|
|
let loading = document.querySelector("div.scan.loading");
|
|
loading.style.display = "inherit";
|
|
|
|
for (let button of document.querySelectorAll(".button-wifi-scan")) {
|
|
button.disabled = true;
|
|
}
|
|
|
|
sendAction("scan");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Simple LEDs configuration
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function ledAdd(cfg) {
|
|
let container = document.getElementById("leds");
|
|
|
|
let id = idForTemplateContainer(container);
|
|
if (id < 0) {
|
|
return;
|
|
}
|
|
|
|
let line = loadConfigTemplate("led-config");
|
|
line.querySelector("span").textContent = id;
|
|
fillTemplateLineFromCfg(line, id, cfg);
|
|
|
|
mergeTemplate(container, line);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Date and time scheduler
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function schAdd(cfg) {
|
|
if (cfg.schType === undefined) {
|
|
return;
|
|
}
|
|
|
|
let container = document.getElementById("schedules");
|
|
|
|
let id = idForTemplateContainer(container);
|
|
if (id < 0) {
|
|
return;
|
|
}
|
|
|
|
let line = loadConfigTemplate("schedule-config");
|
|
|
|
if (cfg.schType !== "none") {
|
|
mergeTemplate(line.querySelector(".schedule-action"),
|
|
loadConfigTemplate("schedule-action-".concat(cfg.schType)));
|
|
}
|
|
|
|
fillTemplateLineFromCfg(line, id, cfg);
|
|
mergeTemplate(container, line);
|
|
return;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Relays
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function relayToggle(event) {
|
|
event.preventDefault();
|
|
sendAction("relay", {
|
|
id: parseInt(event.target.dataset["id"], 10),
|
|
status: event.target.checked ? "1" : "0"});
|
|
}
|
|
|
|
function initRelayToggle(id, cfg) {
|
|
let line = loadConfigTemplate("relay-control");
|
|
|
|
let root = line.querySelector("div");
|
|
root.classList.add(`relay-control-${id}`);
|
|
|
|
let name = line.querySelector("span[data-key='relayName']");
|
|
name.textContent = cfg.relayName;
|
|
name.dataset["id"] = id;
|
|
name.setAttribute("title", cfg.relayProv);
|
|
|
|
let realId = "relay".concat(id);
|
|
|
|
let toggle = line.querySelector("input[type='checkbox']");
|
|
toggle.checked = false;
|
|
toggle.disabled = true;
|
|
toggle.dataset["id"] = id;
|
|
toggle.addEventListener("change", relayToggle);
|
|
|
|
toggle.setAttribute("id", realId);
|
|
toggle.nextElementSibling.setAttribute("for", realId);
|
|
|
|
mergeTemplate(document.getElementById("relays"), line);
|
|
}
|
|
|
|
function updateRelays(data) {
|
|
data.states.forEach((state, id) => {
|
|
const relay = fromSchema(state, data.schema);
|
|
|
|
let elem = document.querySelector(`input[name='relay'][data-id='${id}']`);
|
|
elem.checked = relay.status;
|
|
elem.disabled = ({
|
|
0: false,
|
|
1: !relay.status,
|
|
2: relay.status
|
|
})[relay.lock]; // TODO: specify lock statuses earlier?
|
|
});
|
|
}
|
|
|
|
function initRelayConfig(id, cfg) {
|
|
let line = loadConfigTemplate("relay-config");
|
|
fillTemplateLineFromCfg(line, id, cfg);
|
|
mergeTemplate(document.getElementById("relayConfig"), line);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Sensors & Magnitudes
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!sensor)
|
|
|
|
function initMagnitudes(data) {
|
|
data.types.values.forEach((cfg) => {
|
|
const info = fromSchema(cfg, data.types.schema);
|
|
Magnitudes.types[info.type] = info.name;
|
|
Magnitudes.typePrefix[info.type] = info.prefix;
|
|
Magnitudes.prefixType[info.prefix] = info.type;
|
|
});
|
|
|
|
data.errors.values.forEach((cfg) => {
|
|
const error = fromSchema(cfg, data.errors.schema);
|
|
Magnitudes.errors[error.type] = error.name;
|
|
});
|
|
|
|
data.units.values.forEach((cfg, id) => {
|
|
const values = fromSchema(cfg, data.units.schema);
|
|
values.supported.forEach(([type, name]) => {
|
|
Magnitudes.units.names[type] = name;
|
|
});
|
|
|
|
Magnitudes.units.supported[id] = values.supported;
|
|
});
|
|
}
|
|
|
|
function initMagnitudesList(data, callbacks) {
|
|
data.values.forEach((cfg, id) => {
|
|
const magnitude = fromSchema(cfg, data.schema);
|
|
const prettyName = Magnitudes.types[magnitude.type]
|
|
.concat(" #").concat(parseInt(magnitude.index_global, 10));
|
|
|
|
const result = {
|
|
name: prettyName,
|
|
units: magnitude.units,
|
|
type: magnitude.type,
|
|
index_global: magnitude.index_global,
|
|
description: magnitude.description
|
|
};
|
|
|
|
Magnitudes.properties[id] = result;
|
|
callbacks.forEach((callback) => {
|
|
callback(id, result);
|
|
});
|
|
});
|
|
}
|
|
|
|
function createMagnitudeInfo(id, magnitude) {
|
|
const container = document.getElementById("magnitudes");
|
|
|
|
const info = loadTemplate("magnitude-info");
|
|
const label = info.querySelector("label");
|
|
label.textContent = magnitude.name;
|
|
|
|
const input = info.querySelector("input");
|
|
input.dataset["id"] = id;
|
|
input.dataset["type"] = magnitude.type;
|
|
|
|
const description = info.querySelector(".magnitude-description");
|
|
description.textContent = magnitude.description;
|
|
|
|
const extra = info.querySelector(".magnitude-info");
|
|
extra.style.display = "none";
|
|
|
|
mergeTemplate(container, info);
|
|
}
|
|
|
|
function createMagnitudeUnitSelector(id, magnitude) {
|
|
// but, no need for the element when there's no choice
|
|
const supported = Magnitudes.units.supported[id];
|
|
if ((supported !== undefined) && (supported.length > 1)) {
|
|
const line = loadTemplate("magnitude-units");
|
|
line.querySelector("label").textContent =
|
|
`${Magnitudes.types[magnitude.type]} #${magnitude.index_global}`;
|
|
|
|
const select = line.querySelector("select");
|
|
select.setAttribute("name", magnitudeTypedKey(magnitude, "Units"));
|
|
|
|
const options = [];
|
|
supported.forEach(([id, name]) => {
|
|
options.push({id, name});
|
|
});
|
|
|
|
initSelect(select, options);
|
|
setSelectValue(select, magnitude.units);
|
|
setOriginalsFromValuesForNode(line, [select]);
|
|
|
|
const container = document.getElementById("magnitude-units");
|
|
container.parentElement.classList.remove("maybe-hidden");
|
|
mergeTemplate(container, line);
|
|
}
|
|
}
|
|
|
|
function magnitudeSettingInfo(id, key) {
|
|
const out = {
|
|
id: id,
|
|
name: Magnitudes.properties[id].name,
|
|
prefix: `${Magnitudes.typePrefix[Magnitudes.properties[id].type]}`,
|
|
index_global: `${Magnitudes.properties[id].index_global}`
|
|
};
|
|
|
|
out.key = `${out.prefix}${key}${out.index_global}`;
|
|
return out;
|
|
}
|
|
|
|
function emonRatioInfo(id) {
|
|
return magnitudeSettingInfo(id, "Ratio");
|
|
}
|
|
|
|
function initMagnitudeTextSetting(containerId, id, keySuffix, value) {
|
|
const template = loadTemplate("text-input");
|
|
const input = template.querySelector("input");
|
|
|
|
const info = magnitudeSettingInfo(id, keySuffix);
|
|
input.id = info.key;
|
|
input.name = input.id;
|
|
input.value = value;
|
|
setOriginalsFromValuesForNode(template, [input]);
|
|
|
|
const label = template.querySelector("label");
|
|
label.textContent = info.name;
|
|
label.htmlFor = input.id;
|
|
|
|
const container = document.getElementById(containerId);
|
|
container.parentElement.classList.remove("maybe-hidden");
|
|
mergeTemplate(container, template);
|
|
}
|
|
|
|
function initMagnitudesRatio(id, value) {
|
|
initMagnitudeTextSetting("emon-ratios", id, "Ratio", value);
|
|
}
|
|
|
|
function initMagnitudesExpected(id) {
|
|
// TODO: also display currently read value?
|
|
const template = loadTemplate("emon-expected");
|
|
const [expected, result] = template.querySelectorAll("input");
|
|
|
|
const info = emonRatioInfo(id);
|
|
|
|
expected.name += `${info.key}`;
|
|
expected.id = expected.name;
|
|
expected.dataset["id"] = info.id;
|
|
|
|
result.name += `${info.key}`;
|
|
result.id = result.name;
|
|
|
|
const label = template.querySelector("label");
|
|
label.textContent = info.name;
|
|
label.htmlFor = expected.id;
|
|
|
|
styleInject([
|
|
styleVisible(`.emon-expected-${info.prefix}`, true)
|
|
]);
|
|
|
|
mergeTemplate(document.getElementById("emon-expected"), template);
|
|
}
|
|
|
|
function emonCalculateRatios() {
|
|
const inputs = document.getElementById("emon-expected")
|
|
.querySelectorAll(".emon-expected-input");
|
|
|
|
inputs.forEach((input) => {
|
|
if (input.value.length) {
|
|
sendAction("emon-expected", {
|
|
id: parseInt(input.dataset["id"], 10),
|
|
expected: parseFloat(input.value) });
|
|
}
|
|
});
|
|
}
|
|
|
|
function emonApplyRatios() {
|
|
const results = document.getElementById("emon-expected")
|
|
.querySelectorAll(".emon-expected-result");
|
|
|
|
results.forEach((result) => {
|
|
if (result.value.length) {
|
|
const ratio = document.getElementById(
|
|
result.name.replace("result:", ""));
|
|
ratio.value = result.value;
|
|
setChangedElement(ratio);
|
|
|
|
result.value = "";
|
|
|
|
const expected = document.getElementById(
|
|
result.name.replace("result:", "expected:"));
|
|
expected.value = "";
|
|
}
|
|
});
|
|
|
|
showPanelByName("sns");
|
|
}
|
|
|
|
function initMagnitudesCorrection(id, value) {
|
|
initMagnitudeTextSetting("magnitude-corrections", id, "Correction", value);
|
|
}
|
|
|
|
function initMagnitudesSettings(data) {
|
|
data.values.forEach((cfg, id) => {
|
|
const settings = fromSchema(cfg, data.schema);
|
|
|
|
if (settings.Ratio !== null) {
|
|
initMagnitudesRatio(id, settings.Ratio);
|
|
initMagnitudesExpected(id);
|
|
}
|
|
|
|
if (settings.Correction !== null) {
|
|
initMagnitudesCorrection(id, settings.Correction);
|
|
}
|
|
|
|
let threshold = settings.ZeroThreshold;
|
|
if (threshold === null) {
|
|
threshold = NaN;
|
|
}
|
|
|
|
initMagnitudeTextSetting(
|
|
"magnitude-zero-thresholds", id,
|
|
"ZeroThreshold", threshold);
|
|
|
|
initMagnitudeTextSetting(
|
|
"magnitude-min-deltas", id,
|
|
"MinDelta", settings.MinDelta);
|
|
|
|
initMagnitudeTextSetting(
|
|
"magnitude-max-deltas", id,
|
|
"MaxDelta", settings.MaxDelta);
|
|
});
|
|
}
|
|
|
|
function magnitudeValueContainer(id) {
|
|
return document.querySelector(`input[name='magnitude'][data-id='${id}']`);
|
|
}
|
|
|
|
function updateMagnitudes(data) {
|
|
data.values.forEach((cfg, id) => {
|
|
if (!Magnitudes.properties[id]) {
|
|
return;
|
|
}
|
|
|
|
const magnitude = fromSchema(cfg, data.schema);
|
|
const properties = Magnitudes.properties[id];
|
|
properties.units = magnitude.units;
|
|
|
|
const units = Magnitudes.units.names[properties.units] || "";
|
|
const input = magnitudeValueContainer(id);
|
|
input.value = (0 !== magnitude.error)
|
|
? Magnitudes.errors[magnitude.error]
|
|
: (("nan" === magnitude.value)
|
|
? ""
|
|
: `${magnitude.value}${units}`);
|
|
});
|
|
}
|
|
|
|
function updateEnergy(data) {
|
|
data.values.forEach((cfg) => {
|
|
const energy = fromSchema(cfg, data.schema);
|
|
if (!Magnitudes.properties[energy.id]) {
|
|
return;
|
|
}
|
|
|
|
if (energy.saved.length) {
|
|
const input = magnitudeValueContainer(energy.id);
|
|
const info = input.parentElement.parentElement
|
|
.querySelector(".magnitude-info");
|
|
info.style.display = "inherit";
|
|
info.textContent = energy.saved;
|
|
}
|
|
});
|
|
}
|
|
|
|
//endRemoveIf(!sensor)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Thermostat
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!thermostat)
|
|
|
|
function thermostatCheckTempRange(event) {
|
|
const min = document.getElementById("tempRangeMinInput");
|
|
const max = document.getElementById("tempRangeMaxInput");
|
|
|
|
if (event.target.id === max.id) {
|
|
const maxValue = parseInt(max.value, 10) - 1;
|
|
if (parseInt(min.value, 10) > maxValue) {
|
|
min.value = maxValue;
|
|
}
|
|
} else {
|
|
const minValue = parseInt(min.value, 10) + 1;
|
|
if (parseInt(max.value, 10) < minValue) {
|
|
max.value = minValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
//endRemoveIf(!thermostat)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Curtains
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!curtain)
|
|
|
|
function curtainButtonHandler(event) {
|
|
if (event.type !== "click") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
let code = -1;
|
|
|
|
const list = event.target.classList;
|
|
if (list.contains("button-curtain-pause")) {
|
|
code = 0;
|
|
} else if (list.contains("button-curtain-open")) {
|
|
code = 1;
|
|
} else if (list.contains("button-curtain-close")) {
|
|
code = 2;
|
|
}
|
|
|
|
if (code >= 0) {
|
|
sendAction("curtainAction", {button: code});
|
|
event.target.style.background = "red";
|
|
}
|
|
}
|
|
|
|
function curtainSetHandler(event) {
|
|
sendAction("curtainAction", {position: event.target.value});
|
|
}
|
|
|
|
//Create the controls for one curtain. It is called when curtain is updated (so created the first time)
|
|
//Let this there as we plan to have more than one curtain per switch
|
|
function initCurtain() {
|
|
let container = document.getElementById("curtains");
|
|
if (container.childElementCount > 0) {
|
|
return;
|
|
}
|
|
|
|
// add and init curtain template, prepare multi switches
|
|
let line = loadConfigTemplate("curtain-control");
|
|
line.querySelector(".button-curtain-open").addEventListener("click", curtainButtonHandler);
|
|
line.querySelector(".button-curtain-pause").addEventListener("click", curtainButtonHandler);
|
|
line.querySelector(".button-curtain-close").addEventListener("click", curtainButtonHandler);
|
|
mergeTemplate(container, line);
|
|
|
|
// simple position slider
|
|
document.getElementById("curtainSet").addEventListener("change", curtainSetHandler);
|
|
|
|
addSimpleEnumerables("curtain", "Curtain", 1);
|
|
}
|
|
|
|
function setCurtainBackground(a, b) {
|
|
let elem = document.getElementById("curtainGetPicture");
|
|
elem.style.background = `linear-gradient(${a}, black ${b}%, #a0d6ff ${b}%)`;
|
|
}
|
|
|
|
function setCurtainBackgroundTwoSides(a, b) {
|
|
let elem = document.getElementById("curtainGetPicture");
|
|
elem.style.background = `linear-gradient(90deg, black ${a}%, #a0d6ff ${a}% ${b}%, black ${b}%)`;
|
|
}
|
|
|
|
function updateCurtain(value) {
|
|
switch(value.type) {
|
|
case '1': //One side left to right
|
|
setCurtainBackground('90deg', value.get);
|
|
break;
|
|
case '2': //One side right to left
|
|
setCurtainBackground('270deg', value.get);
|
|
break;
|
|
case '3': //Two sides
|
|
setCurtainBackgroundTwoSides(value.get / 2, (100 - value.get/2));
|
|
break;
|
|
case '0': //Roller
|
|
default:
|
|
setCurtainBackground('180deg', value.get);
|
|
break;
|
|
}
|
|
|
|
let set = document.getElementById("curtainSet");
|
|
set.value = value.set;
|
|
|
|
const backgroundMoving = 'rgb(192, 0, 0)';
|
|
const backgroundStopped = 'rgb(64, 184, 221)';
|
|
|
|
if (!value.moving) {
|
|
let button = document.querySelector("button.curtain-button");
|
|
button.style.background = backgroundStopped;
|
|
} else if (!value.button) {
|
|
let pause = document.querySelector("button.curtain-pause");
|
|
pause.style.background = backgroundMoving;
|
|
} else {
|
|
let open = document.querySelector("button.button-curtain-open");
|
|
let close = document.querySelector("button.button-curtain-close");
|
|
if (value.button === 1) {
|
|
open.style.background = backgroundMoving;
|
|
close.style.background = backgroundStopped;
|
|
} else if (value.button === 2) {
|
|
open.style.background = backgroundStopped;
|
|
close.style.background = backgroundMoving;
|
|
}
|
|
}
|
|
}
|
|
|
|
//endRemoveIf(!curtain)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Lights
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!light)
|
|
|
|
function colorToHsvString(color) {
|
|
var h = String(Math.round(color.hsv.h));
|
|
var s = String(Math.round(color.hsv.s));
|
|
var v = String(Math.round(color.hsv.v));
|
|
return h + "," + s + "," + v;
|
|
}
|
|
|
|
function hsvStringToColor(hsv) {
|
|
var parts = hsv.split(",");
|
|
return {
|
|
h: parseInt(parts[0]),
|
|
s: parseInt(parts[1]),
|
|
v: parseInt(parts[2])
|
|
};
|
|
}
|
|
|
|
function colorSlider(type) {
|
|
return {component: iro.ui.Slider, options: {sliderType: type}};
|
|
}
|
|
|
|
function colorWheel() {
|
|
return {component: iro.ui.Wheel, options: {}};
|
|
}
|
|
|
|
function colorBox() {
|
|
return {component: iro.ui.Box, options: {}};
|
|
}
|
|
|
|
function colorUpdate(mode, value) {
|
|
if ("rgb" === mode) {
|
|
ColorPicker.color.hexString = value;
|
|
} else if ("hsv" === mode) {
|
|
ColorPicker.color.hsv = hsvStringToColor(value);
|
|
}
|
|
}
|
|
|
|
function lightStateHideRelay(id) {
|
|
styleInject([
|
|
styleVisible(`.relay-control-${id}`, false)
|
|
]);
|
|
}
|
|
|
|
function initLightState() {
|
|
const toggle = document.getElementById("light-state-value");
|
|
toggle.addEventListener("change", (event) => {
|
|
event.preventDefault();
|
|
sendAction("light", {state: event.target.checked});
|
|
});
|
|
}
|
|
|
|
function updateLightState(value) {
|
|
const state = document.getElementById("light-state-value");
|
|
state.checked = value;
|
|
colorPickerState(value);
|
|
}
|
|
|
|
function colorPickerState(value) {
|
|
const light = document.getElementById("light");
|
|
if (value) {
|
|
light.classList.add("light-on");
|
|
} else {
|
|
light.classList.remove("light-on");
|
|
}
|
|
}
|
|
|
|
function colorEnabled(value) {
|
|
if (value) {
|
|
lightAddClass("light-color");
|
|
}
|
|
}
|
|
|
|
function colorInit(value) {
|
|
// TODO: ref. #2451, input:change causes pretty fast updates.
|
|
// either make sure we don't cause any issue on the esp, or switch to
|
|
// color:change instead (which applies after input ends)
|
|
let change = () => {
|
|
};
|
|
|
|
const rules = [];
|
|
const layout = [];
|
|
|
|
// RGB
|
|
if (value) {
|
|
layout.push(colorWheel());
|
|
change = (color) => {
|
|
sendAction("light", {
|
|
rgb: color.hexString
|
|
});
|
|
};
|
|
// HSV
|
|
} else {
|
|
layout.push(colorBox());
|
|
layout.push(colorSlider("hue"));
|
|
layout.push(colorSlider("saturation"));
|
|
change = (color) => {
|
|
sendAction("light", {
|
|
hsv: colorToHsvString(color)
|
|
});
|
|
};
|
|
}
|
|
|
|
layout.push(colorSlider("value"));
|
|
styleInject(rules);
|
|
|
|
ColorPicker = new iro.ColorPicker("#light-picker", {layout});
|
|
ColorPicker.on("input:change", change);
|
|
}
|
|
|
|
function updateMireds(value) {
|
|
const mireds = document.getElementById("mireds-value");
|
|
if (mireds !== null) {
|
|
mireds.value = value;
|
|
mireds.nextElementSibling.textContent = value;
|
|
}
|
|
}
|
|
|
|
function lightAddClass(className) {
|
|
const light = document.getElementById("light");
|
|
light.classList.add(className);
|
|
}
|
|
|
|
// White implies we should hide one or both white channels
|
|
function whiteEnabled(value) {
|
|
if (value) {
|
|
lightAddClass("light-white");
|
|
}
|
|
}
|
|
|
|
// When there are CCT controls, no need for raw white channel sliders
|
|
function cctEnabled(value) {
|
|
if (value) {
|
|
lightAddClass("light-cct");
|
|
}
|
|
}
|
|
|
|
function cctInit(value) {
|
|
const control = loadTemplate("mireds-control");
|
|
|
|
const slider = control.getElementById("mireds-value");
|
|
slider.setAttribute("min", value.cold);
|
|
slider.setAttribute("max", value.warm);
|
|
slider.addEventListener("change", (event) => {
|
|
event.target.nextElementSibling.textContent = event.target.value;
|
|
sendAction("light", {mireds: event.target.value});
|
|
});
|
|
|
|
const datalist = control.querySelector("datalist");
|
|
datalist.innerHTML = `
|
|
<option value="${value.cold}">Cold</option>
|
|
<option value="${value.warm}">Warm</option>
|
|
`;
|
|
|
|
mergeTemplate(document.getElementById("light-cct"), control);
|
|
}
|
|
|
|
function updateLight(data) {
|
|
for (const [key, value] of Object.entries(data)) {
|
|
switch (key) {
|
|
case "state":
|
|
updateLightState(value);
|
|
break;
|
|
|
|
case "state_relay_id":
|
|
lightStateHideRelay(value);
|
|
break;
|
|
|
|
case "channels":
|
|
initLightState();
|
|
initBrightness();
|
|
initChannels(value);
|
|
break;
|
|
|
|
case "cct":
|
|
cctInit(value);
|
|
break;
|
|
|
|
case "brightness":
|
|
updateBrightness(value);
|
|
break;
|
|
|
|
case "values":
|
|
updateChannels(value);
|
|
break;
|
|
|
|
case "rgb":
|
|
case "hsv":
|
|
colorUpdate(key, value);
|
|
break;
|
|
|
|
case "mireds":
|
|
updateMireds(value);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function onChannelSliderChange(event) {
|
|
event.target.nextElementSibling.textContent = event.target.value;
|
|
|
|
let channel = {}
|
|
channel[event.target.dataset["id"]] = event.target.value;
|
|
|
|
sendAction("light", {channel});
|
|
}
|
|
|
|
function onBrightnessSliderChange(event) {
|
|
event.target.nextElementSibling.textContent = event.target.value;
|
|
sendAction("light", {brightness: event.target.value});
|
|
}
|
|
|
|
function initBrightness() {
|
|
const template = loadTemplate("brightness-control");
|
|
|
|
const slider = template.getElementById("brightness-value");
|
|
slider.addEventListener("change", onBrightnessSliderChange);
|
|
|
|
mergeTemplate(document.getElementById("light-brightness"), template);
|
|
}
|
|
|
|
function updateBrightness(value) {
|
|
const brightness = document.getElementById("brightness-value");
|
|
if (brightness !== null) {
|
|
brightness.value = value;
|
|
brightness.nextElementSibling.textContent = value;
|
|
}
|
|
}
|
|
|
|
function initChannels(channels) {
|
|
const container = document.getElementById("light-channels");
|
|
const enumerables = [];
|
|
|
|
channels.forEach((tag, channel) => {
|
|
const line = loadTemplate("channel-control");
|
|
line.querySelector("span.slider").dataset["id"] = channel;
|
|
line.querySelector("div").setAttribute("id", `light-channel-${tag.toLowerCase()}`);
|
|
|
|
const slider = line.querySelector("input.slider");
|
|
slider.dataset["id"] = channel;
|
|
slider.addEventListener("change", onChannelSliderChange);
|
|
|
|
const label = `Channel #${channel} (${tag.toUpperCase()})`;
|
|
line.querySelector("label").textContent = label;
|
|
mergeTemplate(container, line);
|
|
|
|
enumerables.push({"id": channel, "name": label});
|
|
});
|
|
|
|
addEnumerables("Channels", enumerables);
|
|
}
|
|
|
|
function updateChannels(values) {
|
|
const container = document.getElementById("light");
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
values.forEach((value, channel) => {
|
|
const slider = container.querySelector(`input.slider[data-id='${channel}']`);
|
|
if (!slider) {
|
|
return;
|
|
}
|
|
|
|
slider.value = value;
|
|
slider.nextElementSibling.textContent = value;
|
|
});
|
|
}
|
|
|
|
//endRemoveIf(!light)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// RFBridge
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!rfbridge)
|
|
|
|
function rfbAction(event) {
|
|
const prefix = "button-rfb-";
|
|
const [buttonRfbClass] = Array.from(event.target.classList)
|
|
.filter(x => x.startsWith(prefix));
|
|
|
|
if (buttonRfbClass) {
|
|
const container = event.target.parentElement.parentElement;
|
|
const input = container.querySelector("input");
|
|
|
|
sendAction(`rfb${buttonRfbClass.replace(prefix, "")}`, {
|
|
id: parseInt(input.dataset["id"], 10),
|
|
status: input.name === "rfbON"
|
|
});
|
|
}
|
|
}
|
|
|
|
function rfbAdd() {
|
|
let container = document.getElementById("rfbNodes");
|
|
|
|
const id = container.childElementCount;
|
|
const line = loadConfigTemplate("rfb-node");
|
|
line.querySelector("span").textContent = id;
|
|
|
|
for (let input of line.querySelectorAll("input")) {
|
|
input.dataset["id"] = id;
|
|
input.setAttribute("id", `${input.name}${id}`);
|
|
}
|
|
|
|
for (let action of ["learn", "forget"]) {
|
|
for (let button of line.querySelectorAll(`.button-rfb-${action}`)) {
|
|
button.addEventListener("click", rfbAction);
|
|
}
|
|
}
|
|
|
|
mergeTemplate(container, line);
|
|
}
|
|
|
|
function rfbHandleCodes(value) {
|
|
value.codes.forEach((codes, id) => {
|
|
const realId = id + value.start;
|
|
const [off, on] = codes;
|
|
|
|
const rfbOn = document.getElementById(`rfbON${realId}`);
|
|
setInputValue(rfbOn, on);
|
|
|
|
const rfbOff = document.getElementById(`rfbOFF${realId}`);
|
|
setInputValue(rfbOff, off);
|
|
|
|
setOriginalsFromValues([rfbOn, rfbOff]);
|
|
});
|
|
}
|
|
|
|
//endRemoveIf(!rfbridge)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// LightFox
|
|
// -----------------------------------------------------------------------------
|
|
|
|
//removeIf(!lightfox)
|
|
|
|
function lightfoxLearn() {
|
|
sendAction("lightfoxLearn");
|
|
}
|
|
|
|
function lightfoxClear() {
|
|
sendAction("lightfoxClear");
|
|
}
|
|
|
|
//endRemoveIf(!lightfox)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Processing
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function processData(data) {
|
|
if (Debug) {
|
|
console.log(data);
|
|
}
|
|
|
|
// title
|
|
if ("app_name" in data) {
|
|
let title = data.app_name;
|
|
if ("app_version" in data) {
|
|
let span = document.querySelector("span[data-key='version']");
|
|
span.textContent = data.app_version;
|
|
title = title + " " + data.app_version;
|
|
}
|
|
if ("hostname" in data) {
|
|
title = data.hostname + " - " + title;
|
|
}
|
|
document.title = title;
|
|
}
|
|
|
|
Object.keys(data).forEach((key) => {
|
|
let value = data[key];
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Web mode & modules
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("webMode" === key) {
|
|
let initial = (1 === value);
|
|
|
|
let layout = document.getElementById("layout");
|
|
layout.style.display = initial ? "none" : "inherit";
|
|
|
|
let password = document.getElementById("password");
|
|
password.style.display = initial ? "inherit" : "none";
|
|
|
|
return;
|
|
}
|
|
|
|
if ("modulesVisible" === key) {
|
|
// TODO: Move to another 'module' that saves the energy data and have a common setting?
|
|
if (value.includes("pzem")) {
|
|
document.querySelector("input[name='snsSave']").disabled = true;
|
|
}
|
|
|
|
value.forEach((module) => {
|
|
moduleVisible(module);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if ("gpioConfig" === key) {
|
|
let types = [];
|
|
|
|
for (const [type, id] of value.types) {
|
|
types.push({
|
|
"id": id,
|
|
"name": type
|
|
});
|
|
|
|
let gpios = [{"id": 153, "name": "NONE"}];
|
|
value[type].forEach((pin) => {
|
|
gpios.push({"id": pin, "name": `GPIO${pin}`});
|
|
});
|
|
addEnumerables(`gpio-${type}`, gpios);
|
|
}
|
|
|
|
addEnumerables("gpio-types", types);
|
|
return;
|
|
}
|
|
|
|
if ("gpioInfo" === key) {
|
|
let failed = "";
|
|
for (const [pin, file, func, line] of value["failed-locks"]) {
|
|
failed += `GPIO${pin} @ ${file}:${func}:${line}\n`;
|
|
}
|
|
|
|
if (failed.length > 0) {
|
|
showErrorNotification("Could not acquire locks on the following pins, check configuration\n\n" + failed);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Actions
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("action" === key) {
|
|
if ("reload" === data.action) {
|
|
pageReloadIn(1000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// RFBridge
|
|
// ---------------------------------------------------------------------
|
|
|
|
//removeIf(!rfbridge)
|
|
|
|
if ("rfbCount" === key) {
|
|
for (let i = 0; i < data.rfbCount; ++i) {
|
|
rfbAdd();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ("rfb" === key) {
|
|
rfbHandleCodes(value);
|
|
return;
|
|
}
|
|
|
|
//endRemoveIf(!rfbridge)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// RFM69
|
|
// ---------------------------------------------------------------------
|
|
|
|
//removeIf(!rfm69)
|
|
|
|
if ("rfm69" === key) {
|
|
if (value.message !== undefined) {
|
|
rfm69AddMessage(value.message);
|
|
}
|
|
if (value.mapping !== undefined) {
|
|
value.mapping.forEach((mapping) => {
|
|
rfm69AddMapping(fromSchema(mapping, value.schema));
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
//endRemoveIf(!rfm69)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// RPN Rules
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("rpnRules" === key) {
|
|
for (let rule of value) {
|
|
rpnAddRule({"rpnRule": rule});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ("rpnTopics" === key) {
|
|
value.topics.forEach((topic) => {
|
|
rpnAddTopic(fromSchema(topic, value.schema));
|
|
});
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Curtains
|
|
// ---------------------------------------------------------------------
|
|
|
|
//removeIf(!curtain)
|
|
|
|
if ("curtainState" === key) {
|
|
initCurtain();
|
|
updateCurtain(value);
|
|
return;
|
|
}
|
|
|
|
//endRemoveIf(!curtain)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Lights
|
|
// ---------------------------------------------------------------------
|
|
|
|
//removeIf(!light)
|
|
|
|
if ("light" === key) {
|
|
updateLight(value);
|
|
return;
|
|
}
|
|
|
|
if ("useWhite" === key) {
|
|
whiteEnabled(value);
|
|
}
|
|
|
|
if ("useCCT" === key) {
|
|
cctEnabled(value);
|
|
}
|
|
|
|
if ("useColor" === key) {
|
|
colorEnabled(value);
|
|
}
|
|
|
|
if ("useRGB" === key) {
|
|
colorInit(value);
|
|
}
|
|
|
|
//endRemoveIf(!light)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Sensors & Magnitudes
|
|
// ---------------------------------------------------------------------
|
|
|
|
//removeIf(!sensor)
|
|
|
|
if ("magnitudes-init" === key) {
|
|
initMagnitudes(value);
|
|
return;
|
|
}
|
|
|
|
if ("magnitudes-module" === key) {
|
|
initModuleMagnitudes(value);
|
|
return;
|
|
}
|
|
|
|
if ("magnitudes-list" === key) {
|
|
initMagnitudesList(value, [
|
|
createMagnitudeUnitSelector, createMagnitudeInfo]);
|
|
return;
|
|
}
|
|
|
|
if ("magnitudes-settings" === key) {
|
|
initMagnitudesSettings(value);
|
|
return;
|
|
}
|
|
|
|
if ("magnitudes" === key) {
|
|
updateMagnitudes(value);
|
|
return;
|
|
}
|
|
|
|
if ("energy" === key) {
|
|
updateEnergy(value);
|
|
return;
|
|
}
|
|
|
|
//endRemoveIf(!sensor)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// WiFi
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("wifiConfig" === key) {
|
|
let container = document.getElementById("networks");
|
|
container.dataset["settingsMax"] = value.max;
|
|
|
|
value.networks.forEach((entries) => {
|
|
wifiNetworkAdd(fromSchema(entries, value.schema), false);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if ("scanResult" === key) {
|
|
wifiScanResult(value);
|
|
return;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Relays scheduler
|
|
// -----------------------------------------------------------------------------
|
|
|
|
if ("schConfig" === key) {
|
|
let container = document.getElementById("schedules");
|
|
container.dataset["settingsMax"] = value.max;
|
|
|
|
value.schedules.forEach((entries) => {
|
|
schAdd(fromSchema(entries, value.schema));
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Relays
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("relayConfig" === key) {
|
|
let container = document.getElementById("relays");
|
|
if (container.childElementCount > 0) {
|
|
return;
|
|
}
|
|
|
|
let relays = [];
|
|
value.relays.forEach((entries, id) => {
|
|
let cfg = fromSchema(entries, value.schema);
|
|
if (!cfg.relayName || !cfg.relayName.length) {
|
|
cfg.relayName = `Switch #${id}`;
|
|
}
|
|
|
|
relays.push({
|
|
"id": id,
|
|
"name": `${cfg.relayName} (${cfg.relayProv})`
|
|
});
|
|
|
|
initRelayToggle(id, cfg);
|
|
initRelayConfig(id, cfg);
|
|
});
|
|
|
|
addEnumerables("relay", relays);
|
|
return;
|
|
}
|
|
|
|
if ("relayState" === key) {
|
|
updateRelays(value);
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// LEDs
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("ledConfig" === key) {
|
|
let container = document.getElementById("leds");
|
|
if (container.childElementCount > 0) {
|
|
return;
|
|
}
|
|
|
|
value.leds.forEach((entries) => {
|
|
ledAdd(fromSchema(entries, value.schema));
|
|
});
|
|
|
|
addSimpleEnumerables("led", "LED", value.leds.length);
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Special mapping for domoticz and thingspeak
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("dczRelays" === key) {
|
|
createRelayList("dcz-relays", value, "dczRelayIdx");
|
|
return;
|
|
}
|
|
|
|
if ("tspkRelays" === key) {
|
|
createRelayList("tspk-relays", value, "tspkRelay");
|
|
return;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// General
|
|
// ---------------------------------------------------------------------
|
|
|
|
if ("saved" === key) {
|
|
Settings.saved = value;
|
|
return;
|
|
}
|
|
|
|
if ("message" === key) {
|
|
window.alert(value);
|
|
return;
|
|
}
|
|
|
|
if ("log" === key) {
|
|
send("{}");
|
|
|
|
const messages = value["msg"];
|
|
if (messages === undefined) {
|
|
return;
|
|
}
|
|
|
|
for (let msg of messages) {
|
|
CmdOutput.push(msg);
|
|
}
|
|
|
|
CmdOutput.follow();
|
|
return;
|
|
}
|
|
|
|
if ("deviceip" === key) {
|
|
const deviceAddress = document.querySelector(`span[data-key='${key}']`);
|
|
deviceAddress.textContent = value;
|
|
deviceAddress.parentElement.setAttribute("href", "//".concat(value));
|
|
|
|
const telnetAddress = document.querySelector("span[data-key='telnetip']");
|
|
telnetAddress.textContent = value;
|
|
telnetAddress.parentElement.setAttribute("href", "telnet://".concat(value));
|
|
return;
|
|
}
|
|
|
|
if ("uptime" === key) {
|
|
setUptime(value);
|
|
return;
|
|
}
|
|
|
|
if ("now" === key) {
|
|
Now = parseInt(value, 10);
|
|
return;
|
|
}
|
|
|
|
if ("free_size" === key) {
|
|
FreeSize = parseInt(value, 10);
|
|
initGenericKeyValueElement(key, value);
|
|
return;
|
|
}
|
|
|
|
if ("mqttStatus" === key) {
|
|
initGenericKeyValueElement(key, value ? "CONNECTED" : "DISCONNECTED");
|
|
return;
|
|
}
|
|
|
|
if ("ntpStatus" === key) {
|
|
initGenericKeyValueElement(key, value ? "SYNC'D" : "NOT SYNC'D");
|
|
return;
|
|
}
|
|
|
|
initGenericKeyValueElement(key, value);
|
|
});
|
|
}
|
|
|
|
function onElementChange(event) {
|
|
let action = event.target.dataset["action"];
|
|
if ("none" === action) {
|
|
return;
|
|
}
|
|
|
|
let originalValue = event.target.dataset["original"];
|
|
let newValue;
|
|
|
|
if ((event.target.tagName === "INPUT") && (event.target.type === "checkbox")) {
|
|
originalValue = stringToBoolean(originalValue);
|
|
newValue = event.target.checked;
|
|
} else if (event.target.tagName === "SELECT") {
|
|
newValue = stringifySelectedValues(event.target);
|
|
} else {
|
|
newValue = event.target.value;
|
|
}
|
|
|
|
if (typeof originalValue === "undefined") {
|
|
return;
|
|
}
|
|
|
|
let changed = isChangedElement(event.target);
|
|
if (newValue !== originalValue) {
|
|
if (!changed) {
|
|
++Settings.counters.changed;
|
|
if (action in Settings.counters) {
|
|
++Settings.counters[action];
|
|
}
|
|
}
|
|
setChangedElement(event.target);
|
|
} else {
|
|
if (changed) {
|
|
--Settings.counters.changed;
|
|
if (action in Settings.counters) {
|
|
--Settings.counters[action];
|
|
}
|
|
}
|
|
resetChangedElement(event.target);
|
|
}
|
|
}
|
|
|
|
function listenChanged(selector) {
|
|
for (let elem of document.querySelectorAll(selector)) {
|
|
elem.addEventListener("change", onElementChange);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Init & connect
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function onWebSocketMessage(event) {
|
|
var data = {};
|
|
try {
|
|
data = JSON.parse(event.data
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\r/g, "\\r")
|
|
.replace(/\t/g, "\\t"));
|
|
} catch (e) {
|
|
notifyError(null, null, 0, 0, e);
|
|
}
|
|
|
|
processData(data);
|
|
}
|
|
|
|
function webSocketPing() {
|
|
sendAction("ping");
|
|
}
|
|
|
|
function onWebSocketOpen() {
|
|
WebsockPingPong = setInterval(webSocketPing, 5000);
|
|
}
|
|
|
|
function onWebSocketClose() {
|
|
clearInterval(WebsockPingPong);
|
|
if (window.confirm("Connection lost with the device, click OK to refresh the page")) {
|
|
let layout = document.getElementById("layout");
|
|
layout.style.display = "none";
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
function connectToURL(url) {
|
|
Urls = new UrlsBase(url);
|
|
setInterval(() => keepTime(), 1000);
|
|
|
|
fetch(Urls.auth.href, {
|
|
'method': 'GET',
|
|
'cors': true,
|
|
'credentials': 'same-origin'
|
|
}).then((response) => {
|
|
if (response.status === 200) {
|
|
if (Websock) {
|
|
Websock.close();
|
|
}
|
|
|
|
Websock = new WebSocket(Urls.ws.href);
|
|
Websock.onmessage = onWebSocketMessage;
|
|
Websock.onclose = onWebSocketClose;
|
|
Websock.onopen = onWebSocketOpen;
|
|
|
|
document.getElementById("downloader").href = Urls.config.href;
|
|
return;
|
|
}
|
|
|
|
// Nothing to do, reload page and retry on errors
|
|
notifyError(`${Urls.ws.href} responded with status code ${response.status}, reloading the page`, null, 0, 0, null);
|
|
pageReloadIn(5000);
|
|
}).catch((error) => {
|
|
notifyError(null, null, 0, 0, error);
|
|
pageReloadIn(5000);
|
|
});
|
|
}
|
|
|
|
function connect(host) {
|
|
if (!host.startsWith("http:") && !host.startsWith("https:")) {
|
|
host = "http://" + host;
|
|
}
|
|
connectToURL(new URL(host));
|
|
}
|
|
|
|
function connectToCurrentURL() {
|
|
connectToURL(new URL(window.location));
|
|
}
|
|
|
|
// TODO: modularize initialization with separate files?
|
|
|
|
function main() {
|
|
initExternalLinks();
|
|
createCheckboxes(document);
|
|
|
|
// Initial page, when webMode only allows to change the password
|
|
initSetupPassword(document.forms["form-setup-password"]);
|
|
|
|
// Sidebar menu & buttons
|
|
elementSelectorOnClick(".menu-link", toggleMenu);
|
|
elementSelectorOnClick(".pure-menu-link", showPanel);
|
|
elementSelectorOnClick(".button-update", (event) => {
|
|
event.preventDefault();
|
|
applySettingsFromAllForms();
|
|
});
|
|
elementSelectorOnClick(".button-reconnect", askAndCallReconnect);
|
|
elementSelectorOnClick(".button-reboot", askAndCallReboot);
|
|
|
|
// Generic action sender
|
|
elementSelectorOnClick(".button-simple-action", askAndCallAction);
|
|
|
|
// WiFi config
|
|
elementSelectorOnClick(".button-wifi-scan", startWifiScan);
|
|
|
|
// OTA
|
|
let upgrade = document.querySelector("input[name='upgrade']");
|
|
elementSelectorOnClick(".button-upgrade-browse", () => {
|
|
upgrade.click();
|
|
});
|
|
upgrade.addEventListener("change", (event) => {
|
|
document.querySelector("input[name='filename']").value = event.target.files[0].name;
|
|
});
|
|
|
|
// Module specific elements
|
|
|
|
//removeIf(!sensor)
|
|
elementSelectorListener(".button-emon-expected", "click", showPanel);
|
|
elementSelectorListener(".button-emon-expected-calculate", "click", emonCalculateRatios);
|
|
elementSelectorListener(".button-emon-expected-apply", "click", emonApplyRatios);
|
|
//endRemoveIf(!sensor)
|
|
|
|
//removeIf(!garland)
|
|
elementSelectorListener(".checkbox-garland-enable", "change", (event) => {
|
|
sendAction("garland_switch", {status: event.target.checked ? 1 : 0});
|
|
});
|
|
|
|
elementSelectorListener(".slider-garland-brightness", "change", (event) => {
|
|
sendAction("garland_set_brightness", {brightness: event.target.value});
|
|
});
|
|
elementSelectorListener(".slider-garland-speed", "change", (event) => {
|
|
sendAction("garland_set_speed", {speed: event.target.value});
|
|
});
|
|
|
|
elementSelectorOnClick(".button-garland-set-default", () => {
|
|
sendAction("garland_set_default", {});
|
|
});
|
|
//endRemoveIf(!garland)
|
|
|
|
//removeIf(!thermostat)
|
|
elementSelectorOnClick(".button-thermostat-reset-counters", () => {
|
|
const questions = [askSaveSettings, (ask) => ask("Are you sure you want to reset burning counters?")];
|
|
askAndCall(questions, () => {
|
|
sendAction("thermostat_reset_counters");
|
|
});
|
|
});
|
|
elementSelectorListener("#tempRangeMaxInput", "change", thermostatCheckTempRange);
|
|
elementSelectorListener("#tempRangeMinInput", "change", thermostatCheckTempRange);
|
|
//endRemoveIf(!thermostat)
|
|
|
|
//removeIf(!lightfox)
|
|
elementSelectorOnClick(".button-lightfox-learn", lightfoxLearn);
|
|
elementSelectorOnClick(".button-lightfox-clear", lightfoxClear);
|
|
//endRemoveIf(!lightfox)
|
|
|
|
//removeIf(!rfm69)
|
|
elementSelectorOnClick(".button-clear-counts", rfm69ClearCounters);
|
|
elementSelectorOnClick(".button-clear-messages", rfm69ClearMessages);
|
|
|
|
elementSelectorOnClick(".button-clear-filters", rfm69ClearFilters);
|
|
elementSelectorOnClick("#rfm69-messages tbody", rfm69FilterEvent);
|
|
|
|
groupSettingsOnAdd("rfm69-mapping", () => {
|
|
rfm69AddMapping();
|
|
});
|
|
//endRemoveIf(!rfm69)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// While the settings are grouped using forms, actual submit is useless here
|
|
// b/c the data is intended to be sent with the websocket connection and never through some http endpoint
|
|
// *NOTICE* that manual event cancellation should happen asap, any exceptions will stop the specific
|
|
// handler function, but will not stop anything else left in the chain.
|
|
for (let form of document.forms) {
|
|
if (form.id === "form-cmd") {
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
|
|
const line = event.target.elements.cmd.value;
|
|
event.target.elements.cmd.value = "";
|
|
|
|
CmdOutput.pushAndFollow(line);
|
|
sendAction("cmd", {"line": `${line}\n`});
|
|
});
|
|
} else {
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
});
|
|
}
|
|
}
|
|
|
|
// we also need a special handler for the output scroll keep-up
|
|
// make sure we allow scrolling up to search through the log, but stick
|
|
// to the bottom either after stopping the scroll there or a cmd input
|
|
CmdOutput = new CmdOutputBase(document.getElementById("cmd-output"));
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
elementSelectorOnClick(".password-reveal", toggleVisiblePassword);
|
|
|
|
elementSelectorOnClick(".button-dbg-clear", (event) => {
|
|
event.preventDefault();
|
|
CmdOutput.clear();
|
|
});
|
|
|
|
elementSelectorOnClick(".button-settings-backup", () => {
|
|
document.getElementById("downloader").click();
|
|
});
|
|
elementSelectorOnClick(".button-settings-factory", resetToFactoryDefaults);
|
|
elementSelectorOnClick(".button-upgrade", handleFirmwareUpgrade);
|
|
|
|
document.getElementById("uploader").addEventListener("change", handleSettingsFile);
|
|
elementSelectorOnClick(".button-settings-restore", () => {
|
|
document.getElementById("uploader").click();
|
|
});
|
|
|
|
elementSelectorOnClick(".button-apikey", generateApiKey);
|
|
|
|
listenChanged(".settings-group");
|
|
listenChanged("input,select");
|
|
resetOriginals();
|
|
|
|
elementSelectorOnClick(".button-add-settings-group", groupSettingsAdd);
|
|
|
|
groupSettingsOnAdd("networks", () => {
|
|
wifiNetworkAdd();
|
|
});
|
|
|
|
groupSettingsOnAdd("leds", () => {
|
|
ledAdd();
|
|
});
|
|
|
|
groupSettingsOnAdd("schedules", () => {
|
|
if (event.detail.target) {
|
|
schAdd({schType: event.detail.target});
|
|
return;
|
|
}
|
|
});
|
|
|
|
groupSettingsOnAdd("rpn-rules", () => {
|
|
rpnAddRule();
|
|
});
|
|
groupSettingsOnAdd("rpn-topics", () => {
|
|
rpnAddTopic();
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// No group handler should be registered after this point, since we depend on the order
|
|
// of registration to trigger 'after-add' handler and update group attributes *after*
|
|
// module function finishes modifying the container
|
|
for (const group of document.querySelectorAll(".settings-group")) {
|
|
group.addEventListener("settings-group-add", groupSettingsHandler.add, false);
|
|
group.addEventListener("settings-group-del", groupSettingsHandler.del, false);
|
|
}
|
|
|
|
// don't autoconnect when opening from filesystem
|
|
if (window.location.protocol === "file:") {
|
|
processData({webMode: 0});
|
|
return;
|
|
}
|
|
|
|
// Optionally, support host=... param that could redirect to somewhere else
|
|
// Note of the Cross-Origin rules that apply, and require device to handle them
|
|
const search = new URLSearchParams(window.location.search),
|
|
host = search.get("host");
|
|
|
|
if (host !== null) {
|
|
connect(host);
|
|
} else {
|
|
connectToCurrentURL();
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", main);
|