webui: more typings, clean-up sensor units init

This commit is contained in:
Maxim Prokhorov
2024-07-04 07:30:40 +03:00
parent c3e587d6fd
commit 53dc51ff41
31 changed files with 1559 additions and 783 deletions

View File

@@ -2,12 +2,21 @@ import { randomString } from './core.mjs';
import { setChangedElement } from './settings.mjs';
function randomApiKey() {
const elem = document.forms["form-admin"].elements.apiKey;
const form = document.forms.namedItem("form-admin");
if (!form) {
return;
}
const elem = form.elements.namedItem("apiKey");
if (!(elem instanceof HTMLInputElement)) {
return;
}
elem.value = randomString(16, {hex: true});
setChangedElement(elem);
}
export function init() {
document.querySelector(".button-apikey")
.addEventListener("click", randomApiKey);
?.addEventListener("click", randomApiKey);
}

View File

@@ -108,7 +108,7 @@ export function onPanelTargetClick(event) {
* @param {number} length
* @param {RandomStringOptions} options
*/
export function randomString(length, {hex = false, lowercase = true, numbers = true, special = false, uppercase = true} = {}) {
export function randomString(length, {hex = false, lowercase = false, numbers = false, special = false, uppercase = false} = {}) {
let mask = "";
if (lowercase || hex) { mask += "abcdef"; }
if (lowercase) { mask += "ghijklmnopqrstuvwxyz"; }

View File

@@ -2,20 +2,19 @@ import { sendAction } from './connection.mjs';
import { loadConfigTemplate, mergeTemplate } from './template.mjs';
import { addSimpleEnumerables, variableListeners } from './settings.mjs';
function listeners() {
return {
"curtainState": (_, value) => {
initCurtain();
updateCurtain(value);
},
};
}
const BACKGROUND_MOVING = "rgb(192, 0, 0)";
const BACKGROUND_STOPPED = "rgb(64, 184, 221)";
/** @param {Event} event */
function buttonHandler(event) {
if (event.type !== "click") {
return;
}
if (!(event.target instanceof HTMLInputElement)) {
return;
}
event.preventDefault();
let code = -1;
@@ -35,47 +34,123 @@ function buttonHandler(event) {
}
}
/**
* @param {boolean} moving
* @param {number} button
*/
function styleButtons(moving, button) {
const elems = /** @type {NodeListOf<HTMLInputElement>} */
(document.querySelectorAll("curtain-button"));
if (!moving || (0 === button)) {
if (!moving) {
elems.forEach((elem) => {
elem.style.background = BACKGROUND_STOPPED;
});
}
if (0 === button) {
elems.forEach((elem) => {
if (elem.classList.contains("button-curtain-pause")) {
elem.style.background = BACKGROUND_MOVING;
}
});
}
return;
}
elems.forEach((elem) => {
if (elem.classList.contains("button-curtain-open")) {
elem.style.background =
(1 === button) ? BACKGROUND_MOVING :
(2 === button) ? BACKGROUND_STOPPED :
BACKGROUND_STOPPED;
} else if (elem.classList.contains("button-curtain-close")) {
elem.style.background =
(1 === button) ? BACKGROUND_STOPPED :
(2 === button) ? BACKGROUND_MOVING :
BACKGROUND_STOPPED;
}
});
}
/** @param {Event} event */
function positionHandler(event) {
if (!(event.target instanceof HTMLInputElement)) {
return;
}
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) {
const container = document.getElementById("curtains");
if (!container || container.childElementCount > 0) {
return;
}
// add and init curtain template, prepare multi switches
let line = loadConfigTemplate("curtain-control");
line.querySelector(".button-curtain-open")
.addEventListener("click", buttonHandler);
line.querySelector(".button-curtain-pause")
.addEventListener("click", buttonHandler);
line.querySelector(".button-curtain-close")
.addEventListener("click", buttonHandler);
const line = loadConfigTemplate("curtain-control");
["open", "pause", "close"]
.forEach((name) => {
line.querySelector(`.button-curtain-${name}`)
?.addEventListener("click", buttonHandler);
});
mergeTemplate(container, line);
// simple position slider
document.getElementById("curtainSet")
.addEventListener("change", positionHandler);
?.addEventListener("change", positionHandler);
addSimpleEnumerables("curtain", "Curtain", 1);
}
function setBackground(a, b) {
let elem = document.getElementById("curtainGetPicture");
elem.style.background = `linear-gradient(${a}, black ${b}%, #a0d6ff ${b}%)`;
/** @param {function(HTMLInputElement): void} callback */
function withSet(callback) {
const elem = document.getElementById("curtain-set");
if (elem instanceof HTMLInputElement) {
callback(elem);
}
}
function setBackgroundTwoSides(a, b) {
let elem = document.getElementById("curtainGetPicture");
elem.style.background = `linear-gradient(90deg, black ${a}%, #a0d6ff ${a}% ${b}%, black ${b}%)`;
/** @param {function(HTMLElement): void} callback */
function withPicture(callback) {
const elem = document.getElementById("curtain-picture");
if (elem instanceof HTMLElement) {
callback(elem);
}
}
/**
* @param {string} side_or_corner
* @param {number} angle
*/
function setBackground(side_or_corner, angle) {
withPicture((elem) => {
elem.style.background =
`linear-gradient(${side_or_corner}, black ${angle}%, #a0d6ff ${angle}%)`;
});
}
/**
* @param {number} angle
* @param {number} hint_angle
*/
function setBackgroundTwoSides(angle, hint_angle) {
withPicture((elem) => {
elem.style.background =
`linear-gradient(90deg, black ${angle}%, #a0d6ff ${angle}% ${hint_angle}%, black ${hint_angle}%)`;
});
}
/** @typedef {{get: number, set: number, button: number, moving: boolean, type: string}} CurtainValue */
/** @param {CurtainValue} value */
function updateCurtain(value) {
switch(value.type) {
switch (value.type) {
case '1': //One side left to right
setBackground('90deg', value.get);
break;
@@ -83,7 +158,7 @@ function updateCurtain(value) {
setBackground('270deg', value.get);
break;
case '3': //Two sides
setBackgroundTwoSides(value.get / 2, (100 - value.get/2));
setBackgroundTwoSides(value.get / 2, (100 - value.get / 2));
break;
case '0': //Roller
default:
@@ -91,29 +166,23 @@ function updateCurtain(value) {
break;
}
let set = document.getElementById("curtainSet");
set.value = value.set;
withSet((elem) => {
elem.value = value.set.toString();
});
const backgroundMoving = 'rgb(192, 0, 0)';
const backgroundStopped = 'rgb(64, 184, 221)';
styleButtons(value.moving, value.button);
}
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;
}
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"curtainState": (_, value) => {
initCurtain();
updateCurtain(value);
},
};
}
export function init() {

View File

@@ -77,11 +77,7 @@ class CmdOutputBase {
const CmdOutput = new CmdOutputBase();
/**
* @typedef {import('./settings.mjs').KeyValueListeners } KeyValueListeners
*/
/**
* @returns {KeyValueListeners}
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
@@ -112,7 +108,6 @@ function onFormSubmit(event) {
const cmd = event.target.elements
.namedItem("cmd");
if (!(cmd instanceof HTMLInputElement)) {
return;
}

View File

@@ -1,6 +1,7 @@
import { createNodeList } from './relay.mjs';
import { variableListeners } from './settings.mjs';
/** @returns {import('./settings.mjs').KeyValueListeners} */
function listeners() {
return {
"dczRelays": (_, value) => {

View File

@@ -56,8 +56,9 @@ export function showNotification(text) {
* @param {number} lineno
* @param {number} colno
* @param {any} error
* @return {string}
*/
function notify(message, source, lineno, colno, error) {
export function formatErrorEvent(message, source, lineno, colno, error) {
let text = "";
if (message) {
text += message;
@@ -71,24 +72,28 @@ function notify(message, source, lineno, colno, error) {
text += formatError(error);
}
showNotification(text);
return text;
}
/** @param {string} message */
export function notifyMessage(message) {
notify(message, "", 0, 0, null);
showNotification(
formatErrorEvent(message, "", 0, 0, null));
}
/** @param {Error} error */
export function notifyError(error) {
notify("", "", 0, 0, error);
showNotification(
formatErrorEvent("", "", 0, 0, error));
}
/** @param {ErrorEvent} event */
export function notifyErrorEvent(event) {
notify(event.message,
event.filename,
event.lineno,
event.colno,
event.error);
showNotification(
formatErrorEvent(
event.message,
event.filename,
event.lineno,
event.colno,
event.error));
}

View File

@@ -1,39 +1,38 @@
import { sendAction } from './connection.mjs';
import { variableListeners } from './settings.mjs';
function listeners() {
return {
"garlandBrightness": (_, value) => {
const brightnessSlider = document.getElementById("garlandBrightness");
brightnessSlider.value = value;
},
"garlandSpeed": (_, value) => {
const speedSlider = document.getElementById("garlandSpeed");
speedSlider.value = value;
},
};
/**
* @param {string} selector
* @param {function(HTMLInputElement): void} callback
*/
function withInputChange(selector, callback) {
const elem = document.querySelector(selector);
if (!(elem instanceof HTMLInputElement)) {
return;
}
elem.addEventListener("change", () => {
callback(elem);
});
}
export function init() {
variableListeners(listeners());
document.querySelector(".checkbox-garland-enable")
.addEventListener("change", (event) => {
sendAction("garland_switch", {status: event.target.checked ? 1 : 0});
withInputChange(".checkbox-garland-enable",
(elem) => {
sendAction("garland_switch", {status: elem.checked ? 1 : 0});
});
document.querySelector(".slider-garland-brightness")
.addEventListener("change", (event) => {
sendAction("garland_set_brightness", {brightness: event.target.value});
withInputChange(".slider-garland-brightness",
(elem) => {
sendAction("garland_set_brightness", {brightness: elem.value});
});
document.querySelector(".slider-garland-speed")
.addEventListener("change", (event) => {
sendAction("garland_set_speed", {speed: event.target.value});
withInputChange(".slider-garland-speed",
(elem) => {
sendAction("garland_set_speed", {speed: elem.value});
});
document.querySelector(".button-garland-set-default")
.addEventListener("click", () => {
?.addEventListener("click", () => {
sendAction("garland_set_default", {});
});
}

View File

@@ -1,17 +1,24 @@
import { addEnumerables, variableListeners } from './settings.mjs';
import { showErrorNotification } from './errors.mjs';
import { notifyMessage } from './errors.mjs';
function makeConfig(value) {
let types = [];
/**
* @param {any} config
*/
function updateEnumerables(config) {
/** @type {import('./settings.mjs').EnumerableEntry[]} */
const types = [];
for (const [type, id] of value.types) {
for (const [type, id] of /** @type {[string, number][]} */(config.types)) {
types.push({
"id": id,
"name": type
});
let gpios = [{"id": 153, "name": "NONE"}];
value[type].forEach((pin) => {
/** @type {import('./settings.mjs').EnumerableEntry[]} */
const gpios = [{"id": 153, "name": "NONE"}];
/** @type {number[]} */
(config[type]).forEach((pin) => {
gpios.push({"id": pin, "name": `GPIO${pin}`});
});
@@ -21,24 +28,32 @@ function makeConfig(value) {
addEnumerables("gpio-types", types);
}
function reportFailed(value) {
let failed = "";
for (const [pin, file, func, line] of value["failed-locks"]) {
failed += `GPIO${pin} @ ${file}:${func}:${line}\n`;
/** @param {[pin: number, file: string, func: string, line: number]} failed */
function reportFailed(failed) {
if (!failed.length) {
return;
}
if (failed.length > 0) {
showErrorNotification("Could not acquire locks on the following pins, check configuration\n\n" + failed);
let report = [];
for (const [pin, file, func, line] of failed) {
report.push(`GPIO${pin} @ ${file}:${func}:${line}`);
}
notifyMessage(`
Could not acquire locks on the following pins, check configuration
\n\n${report.join("\n")}`);
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"gpioConfig": (_, value) => {
makeConfig(value);
updateEnumerables(value);
},
"gpioInfo": (_, value) => {
reportFailed(value);
reportFailed(value["failed-locks"]);
},
};
}

View File

@@ -1,21 +1,27 @@
import { sendAction } from './connection.mjs';
/**
* @param {Event} event
* @param {number} state
*/
function publishState(event, state) {
event.preventDefault();
sendAction("ha-publish", {state});
}
/** @param {Event} event */
function publishEnabled(event) {
publishState(event, 1);
}
/** @param {Event} event */
function publishDisabled(event) {
publishState(event, 0);
}
export function init() {
document.querySelector(".button-ha-enabled")
.addEventListener("click", publishEnabled);
?.addEventListener("click", publishEnabled);
document.querySelector(".button-ha-disabled")
.addEventListener("click", publishDisabled);
?.addEventListener("click", publishDisabled);
}

View File

@@ -262,12 +262,12 @@ function askAndCallSimpleAction(event) {
});
}
/**
* @typedef {import('./settings.mjs').KeyValueListeners } KeyValueListeners
*/
// TODO https://github.com/microsoft/TypeScript/issues/58969
// at-import'ed type becomes 'unused' for some reason
// until fixed, prefer direct import vs. typedef
/**
* @returns {KeyValueListeners}
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
@@ -329,7 +329,7 @@ function initSetupPassword(form) {
event.preventDefault();
const forms = [form];
if (validateFormsPasswords(forms, true)) {
applySettings(getData(forms, {cleanup: true}));
applySettings(getData(forms, {cleanup: false}));
}
});
document.querySelector(".button-generate-password")

View File

@@ -1,37 +1,53 @@
import {
addSimpleEnumerables,
fromSchema,
groupSettingsOnAdd,
groupSettingsOnAddElem,
variableListeners,
} from './settings.mjs';
import { addFromTemplate } from './template.mjs';
import { addFromTemplate, addFromTemplateWithSchema } from './template.mjs';
function addNode(cfg) {
addFromTemplate(document.getElementById("leds"), "led-config", cfg);
/** @param {function(HTMLElement): void} callback */
function withLeds(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("leds")));
}
/**
* @param {HTMLElement} elem
*/
function addLed(elem) {
addFromTemplate(elem, "led-config", {});
}
/**
* @param {any} value
*/
function onConfig(value) {
withLeds((elem) => {
addFromTemplateWithSchema(
elem, "led-config",
value.leds, value.schema,
value.max ?? 0);
});
addSimpleEnumerables("led", "LED", value.leds.length);
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"ledConfig": (_, value) => {
let container = document.getElementById("leds");
if (container.childElementCount > 0) {
return;
}
value.leds.forEach((entries) => {
addNode(fromSchema(entries, value.schema));
});
addSimpleEnumerables("led", "LED", value.leds.length);
onConfig(value);
},
};
};
export function init() {
variableListeners(listeners());
groupSettingsOnAdd("leds", () => {
addNode();
withLeds((elem) => {
variableListeners(listeners());
groupSettingsOnAddElem(elem, () => {
addLed(elem);
});
});
}

View File

@@ -4,24 +4,46 @@ import { sendAction } from './connection.mjs';
import { mergeTemplate, loadTemplate } from './template.mjs';
import { addEnumerables, variableListeners } from './settings.mjs';
let ColorPicker = null;
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;
/** @param {function(HTMLElement): void} callback */
function withPicker(callback) {
const elem = document.getElementById("light-picker");
if (elem) {
callback(elem);
}
}
function hsvStringToColor(hsv) {
var parts = hsv.split(",");
/**
* @param {iro.Color} color
* @returns {string}
*/
function colorToHsvString(color) {
const hsv =
[color.hsv.h, color.hsv.s, color.hsv.v]
.filter((value) => value !== undefined)
.map(Math.round);
if (hsv.length !== 3) {
return "0,0,0";
}
return hsv.join(",");
}
/**
* @param {string} string
*/
function hsvStringToColor(string) {
const parts = string.split(",")
.map(parseInt);
return {
h: parseInt(parts[0]),
s: parseInt(parts[1]),
v: parseInt(parts[2])
h: parts[0] ?? 0,
s: parts[1] ?? 0,
v: parts[2] ?? 0,
};
}
/** @param {string} type */
function colorSlider(type) {
return {component: iro.ui.Slider, options: {sliderType: type}};
}
@@ -34,36 +56,50 @@ function colorBox() {
return {component: iro.ui.Box, options: {}};
}
/**
* @param {"rgb" | "hsv"} mode
* @param {string} value
*/
function colorUpdate(mode, value) {
if ("rgb" === mode) {
ColorPicker.color.hexString = value;
} else if ("hsv" === mode) {
ColorPicker.color.hsv = hsvStringToColor(value);
}
withPicker((elem) => {
elem.dispatchEvent(
new CustomEvent("color:string", {
detail: {
mode: mode,
value: value,
}}));
});
}
/** @param {number} id */
function lightStateHideRelay(id) {
styleInject([
styleVisible(`.relay-control-${id}`, false)
]);
}
/** @param {function(HTMLInputElement): void} callback */
function withState(callback) {
const elem = document.getElementById("light-state-value");
if (elem instanceof HTMLInputElement) {
callback(elem);
}
}
function initLightState() {
const toggle = document.getElementById("light-state-value");
toggle.addEventListener("change", (event) => {
event.preventDefault();
sendAction("light", {state: event.target.checked});
withState((elem) => {
elem.addEventListener("change", (event) => {
event.preventDefault();
sendAction("light", {state: /** @type {!HTMLInputElement} */(event.target).checked});
});
});
}
function updateLightState(value) {
const state = document.getElementById("light-state-value");
state.checked = value;
colorPickerState(value);
}
/** @param {boolean} value */
function colorPickerState(value) {
const light = document.getElementById("light");
const light = /** @type {!HTMLElement} */
(document.getElementById("light"));
if (value) {
light.classList.add("light-on");
} else {
@@ -71,20 +107,35 @@ function colorPickerState(value) {
}
}
/** @param {boolean} value */
function updateLightState(value) {
withState((elem) => {
elem.checked = value;
colorPickerState(value);
});
}
/** @param {boolean} value */
function colorEnabled(value) {
if (value) {
lightAddClass("light-color");
}
}
/** @param {boolean} value */
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)
/** @type {function(iro.Color): void} */
let change = () => {
};
/** @type {string[]} */
const rules = [];
/** @type {{"component": any, "options": any}[]} */
const layout = [];
// RGB
@@ -110,57 +161,115 @@ function colorInit(value) {
layout.push(colorSlider("value"));
styleInject(rules);
ColorPicker = new iro.ColorPicker("#light-picker", {layout});
ColorPicker.on("input:change", change);
withPicker((elem) => {
// TODO tsserver does not like the resulting type
const picker = new /** @type {any} */
(iro.ColorPicker(elem, {layout}));
picker.on("input:change", change);
elem.addEventListener("color:string", (event) => {
const color = /** @type {CustomEvent<{mode: string, value: string}>} */
(event).detail;
switch (color.mode) {
case "rgb":
picker.color.hexString = color.value;
break;
case "hsv":
picker.color.hsv = hsvStringToColor(color.value);
break;
}
});
});
}
function updateMireds(value) {
const mireds = document.getElementById("mireds-value");
if (mireds !== null) {
mireds.value = value;
mireds.nextElementSibling.textContent = value;
/** @param {function(HTMLInputElement): void} callback */
function withMiredsValue(callback) {
const elem = document.getElementById("mireds-value");
if (elem instanceof HTMLInputElement) {
callback(elem);
}
}
function lightAddClass(className) {
const light = document.getElementById("light");
light.classList.add(className);
/**
* @param {HTMLElement} elem
* @param {string} text
*/
function textForNextSibling(elem, text) {
const next = elem.nextElementSibling;
if (!next) {
return;
}
next.textContent = text;
}
// White implies we should hide one or both white channels
/**
* @param {HTMLInputElement} elem
* @param {number} value
*/
function setMiredsValue(elem, value) {
elem.value = value.toString();
textForNextSibling(elem, elem.value);
}
/** @param {number} value */
function updateMireds(value) {
withMiredsValue((elem) => {
setMiredsValue(elem, value);
});
}
/** @param {string} className */
function lightAddClass(className) {
const light = document.getElementById("light");
light?.classList?.add(className);
}
/**
* implies we should hide one or both white channels
* @param {boolean} value
*/
function whiteEnabled(value) {
if (value) {
lightAddClass("light-white");
}
}
// When there are CCT controls, no need for raw white channel sliders
/**
* no need for raw white channel sliders with cct
* @param {boolean} value
*/
function cctEnabled(value) {
if (value) {
lightAddClass("light-cct");
}
}
/** @param {{cold: number, warm: number}} value */
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});
withMiredsValue((elem) => {
elem.setAttribute("min", value.cold.toString());
elem.setAttribute("max", value.warm.toString());
elem.addEventListener("change", () => {
textForNextSibling(elem, elem.value);
sendAction("light", {mireds: elem.value});
});
});
const datalist = control.querySelector("datalist");
const [datalist] = control.querySelectorAll("datalist");
datalist.innerHTML = `
<option value="${value.cold}">Cold</option>
<option value="${value.warm}">Warm</option>
`;
<option value="${value.cold}">Cold</option>
<option value="${value.warm}">Warm</option>`;
mergeTemplate(document.getElementById("light-cct"), control);
mergeTemplate(
/** @type {HTMLElement} */
(document.getElementById("light-cct")), control);
}
/** @param {{[k: string]: any}} data */
function updateLight(data) {
for (const [key, value] of Object.entries(data)) {
switch (key) {
@@ -202,60 +311,95 @@ function updateLight(data) {
}
}
/** @param {Event} event */
function onChannelSliderChange(event) {
event.target.nextElementSibling.textContent = event.target.value;
if (!(event.target instanceof HTMLInputElement)) {
return;
}
let channel = {}
channel[event.target.dataset["id"]] = event.target.value;
const target = event.target;
textForNextSibling(target, target.value);
const id = target.dataset["id"];
if (!id) {
return;
}
sendAction("light", {channel});
sendAction("light", {
channel: {
[id]: target.value
}
});
}
/** @param {Event} event */
function onBrightnessSliderChange(event) {
event.target.nextElementSibling.textContent = event.target.value;
if (!(event.target instanceof HTMLInputElement)) {
return;
}
textForNextSibling(event.target, 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);
const elem = template.getElementById("brightness-value");
elem?.addEventListener("change", onBrightnessSliderChange);
mergeTemplate(document.getElementById("light-brightness"), template);
mergeTemplate(
/** @type {!HTMLElement} */
(document.getElementById("light-brightness")), template);
}
/** @param {number} value */
function updateBrightness(value) {
const brightness = document.getElementById("brightness-value");
if (brightness !== null) {
brightness.value = value;
brightness.nextElementSibling.textContent = value;
const elem = document.getElementById("brightness-value");
if (elem instanceof HTMLInputElement) {
elem.value = value.toString();
textForNextSibling(elem, elem.value);
}
}
/** @param {string[]} channels */
function initChannels(channels) {
const container = document.getElementById("light-channels");
if (!container) {
return;
}
/** @type {import('./settings.mjs').EnumerableEntry[]} */
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;
const [root] = line.querySelectorAll("div");
root.setAttribute("id", `light-channel-${tag.toLowerCase()}`);
const name =
`Channel #${channel} (${tag.toUpperCase()})`;
const [label] = line.querySelectorAll("label");
label.textContent = name;
enumerables.push({"id": channel, "name": name});
const [span] = line.querySelectorAll("span");
span.dataset["id"] = channel.toString();
const [slider] = line.querySelectorAll("input");
slider.dataset["id"] = channel.toString();
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);
}
/** @param {number[]} values */
function updateChannels(values) {
const container = document.getElementById("light");
if (!container) {
@@ -264,15 +408,18 @@ function updateChannels(values) {
values.forEach((value, channel) => {
const slider = container.querySelector(`input.slider[data-id='${channel}']`);
if (!slider) {
if (!(slider instanceof HTMLInputElement)) {
return;
}
slider.value = value;
slider.nextElementSibling.textContent = value;
slider.value = value.toString();
textForNextSibling(slider, slider.value);
});
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"light": (_, value) => {

View File

@@ -10,7 +10,7 @@ function clear() {
export function init() {
document.querySelector(".button-lightfox-learn")
.addEventListener("click", learn);
?.addEventListener("click", learn);
document.querySelector(".button-lightfox-clear")
.addEventListener("click", clear);
?.addEventListener("click", clear);
}

View File

@@ -174,11 +174,7 @@ async function onFileChanged(event) {
}
/**
* @typedef {import('./settings.mjs').KeyValueListeners } KeyValueListeners
*/
/**
* @returns {KeyValueListeners}
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {

View File

@@ -10,17 +10,17 @@
<div class="pure-control-group">
<label>Enabled</label>
<input class="checkbox-toggle checkbox-garland-enable" type="checkbox" name="garlandEnabled">
<input class="checkbox-toggle checkbox-garland-enable" type="checkbox" name="garlandEnabled" data-action="none">
</div>
<div class="pure-control-group">
<label>Brightness</label>
<input class="slider slider-garland-brightness pure-input-3-4" type="range" min="12" max="254" step="22" id="garlandBrightness">
<input class="slider slider-garland-brightness pure-input-3-4" type="range" min="12" max="254" step="22" id="garlandBrightness" data-action="none">
</div>
<div class="pure-control-group">
<label>Speed</label>
<input class="slider slider-garland-speed pure-input-3-4" type="range" min="10" max="70" id="garlandSpeed">
<input class="slider slider-garland-speed pure-input-3-4" type="range" min="10" max="70" id="garlandSpeed" data-action="none">
</div>
<button type="button" class="pure-button button-garland-set-default">Set default</button>

View File

@@ -1,125 +1,177 @@
import {
addEnumerables,
fromSchema,
getEnumerables,
variableListeners,
} from './settings.mjs';
import { addFromTemplate } from './template.mjs';
import { sendAction } from './connection.mjs';
import {
addFromTemplate,
fromSchema,
loadTemplate,
loadConfigTemplate,
mergeTemplate,
NumberInput,
} from './template.mjs';
import {
addEnumerables,
getEnumerables,
setOriginalsFromValues,
variableListeners,
} from './settings.mjs';
/** @param {Event} event */
function onToggle(event) {
event.preventDefault();
sendAction("relay", {
id: parseInt(event.target.dataset["id"], 10),
status: event.target.checked ? "1" : "0"});
}
function initToggle(id, cfg) {
const line = loadTemplate("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", onToggle);
toggle.setAttribute("id", realId);
toggle.previousElementSibling.setAttribute("for", realId);
mergeTemplate(document.getElementById("relays"), line);
}
function updateState(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 addConfigNode(cfg) {
addFromTemplate(document.getElementById("relayConfig"), "relay-config", cfg);
}
function listeners() {
return {
"relayConfig": (_, value) => {
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})`
});
initToggle(id, cfg);
addConfigNode(cfg);
});
addEnumerables("relay", relays);
},
"relayState": (_, value) => {
updateState(value);
},
};
}
export function createNodeList(containerId, values, keyPrefix) {
const target = document.getElementById(containerId);
if (target.childElementCount > 0) {
const target = /** @type {!HTMLInputElement} */(event.target);
const id = target.dataset["id"];
if (!id) {
return;
}
// TODO: let schema set the settings key
const fragment = loadConfigTemplate("number-input");
values.forEach((value, index) => {
const line = fragment.cloneNode(true);
sendAction("relay", {
id: parseInt(id, 10),
status: target.checked ? "1" : "0"});
}
const enumerables = getEnumerables("relay");
line.querySelector("label").textContent = (enumerables)
? enumerables[index].name : `Switch #${index}`;
/**
* @param {number} id
* @param {any} cfg
*/
function initToggle(id, cfg) {
const container = document.getElementById("relays");
if (!container) {
return;
}
const input = line.querySelector("input");
input.name = keyPrefix;
input.value = value;
input.dataset["original"] = value;
const line = loadTemplate("relay-control");
mergeTemplate(target, line);
const root = /** @type {!HTMLDivElement} */
(line.querySelector("div"));
root.classList.add(`relay-control-${id}`);
const name = /** @type {!HTMLSpanElement} */
(line.querySelector("span[data-key='relayName']"));
name.textContent = cfg.relayName;
name.dataset["id"] = id.toString();
name.setAttribute("title", cfg.relayProv);
const toggle = /** @type {!HTMLInputElement} */
(line.querySelector("input[type='checkbox']"));
toggle.checked = false;
toggle.disabled = true;
toggle.dataset["id"] = id.toString();
toggle.addEventListener("change", onToggle);
const realId = `relay${id}`;
toggle.setAttribute("id", realId);
toggle.previousElementSibling?.setAttribute("for", realId);
mergeTemplate(container, line);
}
/**
* @param {any[]} states
* @param {string[]} schema
*/
function updateFromState(states, schema) {
states.forEach((state, id) => {
const elem = /** @type {!HTMLInputElement} */
(document.querySelector(`input[name='relay'][data-id='${id}']`));
const relay = fromSchema(state, schema);
const status = /** @type {boolean} */(relay.status);
elem.checked = status;
// TODO: publish possible enum values in ws init
const as_lock = new Map();
as_lock.set(0, false);
as_lock.set(1, !status);
as_lock.set(2, status);
const lock = /** @type {number} */(relay.lock);
if (as_lock.has(lock)) {
elem.disabled = as_lock.get(lock);
}
});
}
/** @param {any} cfg */
function addConfigNode(cfg) {
const container = /** @type {!HTMLElement} */
(document.getElementById("relayConfig"));
addFromTemplate(container, "relay-config", cfg);
}
/**
* @param {any[]} configs
* @param {string[]} schema
*/
function updateFromConfig(configs, schema) {
const container = document.getElementById("relays");
if (!container || container.childElementCount > 0) {
return;
}
/** @type {import('./settings.mjs').EnumerableEntry[]} */
const relays = [];
configs.forEach((config, id) => {
const relay = fromSchema(config, schema);
if (!relay.relayName) {
relay.relayName = `Switch #${id}`;
}
relays.push({
"id": id,
"name": `${relay.relayName} (${relay.relayProv})`
});
initToggle(id, relay);
addConfigNode(relay);
});
addEnumerables("relay", relays);
}
/**
* @param {string} id
* @param {any[]} values
* @param {string} keyPrefix
*/
export function createNodeList(id, values, keyPrefix) {
const container = document.getElementById(id);
if (!container || container.childElementCount > 0) {
return;
}
// TODO generic template to automatically match elems to (limited) schema?
const template = new NumberInput();
values.forEach((value, index) => {
mergeTemplate(container, template.with(
(label, input) => {
const enumerables = getEnumerables("relay");
label.textContent =
(enumerables)
? enumerables[index].name
: `Switch #${index}`;
input.name = keyPrefix;
input.value = value;
setOriginalsFromValues([input]);
}));
});
}
/** @returns {import('./settings.mjs').KeyValueListeners} */
function listeners() {
return {
"relayConfig": (_, value) => {
updateFromConfig(value.relays, value.schema);
},
"relayState": (_, value) => {
updateFromState(value.relays, value.schema);
},
};
}
export function init() {
variableListeners(listeners());
}

View File

@@ -11,33 +11,51 @@ import {
loadConfigTemplate,
} from './template.mjs';
/** @param {Event} event */
function onButtonPress(event) {
if (!(event.target instanceof HTMLElement)) {
return;
}
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"
});
if (!buttonRfbClass) {
return;
}
const container = event.target?.parentElement?.parentElement;
if (!container) {
return;
}
const input = container.querySelector("input");
if (!input || !input.dataset["id"]) {
return;
}
sendAction(`rfb${buttonRfbClass.replace(prefix, "")}`, {
id: parseInt(input.dataset["id"], 10),
status: input.name === "rfbON"
});
}
function addNode() {
let container = document.getElementById("rfbNodes");
const container = document.getElementById("rfbNodes");
if (!container) {
return;
}
const id = container.childElementCount;
const id = container.childElementCount.toString();
const line = loadConfigTemplate("rfb-node");
line.querySelector("span").textContent = id;
for (let input of line.querySelectorAll("input")) {
line.querySelectorAll("span").forEach((span) => {
span.textContent = id;
});
line.querySelectorAll("input").forEach((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}`)) {
@@ -48,21 +66,31 @@ function addNode() {
mergeTemplate(container, line);
}
function handleCodes(value) {
value.codes.forEach((codes, id) => {
const realId = id + value.start;
const [off, on] = codes;
/**
* @typedef {[string, string]} CodePair
* @param {CodePair[]} pairs
* @param {!number} start
*/
function handleCodePairs(pairs, start) {
pairs.forEach((pair, id) => {
const realId = id + (start ?? 0);
const [off, on] = pair;
const rfbOn = document.getElementById(`rfbON${realId}`);
setInputValue(rfbOn, on);
const rfbOff = document.getElementById(`rfbOFF${realId}`);
const rfbOff = /** @type {!HTMLInputElement} */
(document.getElementById(`rfbOFF${realId}`));
setInputValue(rfbOff, off);
const rfbOn = /** @type {!HTMLInputElement} */
(document.getElementById(`rfbON${realId}`));
setInputValue(rfbOn, on);
setOriginalsFromValues([rfbOn, rfbOff]);
});
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"rfbCount": (_, value) => {
@@ -71,7 +99,7 @@ function listeners() {
}
},
"rfb": (_, value) => {
handleCodes(value);
handleCodePairs(value.codes, value.start);
},
};
}

View File

@@ -1,34 +1,55 @@
import { addFromTemplate } from './template.mjs';
import { groupSettingsOnAdd, fromSchema, variableListeners } from './settings.mjs';
import { addFromTemplate, addFromTemplateWithSchema } from './template.mjs';
import { groupSettingsOnAddElem, variableListeners } from './settings.mjs';
import { sendAction } from './connection.mjs';
let State = {
filters: {}
};
/**
* @typedef {Map<number, string>} FiltersMap
*/
function addMapping(cfg) {
addFromTemplate(document.getElementById("rfm69-mapping"), "rfm69-node", cfg);
}
function messages() {
let [body] = document.getElementById("rfm69-messages").tBodies;
return body;
}
function rows() {
return messages().rows;
/** @type {FiltersMap} */
const Filters = new Map();
/** @param {function(HTMLTableElement): void} callback */
function withMessages(callback) {
callback(/** @type {!HTMLTableElement} */
(document.getElementById("rfm69-messages")));
}
/** @param {[number, number, number, string, string, number, number, number]} message */
function addMessage(message) {
let timestamp = (new Date()).toLocaleTimeString("en-US", {hour12: false});
withMessages((elem) => {
const timestamp = (new Date())
.toLocaleTimeString("en-US", {hour12: false});
let container = messages();
let row = container.insertRow();
for (let value of [timestamp, ...message]) {
let cell = row.insertCell();
cell.appendChild(document.createTextNode(value));
filterRow(State.filters, row);
}
const row = elem.tBodies[0].insertRow();
for (let value of [timestamp, ...message]) {
const cell = row.insertCell();
cell.appendChild(
document.createTextNode(value.toString()));
filterRow(Filters, row);
}
});
}
/** @param {function(HTMLElement): void} callback */
function withMapping(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("rfm69-mapping")));
}
/** @param {HTMLElement} elem */
function addMappingNode(elem) {
addFromTemplate(elem, "rfm69-node", {});
}
/** @param {any} value */
function onMapping(value) {
withMapping((elem) => {
addFromTemplateWithSchema(
elem, "rfm69-node",
value.mapping, value.schema,
value.max ?? 0);
});
}
function clearCounters() {
@@ -37,59 +58,84 @@ function clearCounters() {
}
function clearMessages() {
let container = messages();
while (container.rows.length) {
container.deleteRow(0);
}
withMessages((elem) => {
while (elem.rows.length) {
elem.deleteRow(0);
}
});
return false;
}
/**
* @param {FiltersMap} filters
* @param {HTMLTableRowElement} row
*/
function filterRow(filters, row) {
row.style.display = "table-row";
for (const [cell, filter] of Object.entries(filters)) {
for (const [cell, filter] of filters) {
if (row.cells[cell].textContent !== filter) {
row.style.display = "none";
}
}
}
/**
* @param {FiltersMap} filters
* @param {HTMLTableRowElement[]} rows
*/
function filterRows(filters, rows) {
for (let row of rows) {
filterRow(filters, row);
}
}
/** @param {Event} event */
function filterEvent(event) {
if (event.target.classList.contains("filtered")) {
delete State.filters[event.target.cellIndex];
} else {
State.filters[event.target.cellIndex] = event.target.textContent;
if (!(event.target instanceof HTMLTableCellElement)) {
return;
}
event.target.classList.toggle("filtered");
filterRows(State.filters, rows());
if (!event.target.textContent) {
return;
}
const index = event.target.cellIndex;
if (event.target.classList.contains("filtered")) {
Filters.delete(index);
} else {
Filters.set(index, event.target.textContent);
}
event.target.classList.toggle("filtered");
withMessages((elem) => {
filterRows(Filters, Array.from(elem.rows));
});
}
function clearFilters() {
let container = messages();
for (let elem of container.querySelectorAll("filtered")) {
elem.classList.remove("filtered");
}
withMessages((elem) => {
for (let filtered of elem.querySelectorAll("filtered")) {
filtered.classList.remove("filtered");
}
State.filters = {};
filterRows(State.filters, container.rows);
Filters.clear();
filterRows(Filters, Array.from(elem.rows));
});
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"rfm69": (_, value) => {
if (value.message !== undefined) {
addMessage(value.message);
}
if (value.mapping !== undefined) {
value.mapping.forEach((mapping) => {
addMapping(fromSchema(mapping, value.schema));
});
onMapping(value);
}
},
};
@@ -99,16 +145,18 @@ export function init() {
variableListeners(listeners());
document.querySelector(".button-clear-counts")
.addEventListener("click", clearCounters);
?.addEventListener("click", clearCounters);
document.querySelector(".button-clear-messages")
.addEventListener("click", clearMessages);
?.addEventListener("click", clearMessages);
document.querySelector(".button-clear-filters")
.addEventListener("click", clearFilters);
?.addEventListener("click", clearFilters);
document.querySelector("#rfm69-messages tbody")
.addEventListener("click", filterEvent);
?.addEventListener("click", filterEvent);
groupSettingsOnAdd("rfm69-mapping", () => {
addMapping();
withMapping((elem) => {
groupSettingsOnAddElem(elem, () => {
addMappingNode(elem);
});
});
}

View File

@@ -1,24 +1,57 @@
import { groupSettingsOnAdd, variableListeners, fromSchema } from './settings.mjs';
import { addFromTemplate } from './template.mjs';
import { groupSettingsOnAddElem, variableListeners } from './settings.mjs';
import { addFromTemplateWithSchema, addFromTemplate } from './template.mjs';
function addRule(cfg) {
addFromTemplate(document.getElementById("rpn-rules"), "rpn-rule", cfg);
/** @param {function(HTMLElement): void} callback */
function withRules(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("rpn-rules")));
}
function addTopic(cfg) {
addFromTemplate(document.getElementById("rpn-topics"), "rpn-topic", cfg);
/**
* @param {HTMLElement} elem
* @param {string} rule
*/
function addRule(elem, rule = "") {
addFromTemplate(elem, "rpn-rule", {rpnRule: rule});
}
/** @param {function(HTMLElement): void} callback */
function withTopics(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("rpn-topics")));
}
/** @param {HTMLElement} elem */
function addTopic(elem) {
addFromTemplate(elem, "rpn-topic", {});
}
/**
* @param {HTMLElement} elem
* @param {any} value
*/
function addTopicWithSchema(elem, value) {
addFromTemplateWithSchema(
elem, "rpn-topic",
value.topics, value.schema,
value.max ?? 0);
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"rpnRules": (_, value) => {
for (let rule of value) {
addRule({"rpnRule": rule});
}
withRules((elem) => {
for (let rule of value) {
addRule(elem, rule);
}
});
},
"rpnTopics": (_, value) => {
value.topics.forEach((topic) => {
addTopic(fromSchema(topic, value.schema));
withTopics((elem) => {
addTopicWithSchema(elem, value);
});
},
};
@@ -26,10 +59,14 @@ function listeners() {
export function init() {
variableListeners(listeners());
groupSettingsOnAdd("rpn-rules", () => {
addRule();
withRules((elem) => {
groupSettingsOnAddElem(elem, () => {
addRule(elem);
});
});
groupSettingsOnAdd("rpn-topics", () => {
addTopic();
withTopics((elem) => {
groupSettingsOnAddElem(elem, () => {
addTopic(elem);
});
});
}

View File

@@ -1,26 +1,47 @@
import { addFromTemplate } from './template.mjs';
import { groupSettingsOnAdd, variableListeners, fromSchema } from './settings.mjs';
import { addFromTemplate, addFromTemplateWithSchema } from './template.mjs';
import { groupSettingsOnAddElem, variableListeners } from './settings.mjs';
function addNode(cfg) {
addFromTemplate(document.getElementById("schedules"), "schedule-config", cfg);
/** @param {function(HTMLElement): void} callback */
function withSchedules(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("schedules")));
}
/**
* @param {HTMLElement} elem
*/
function scheduleAdd(elem) {
addFromTemplate(elem, "schedule-config", {});
}
/**
* @param {any} value
*/
function onConfig(value) {
withSchedules((elem) => {
addFromTemplateWithSchema(
elem, "schedule-config",
value.schedules, value.schema,
value.max ?? 0);
});
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"schConfig": (_, value) => {
let container = document.getElementById("schedules");
container.dataset["settingsMax"] = value.max;
value.schedules.forEach((entries) => {
addNode(fromSchema(entries, value.schema));
});
onConfig(value);
},
};
}
export function init() {
variableListeners(listeners());
groupSettingsOnAdd("schedules", () => {
addNode();
withSchedules((elem) => {
variableListeners(listeners());
groupSettingsOnAddElem(elem, () => {
scheduleAdd(elem);
});
});
}

View File

@@ -1,11 +1,4 @@
import { sendAction } from './connection.mjs';
import { variableListeners } from './settings.mjs';
import {
loadConfigTemplate,
loadTemplate,
mergeTemplate,
} from './template.mjs';
import {
onPanelTargetClick,
@@ -15,199 +8,340 @@ import {
} from './core.mjs';
import {
fromSchema,
initSelect,
setChangedElement,
setOriginalsFromValues,
setSelectValue,
variableListeners,
} from './settings.mjs';
import {
fromSchema,
loadConfigTemplate,
loadTemplate,
mergeTemplate,
NumberInput,
} from './template.mjs';
/** @typedef {{name: string, units: number, type: number, index_global: number, description: string}} Magnitude */
/** @typedef {[number, string]} SupportedUnits */
const Magnitudes = {
properties: {},
errors: {},
types: {},
/** @type {Map<number, Magnitude>} */
properties: new Map(),
/** @type {Map<number, string>} */
errors: new Map(),
/** @type {Map<number, string>} */
types: new Map(),
units: {
names: {},
supported: {}
/** @type {Map<number, SupportedUnits[]>} */
supported: new Map(),
/** @type {Map<number, string>} */
names: new Map(),
},
typePrefix: {},
prefixType: {}
/** @type {Map<number, string>} */
typePrefix: new Map(),
/** @type {Map<string, number>} */
prefixType: new Map(),
};
/**
* @param {Magnitude} magnitude
* @param {string} name
*/
function magnitudeTypedKey(magnitude, name) {
const prefix = Magnitudes.typePrefix[magnitude.type];
const prefix = Magnitudes.typePrefix
.get(magnitude.type);
const index = magnitude.index_global;
return `${prefix}${name}${index}`;
}
function initModuleMagnitudes(data) {
const targetId = `${data.prefix}-magnitudes`;
/**
* @param {string} prefix
* @param {any[][]} values
* @param {string[]} schema
*/
function initModuleMagnitudes(prefix, values, schema) {
const container = document.getElementById(`${prefix}-magnitudes`);
if (!container || container.childElementCount > 0) {
return;
}
let target = document.getElementById(targetId);
if (target.childElementCount > 0) { return; }
values.forEach((value) => {
const magnitude = fromSchema(value, schema);
data.values.forEach((values) => {
const entry = fromSchema(values, data.schema);
const type = /** @type {!number} */
(magnitude.type);
const index_global = /** @type {!number} */
(magnitude.index_global);
const index_module = /** @type {!number} */
(magnitude.index_module);
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;
const line = loadConfigTemplate("module-magnitude");
let input = line.querySelector("input");
input.name = `${data.prefix}Magnitude`;
input.value = entry.index_module;
const label = /** @type {!HTMLLabelElement} */
(line.querySelector("label"));
label.textContent =
`${Magnitudes.types.get(type) ?? "?"} #${magnitude.index_global}`;
const span = /** @type {!HTMLSpanElement} */
(line.querySelector("span"));
span.textContent =
Magnitudes.properties.get(index_global)?.description ?? "";
const input = /** @type {!HTMLInputElement} */
(line.querySelector("input"));
input.name = `${prefix}Magnitude`;
input.value = index_module.toString();
input.dataset["original"] = input.value;
mergeTemplate(target, line);
mergeTemplate(container, line);
});
}
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;
/**
* @param {any} types
* @param {any} errors
* @param {any} units
*/
function initMagnitudes(types, errors, units) {
/** @type {[number, string, string][]} */
(types.values).forEach((value) => {
const info = fromSchema(value, types.schema);
const type = /** @type {number} */(info.type);
Magnitudes.types.set(type,
/** @type {string} */(info.name));
const prefix = /** @type {string} */(info.prefix);
Magnitudes.typePrefix.set(type, prefix);
Magnitudes.prefixType.set(prefix, type);
});
data.errors.values.forEach((cfg) => {
const error = fromSchema(cfg, data.errors.schema);
Magnitudes.errors[error.type] = error.name;
/** @type {[number, string][]} */
(errors.values).forEach((value) => {
const error = fromSchema(value, errors.schema);
Magnitudes.errors.set(
/** @type {number} */(error.type),
/** @type {string} */(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;
/** @type {SupportedUnits[][]} */
(units.values).forEach((value, id) => {
Magnitudes.units.supported.set(id, value);
value.forEach(([type, name]) => {
Magnitudes.units.names.set(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));
/**
* @typedef {function(number, Magnitude): void} MagnitudeCallback
* @param {any[]} values
* @param {string[]} schema
* @param {MagnitudeCallback[]} callbacks
*/
function initMagnitudesList(values, schema, callbacks) {
values.forEach((value, id) => {
const magnitude = fromSchema(value, schema);
const type = /** @type {number} */(magnitude.type);
const prettyName =
`${Magnitudes.types.get(type) ?? "?"} #${magnitude.index_global}`;
/** @type {Magnitude} */
const result = {
name: prettyName,
units: magnitude.units,
type: magnitude.type,
index_global: magnitude.index_global,
description: magnitude.description
units: /** @type {number} */(magnitude.units),
type: type,
index_global: /** @type {number} */(magnitude.index_global),
description: /** @type {string} */(magnitude.description),
};
Magnitudes.properties[id] = result;
Magnitudes.properties.set(id, result);
callbacks.forEach((callback) => {
callback(id, result);
});
});
}
/**
* @param {number} id
* @param {Magnitude} magnitude
*/
function createMagnitudeInfo(id, magnitude) {
const container = document.getElementById("magnitudes");
if (!container) {
return;
}
const info = loadTemplate("magnitude-info");
const label = info.querySelector("label");
const line = loadTemplate("magnitude-info");
const label = /** @type {!HTMLLabelElement} */
(line.querySelector("label"));
label.textContent = magnitude.name;
const input = info.querySelector("input");
input.dataset["id"] = id;
input.dataset["type"] = magnitude.type;
const input = /** @type {!HTMLInputElement} */
(line.querySelector("input"));
input.dataset["id"] = id.toString();
input.dataset["type"] = magnitude.type.toString();
const description = info.querySelector(".magnitude-description");
const info = /** @type {!HTMLSpanElement} */
(line.querySelector(".magnitude-info"));
info.style.display = "none";
const description = /** @type {!HTMLSpanElement} */
(line.querySelector(".magnitude-description"));
description.textContent = magnitude.description;
const extra = info.querySelector(".magnitude-info");
extra.style.display = "none";
mergeTemplate(container, info);
mergeTemplate(container, line);
}
/**
* @param {number} id
* @param {Magnitude} magnitude
*/
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);
setOriginalsFromValues([select]);
const container = document.getElementById("magnitude-units");
container.parentElement.classList.remove("maybe-hidden");
mergeTemplate(container, line);
const supported = Magnitudes.units.supported.get(id);
if ((supported === undefined) || (!supported.length)) {
return;
}
const container = document.getElementById("magnitude-units");
if (!container) {
return;
}
const line = loadTemplate("magnitude-units");
const label = /** @type {!HTMLLabelElement} */
(line.querySelector("label"));
label.textContent =
`${Magnitudes.types.get(magnitude.type) ?? "?"} #${magnitude.index_global}`;
const select = /** @type {!HTMLSelectElement} */
(line.querySelector("select"));
select.setAttribute("name",
magnitudeTypedKey(magnitude, "Units"));
/** @type {{id: number, name: string}[]} */
const options = [];
supported.forEach(([id, name]) => {
options.push({id, name});
});
initSelect(select, options);
setSelectValue(select, magnitude.units);
setOriginalsFromValues([select]);
container?.parentElement?.classList?.remove("maybe-hidden");
mergeTemplate(container, line);
}
function magnitudeSettingInfo(id, key) {
/**
* @typedef {{id: number, name: string, key: string, prefix: string, index_global: number}} SettingInfo
* @param {number} id
* @param {string} suffix
* @returns {SettingInfo | null}
*/
function magnitudeSettingInfo(id, suffix) {
const props = Magnitudes.properties.get(id);
if (!props) {
return null;
}
const prefix = Magnitudes.typePrefix.get(props.type);
if (!prefix) {
return null;
}
const out = {
id: id,
name: Magnitudes.properties[id].name,
prefix: `${Magnitudes.typePrefix[Magnitudes.properties[id].type]}`,
index_global: `${Magnitudes.properties[id].index_global}`
name: props.name,
key: `${prefix}${suffix}${props.index_global}`,
prefix: prefix,
index_global: props.index_global,
};
out.key = `${out.prefix}${key}${out.index_global}`;
return out;
}
/**
* @param {number} id
* @returns {SettingInfo | null}
*/
function emonRatioInfo(id) {
return magnitudeSettingInfo(id, "Ratio");
}
function initMagnitudeTextSetting(containerId, id, keySuffix, value) {
const template = loadTemplate("text-input");
const input = template.querySelector("input");
/**
* @param {string} containerId
* @param {number} id
* @param {string} keySuffix
* @param {number} value
*/
function initMagnitudeNumberSetting(containerId, id, keySuffix, value) {
const container = document.getElementById(containerId);
if (!container) {
return;
}
const info = magnitudeSettingInfo(id, keySuffix);
input.id = info.key;
input.name = input.id;
input.value = value;
setOriginalsFromValues([input]);
if (!info) {
return;
}
const label = template.querySelector("label");
label.textContent = info.name;
label.htmlFor = input.id;
container?.parentElement?.classList?.remove("maybe-hidden");
const container = document.getElementById(containerId);
container.parentElement.classList.remove("maybe-hidden");
mergeTemplate(container, template);
}
function initMagnitudesRatio(id, value) {
initMagnitudeTextSetting("emon-ratios", id, "Ratio", value);
const template = new NumberInput();
mergeTemplate(container, template.with(
(label, input) => {
label.textContent = info.name;
label.htmlFor = input.id;
input.id = info.key;
input.name = input.id;
input.value = value.toString();
setOriginalsFromValues([input]);
}));
}
/**
* @param {number} id
*/
function initMagnitudesExpected(id) {
// TODO: also display currently read value?
const template = loadTemplate("emon-expected");
const [expected, result] = template.querySelectorAll("input");
const container = document.getElementById("emon-expected");
if (!container) {
return;
}
const info = emonRatioInfo(id);
if (!info) {
return;
}
expected.name += `${info.key}`;
// TODO: also display currently read value?
const template = loadTemplate("emon-expected");
const [expected, result] = template.querySelectorAll("input");
expected.name += info.key;
expected.id = expected.name;
expected.dataset["id"] = info.id;
expected.dataset["id"] = info.id.toString();
result.name += `${info.key}`;
result.name += info.key;
result.id = result.name;
const label = template.querySelector("label");
const [label] = template.querySelectorAll("label");
label.textContent = info.name;
label.htmlFor = expected.id;
@@ -215,141 +349,214 @@ function initMagnitudesExpected(id) {
styleVisible(`.emon-expected-${info.prefix}`, true)
]);
mergeTemplate(document.getElementById("emon-expected"), template);
mergeTemplate(container, template);
}
function emonCalculateRatios() {
const inputs = document.getElementById("emon-expected")
.querySelectorAll(".emon-expected-input");
const expected = document.getElementById("emon-expected")
?.querySelectorAll("input.emon-expected-input");
if (!expected) {
return;
}
inputs.forEach((input) => {
if (input.value.length) {
sendAction("emon-expected", {
id: parseInt(input.dataset["id"], 10),
expected: parseFloat(input.value) });
/** @type {NodeListOf<HTMLInputElement>} */
(expected).forEach((input) => {
if (!input.value || !input.dataset["id"]) {
return;
}
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");
?.querySelectorAll("input.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 = "";
/** @type {NodeListOf<HTMLInputElement>} */
(results).forEach((result) => {
if (!result.value) {
return;
}
let next = result.name
.replace("result:", "");
const ratio = document.getElementById(next);
if (!(ratio instanceof HTMLInputElement)) {
return;
}
ratio.value = result.value;
setChangedElement(ratio);
result.value = "";
next = result.name
.replace("result:", "expected:");
const expected = document.getElementById(next);
if (!(expected instanceof HTMLInputElement)) {
return;
}
expected.value = "";
});
showPanelByName("sns");
}
function initMagnitudesCorrection(id, value) {
initMagnitudeTextSetting("magnitude-corrections", id, "Correction", value);
}
/**
* @param {any[]} values
* @param {string[]} schema
*/
function initMagnitudesSettings(values, schema) {
values.forEach((value, id) => {
const settings = fromSchema(value, schema);
function initMagnitudesSettings(data) {
data.values.forEach((cfg, id) => {
const settings = fromSchema(cfg, data.schema);
if (settings.Ratio !== null) {
initMagnitudesRatio(id, settings.Ratio);
if (typeof settings.Ratio === "number") {
initMagnitudeNumberSetting(
"emon-ratios", id,
"Ratio", settings.Ratio);
initMagnitudesExpected(id);
}
if (settings.Correction !== null) {
initMagnitudesCorrection(id, settings.Correction);
if (typeof settings.Correction === "number") {
initMagnitudeNumberSetting(
"magnitude-corrections", id,
"Correction", settings.Correction);
}
let threshold = settings.ZeroThreshold;
if (threshold === null) {
threshold = NaN;
const threshold =
(typeof settings.ZeroThreshold === "number")
? settings.ZeroThreshold :
(typeof settings.ZeroThreshold === "string")
? NaN : null;
if (typeof threshold === "number") {
initMagnitudeNumberSetting(
"magnitude-zero-thresholds", id,
"ZeroThreshold", threshold);
}
initMagnitudeTextSetting(
"magnitude-zero-thresholds", id,
"ZeroThreshold", threshold);
if (typeof settings.MinDelta === "number") {
initMagnitudeNumberSetting(
"magnitude-min-deltas", id,
"MinDelta", settings.MinDelta);
}
initMagnitudeTextSetting(
"magnitude-min-deltas", id,
"MinDelta", settings.MinDelta);
initMagnitudeTextSetting(
"magnitude-max-deltas", id,
"MaxDelta", settings.MaxDelta);
if (typeof settings.MaxDelta === "number") {
initMagnitudeNumberSetting(
"magnitude-max-deltas", id,
"MaxDelta", settings.MaxDelta);
}
});
}
/**
* @param {number} id
* @returns {HTMLInputElement | null}
*/
function magnitudeValueContainer(id) {
return document.querySelector(`input[name='magnitude'][data-id='${id}']`);
}
function updateMagnitudes(data) {
data.values.forEach((cfg, id) => {
if (!Magnitudes.properties[id]) {
/**
* @param {any[]} values
* @param {string[]} schema
*/
function updateMagnitudes(values, schema) {
values.forEach((value, id) => {
const props = Magnitudes.properties.get(id);
if (!props) {
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]) {
if (!input) {
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;
const magnitude = fromSchema(value, schema);
if (typeof magnitude.units === "number") {
props.units = magnitude.units;
}
if (typeof magnitude.error === "number" && 0 !== magnitude.error) {
input.value =
Magnitudes.errors.get(magnitude.error) ?? "Unknown error";
} else if (typeof magnitude.value === "number") {
const units =
Magnitudes.units.names.get(props.units) ?? "";
input.value = `${magnitude.value}${units}`;
} else {
input.value = "?";
}
});
}
/**
* @param {any[]} values
* @param {string[]} schema
*/
function updateEnergy(values, schema) {
values.forEach((value) => {
const energy = fromSchema(value, schema);
if (typeof energy.id !== "number") {
return;
}
const input = magnitudeValueContainer(energy.id);
if (!input) {
return;
}
const props = Magnitudes.properties.get(energy.id);
if (!props) {
return;
}
if (typeof energy.saved !== "string" || !energy.saved) {
return;
}
const info = input?.parentElement?.parentElement
?.querySelector(".magnitude-info");
if (!(info instanceof HTMLElement)) {
return;
}
info.style.display = "inherit";
info.textContent = energy.saved;
});
}
/**
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"magnitudes-init": (_, value) => {
initMagnitudes(value);
initMagnitudes(
value.types, value.errors, value.units);
},
"magnitudes-module": (_, value) => {
initModuleMagnitudes(value);
initModuleMagnitudes(
value.prefix, value.values, value.schema);
},
"magnitudes-list": (_, value) => {
initMagnitudesList(value, [
initMagnitudesList(value.values, value.schema, [
createMagnitudeUnitSelector, createMagnitudeInfo]);
},
"magnitudes-settings": (_, value) => {
initMagnitudesSettings(value);
initMagnitudesSettings(value.values, value.schema);
},
"magnitudes": (_, value) => {
updateMagnitudes(value);
updateMagnitudes(value.values, value.schema);
},
"energy": (_, value) => {
updateEnergy(value);
updateEnergy(value.values, value.schema);
},
};
}
@@ -358,9 +565,9 @@ export function init() {
variableListeners(listeners());
document.querySelector(".button-emon-expected")
.addEventListener("click", onPanelTargetClick);
?.addEventListener("click", onPanelTargetClick);
document.querySelector(".button-emon-expected-calculate")
.addEventListener("click", emonCalculateRatios);
?.addEventListener("click", emonCalculateRatios);
document.querySelector(".button-emon-expected-apply")
.addEventListener("click", emonApplyRatios);
?.addEventListener("click", emonApplyRatios);
}

View File

@@ -31,28 +31,6 @@ function resetGroupPending(elem) {
elem.dataset["settingsGroupPending"] = "";
}
// 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?
/**
* @param {string[]} source
* @param {string[]} schema
* @returns {{[k: string]: any}}
*/
export function fromSchema(source, schema) {
if (schema.length !== source.length) {
throw `Schema mismatch! Expected length ${schema.length} vs. ${source.length}`;
}
/** @type {{[k: string]: string}} */
let target = {};
schema.forEach((key, index) => {
target[key] = source[index];
});
return target;
}
// 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.
@@ -270,7 +248,7 @@ function groupSettingsCheckMax(event) {
const max = target.dataset["settingsMax"];
const val = 1 + target.children.length;
if ((max !== undefined) && (parseInt(max, 10) < val)) {
if ((max !== undefined) && (max !== "0") && (parseInt(max, 10) < val)) {
alert(`Max number of ${target.id} has been reached (${val} out of ${max})`);
return false;
}
@@ -279,21 +257,31 @@ function groupSettingsCheckMax(event) {
}
/**
* @param {string} elementId
* @param {HTMLElement} elem
* @param {EventListener} listener
*/
export function groupSettingsOnAdd(elementId, listener) {
document.getElementById(elementId)
?.addEventListener("settings-group-add",
(event) => {
event.stopPropagation();
if (!groupSettingsCheckMax(event)) {
return;
}
export function groupSettingsOnAddElem(elem, listener) {
elem.addEventListener("settings-group-add",
(event) => {
event.stopPropagation();
if (!groupSettingsCheckMax(event)) {
return;
}
listener(event);
onGroupSettingsEventAdd(event);
});
listener(event);
onGroupSettingsEventAdd(event);
});
}
/**
* @param {string} id
* @param {EventListener} listener
*/
export function groupSettingsOnAdd(id, listener) {
const elem = document.getElementById(id);
if (elem) {
groupSettingsOnAddElem(elem, listener);
}
}
/**
@@ -1225,12 +1213,7 @@ export function pendingChanges() {
return Settings.counters.changed > 0;
}
/**
* TODO https://github.com/microsoft/TypeScript/issues/58969 ? at-import becomes 'unused' for some reason
* @typedef {import("./question.mjs").QuestionWrapper} QuestionWrapper
*/
/** @type {QuestionWrapper} */
/** @type {import("./question.mjs").QuestionWrapper} */
export function askSaveSettings(ask) {
if (pendingChanges()) {
return ask("There are pending changes to the settings, continue the operation without saving?");

View File

@@ -1,12 +1,12 @@
<template id="template-curtain-control">
<div class="curtain-roller" id="curtainGetPicture"></div>
<div class="curtain-roller" id="curtain-picture"></div>
<div class="pure-button-group">
<button type="button" class="pure-button pure-input-1-3 curtain-button button-curtain-close">CLOSE</button>
<button type="button" class="pure-button pure-input-1-3 curtain-button button-curtain-pause">PAUSE</button>
<button type="button" class="pure-button pure-input-1-3 curtain-button button-curtain-open">OPEN</button>
</div>
<div class="pure-control-group">
<input type="range" list="tickmarks" min="0" max="100" class="slider pure-input-1 reverse-range" id="curtainSet">
<input type="range" list="tickmarks" min="0" max="100" class="slider pure-input-1 reverse-range" id="curtain-set">
<datalist id="tickmarks">
<option value="0"></option>
<option value="50"></option>

View File

@@ -69,22 +69,21 @@ export function loadConfigTemplate(name) {
return template;
}
/**
* @import { DisplayValue } from './settings.mjs'
* @typedef {{[k: string]: DisplayValue}} TemplateConfig
*/
/** @typedef {InputOrSelect | HTMLSpanElement} TemplateLineElement */
/**
* @import { DisplayValue } from './settings.mjs'
* @param {DocumentFragment} fragment
* @param {number} id
* @param {{[k: string]: DisplayValue}} cfg
* @param {TemplateConfig} cfg
*/
export function fillTemplateFromCfg(fragment, id, cfg) {
let local = {"template-id": id};
if (cfg === undefined) {
cfg = {};
}
Object.assign(local, cfg);
cfg = local;
export function fillTemplateFromCfg(fragment, id, cfg = {}) {
const local = {"template-id": id};
cfg = Object.assign({}, local, cfg);
for (let elem of /** @type {NodeListOf<TemplateLineElement>} */(fragment.querySelectorAll("input,select,span"))) {
const key =
@@ -130,10 +129,100 @@ export function mergeTemplate(target, template) {
/**
* @param {HTMLElement} container
* @param {string} name
* @param {any} cfg
* @param {TemplateConfig} cfg
*/
export function addFromTemplate(container, name, cfg) {
const fragment = loadConfigTemplate(name);
fillTemplateFromCfg(fragment, container.childElementCount, cfg);
mergeTemplate(container, fragment);
}
// 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?
/**
* @param {DisplayValue[]} values
* @param {string[]} schema
* @returns {TemplateConfig}
*/
export function fromSchema(values, schema) {
if (schema.length !== values.length) {
throw `Schema mismatch! Expected length ${schema.length} vs. ${values.length}`;
}
/** @type {{[k: string]: any}} */
const out = {};
schema.forEach((key, index) => {
out[key] = values[index];
});
return out;
}
/**
* @param {DisplayValue[][]} source
* @param {string[]} schema
* @returns {TemplateConfig[]}
*/
export function prepareFromSchema(source, schema) {
return source.map((values) => fromSchema(values, schema));
}
/**
* @param {HTMLElement} container
* @param {string} name
* @param {DisplayValue[][]} entries
* @param {string[]} schema
* @param {number} max
*/
export function addFromTemplateWithSchema(container, name, entries, schema, max = 0) {
const prepared = prepareFromSchema(entries, schema);
if (!prepared) {
return;
}
if (max > 0) {
container.dataset["settingsMax"] = max.toString();
}
prepared.forEach((cfg) => {
addFromTemplate(container, name, cfg);
});
}
export class BaseInput {
/** @param {string} name */
constructor(name) {
this.fragment = loadConfigTemplate(name);
}
/**
* @param {function(HTMLLabelElement, HTMLInputElement): void} callback
* @returns {DocumentFragment}
*/
with(callback) {
const out = document.createDocumentFragment();
out.appendChild(this.fragment.cloneNode(true));
const root = /** @type {!HTMLDivElement} */
(out.children[0]);
callback(
/** @type {!HTMLLabelElement} */(root.children[0]),
/** @type {!HTMLInputElement} */(root.children[1]));
return out;
}
}
export class TextInput extends BaseInput {
constructor() {
super("text-input");
}
}
export class NumberInput extends BaseInput {
constructor() {
super("number-input");
}
}

View File

@@ -2,27 +2,41 @@ import { askAndCall } from './question.mjs';
import { askSaveSettings } from './settings.mjs';
import { sendAction } from './connection.mjs';
function checkTempRange(event) {
const min = document.getElementById("tempRangeMinInput");
const max = document.getElementById("tempRangeMaxInput");
if (event.target.id === max.id) {
/**
* @param {HTMLInputElement} min
* @param {HTMLInputElement} max
*/
function checkTempMax(min, max) {
return function() {
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;
min.value = maxValue.toString();
}
}
}
/**
* @param {HTMLInputElement} min
* @param {HTMLInputElement} max
*/
function checkTempMin(min, max) {
return function() {
const minValue = parseInt(min.value, 10) + 1;
if (parseInt(max.value, 10) < minValue) {
max.value = minValue.toString();
}
}
}
/** @type {import("./question.mjs").QuestionWrapper} */
function askResetCounters(ask) {
return ask("Are you sure you want to reset burning counters?");
}
function onResetCounters() {
const questions = [
askSaveSettings,
(ask) => ask("Are you sure you want to reset burning counters?")
askResetCounters,
];
askAndCall(questions, () => {
@@ -32,9 +46,16 @@ function onResetCounters() {
export function init() {
document.querySelector(".button-thermostat-reset-counters")
.addEventListener("click", onResetCounters);
document.getElementById("tempRangeMaxInput")
.addEventListener("change", checkTempRange);
document.getElementById("tempRangeMinInput")
.addEventListener("change", checkTempRange);
?.addEventListener("click", onResetCounters);
const [min, max] =
["Min", "Max"]
.map((x) => `tempRange${x}Input`)
.map((x) => document.getElementById(x))
.filter((x) => x instanceof HTMLInputElement);
if (min && max) {
min.addEventListener("change", checkTempMin(min, max));
max.addEventListener("change", checkTempMax(min, max));
}
}

View File

@@ -1,6 +1,7 @@
import { createNodeList } from './relay.mjs';
import { variableListeners } from './settings.mjs';
/** @returns {import('./settings.mjs').KeyValueListeners} */
function listeners() {
return {
"tspkRelays": (_, value) => {

View File

@@ -1,5 +1,9 @@
import { isChangedElement } from './settings.mjs';
/**
* @param {string} password
* @returns {boolean}
*/
export function validatePassword(password) {
// http://www.the-art-of-web.com/javascript/validate-password/
// at least one lowercase and one uppercase letter or number
@@ -17,24 +21,34 @@ export function validatePassword(password) {
&& 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`)
export 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;
/**
* 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`)
* @param {HTMLFormElement[]} forms
* @param {{required?: boolean, strict?: boolean}} options
* @returns {boolean}
*/
export function validateFormsPasswords(forms, {required = true, strict = true} = {}) {
const [first, second] = Array.from(forms)
.flatMap((x) => {
return [
x.elements.namedItem("adminPass0"),
x.elements.namedItem("adminPass1"),
];
})
.filter((x) => x instanceof HTMLInputElement);
if (first && second) {
if (!required && !first.value.length && !second.value.length) {
return true;
}
let firstValid = first.checkValidity() && validatePassword(first.value);
let secondValid = second.checkValidity() && validatePassword(second.value);
const firstValid = first.checkValidity()
&& (!strict || validatePassword(first.value));
const secondValid = second.checkValidity()
&& (!strict || validatePassword(second.value));
if (firstValid && secondValid) {
if (first.value === second.value) {
if (first.value !== second.value) {
return true;
}
@@ -49,8 +63,11 @@ export function validateFormsPasswords(forms, required) {
}
// Same as above, but only applies to the general settings page.
// Find the first available form that contains 'hostname' input
/**
* Same as above, but only applies to the general settings page.
* Find the first available form that contains 'hostname' input
* @param {HTMLFormElement[]} forms
*/
export function validateFormsHostname(forms) {
// per. [RFC1035](https://datatracker.ietf.org/doc/html/rfc1035)
// Hostname may contain:
@@ -58,7 +75,9 @@ export function validateFormsHostname(forms) {
// - 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);
const [hostname] = Array.from(forms)
.flatMap(form => form.elements.namedItem("hostname"))
.filter((x) => x instanceof HTMLInputElement);
if (!hostname) {
return true;
}
@@ -66,17 +85,20 @@ export function validateFormsHostname(forms) {
// 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
const result = (hostname.value.length > 0)
&& (!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.");
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;
}
/**
* @param {HTMLFormElement[]} forms
*/
export function validateForms(forms) {
return validateFormsPasswords(forms) && validateFormsHostname(forms);
return validateFormsPasswords(forms)
&& validateFormsHostname(forms);
}

View File

@@ -1,37 +1,51 @@
import {
fromSchema,
groupSettingsOnAdd,
groupSettingsOnAddElem,
variableListeners,
} from './settings.mjs';
import { addFromTemplate } from './template.mjs';
import { addFromTemplate, addFromTemplateWithSchema } from './template.mjs';
import { moreElem } from './core.mjs';
import { sendAction } from './connection.mjs';
/**
* @param {boolean?} showMore
* @param {any?} cfg
*/
function addNode(showMore = false, cfg = undefined) {
const container = document.getElementById("networks");
if (!container) {
return;
}
/** @param {function(HTMLElement): void} callback */
function withNetworks(callback) {
callback(/** @type {!HTMLElement} */
(document.getElementById("networks")));
}
addFromTemplate(container, "network-config", cfg);
if (showMore && container.lastChild instanceof HTMLElement) {
moreElem(container.lastChild)
/**
* @param {HTMLElement} elem
*/
function lastMoreElem(elem) {
if (elem.lastChild instanceof HTMLElement) {
moreElem(elem.lastChild)
}
}
/**
* @param {any} value
*/
function onConfig(value) {
withNetworks((elem) => {
addFromTemplateWithSchema(
elem, "network-config",
value.networks, value.schema,
value.max ?? 0);
lastMoreElem(elem);
});
}
/**
* @param {HTMLElement} elem
*/
function networkAdd(elem) {
addFromTemplate(elem, "network-config", {});
}
/** @param {function(HTMLTableElement): void} callback */
function withTable(callback) {
const table = document.getElementById("scanResult");
if (!(table instanceof HTMLTableElement)) {
return;
}
callback(table);
callback(/** @type {!HTMLTableElement} */
(document.getElementById("scanResult")));
}
/** @param {function(HTMLInputElement): void} callback */
@@ -50,10 +64,9 @@ function buttonDisabled(value) {
/** @param {boolean} value */
function loadingDisplay(value) {
const loading = document.querySelector("div.scan.loading");
if (loading instanceof HTMLElement) {
loading.style.display = value ? "table" : "none";
}
const loading = (/** @type {!HTMLDivElement} */
(document.querySelector("div.scan.loading")));
loading.style.display = value ? "table" : "none";
}
/** @param {string[]} values */
@@ -90,32 +103,12 @@ function scanStart(event) {
}
/**
* @typedef {import('./settings.mjs').KeyValueListeners } KeyValueListeners
*/
/**
* @returns {KeyValueListeners}
* @returns {import('./settings.mjs').KeyValueListeners}
*/
function listeners() {
return {
"wifiConfig": (_, value) => {
const container = document.getElementById("networks");
if (!(container instanceof HTMLElement)) {
return;
}
if (value.max !== undefined) {
container.dataset["settingsMax"] = value.max;
}
const networks = value.networks;
if (!Array.isArray(networks)) {
return;
}
networks.forEach((entries) => {
addNode(false, fromSchema(entries, value.schema));
});
onConfig(value);
},
"scanResult": (_, value) => {
scanResult(value);
@@ -124,13 +117,14 @@ function listeners() {
}
export function init() {
variableListeners(listeners());
groupSettingsOnAdd("networks", () => {
addNode();
});
withButton((button) => {
button.addEventListener("click", scanStart);
withNetworks((elem) => {
variableListeners(listeners());
// TODO: as event arg?
groupSettingsOnAddElem(elem, () => {
networkAdd(elem);
});
withButton((button) => {
button.addEventListener("click", scanStart);
});
});
}