diff --git a/code/espurna/sensor.cpp b/code/espurna/sensor.cpp index 804f2ba7..146ab944 100644 --- a/code/espurna/sensor.cpp +++ b/code/espurna/sensor.cpp @@ -3315,33 +3315,37 @@ void errors(JsonObject& root) { } void units(JsonObject& root) { - espurna::web::ws::EnumerablePayload payload{root, STRING_VIEW("units")}; - payload(STRING_VIEW("values"), magnitude::internal::magnitudes.size(), - {{STRING_VIEW("supported"), [](JsonArray& out, size_t index) { - JsonArray& units = out.createNestedArray(); - const auto range = units::range(magnitude::get(index).type); - for (auto it = range.begin(); it != range.end(); ++it) { - JsonArray& unit = units.createNestedArray(); - unit.add(static_cast(*it)); // raw id - unit.add(units::name(*it)); // as string - } - }} - }); + JsonArray& units = root.createNestedArray(STRING_VIEW("units")); + + for (size_t index = 0; index < magnitude::internal::magnitudes.size(); ++index) { + JsonArray& supported = units.createNestedArray(); + + const auto range = units::range(magnitude::get(index).type); + for (auto it = range.begin(); it != range.end(); ++it) { + JsonArray& unit = units.createNestedArray(); + unit.add(static_cast(*it)); // raw id + unit.add(units::name(*it)); // as string + + supported.add(unit); + } + } } void initial(JsonObject& root) { - if (!magnitude::count()) { + if (!sensor::ready()) { return; } - JsonObject& container = root.createNestedObject(F("magnitudes-init")); - types(container); - errors(container); - units(container); + JsonObject& init = + root.createNestedObject(F("magnitudes-init")); + + types(init); + errors(init); + units(init); } void list(JsonObject& root) { - if (!magnitude::count()) { + if (!sensor::ready()) { return; } @@ -3363,7 +3367,7 @@ void list(JsonObject& root) { } void settings(JsonObject& root) { - if (!magnitude::count()) { + if (!sensor::ready()) { return; } @@ -3395,7 +3399,7 @@ void settings(JsonObject& root) { if (!std::isnan(threshold)) { out.add(threshold); } else { - out.add(NullSymbol); + out.add("NaN"); } }}, {settings::suffix::MinDelta, [](JsonArray& out, size_t index) { @@ -4250,10 +4254,6 @@ void configure_base() { energy::every(sensor::settings::saveEvery()); } -bool ready() { - return State::Reading == internal::state; -} - void configure() { configure_base(); configure_magnitudes(); @@ -4302,6 +4302,10 @@ void setup() { PreInit::~PreInit() = default; +bool ready() { + return State::Reading == internal::state; +} + void add_preinit(PreInitPtr ptr) { internal::pre_init.push_front(std::move(ptr)); } diff --git a/code/espurna/sensor.h b/code/espurna/sensor.h index d482bbdb..08416056 100644 --- a/code/espurna/sensor.h +++ b/code/espurna/sensor.h @@ -259,6 +259,8 @@ struct PreInit { virtual String description() const = 0; }; +bool ready(); + using PreInitPtr = std::unique_ptr; void add_preinit(PreInitPtr); diff --git a/code/html/spec/template.spec.mjs b/code/html/spec/template.spec.mjs index d58cbd08..070d59b0 100644 --- a/code/html/spec/template.spec.mjs +++ b/code/html/spec/template.spec.mjs @@ -25,6 +25,15 @@ beforeAll(async () => { `; + + document.body.innerHTML += ` + + `; }); test('basic template fragment', () => { diff --git a/code/html/src/api.mjs b/code/html/src/api.mjs index 5c41b02e..920182fe 100644 --- a/code/html/src/api.mjs +++ b/code/html/src/api.mjs @@ -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); } diff --git a/code/html/src/core.mjs b/code/html/src/core.mjs index 7a36c3f7..56fc58e0 100644 --- a/code/html/src/core.mjs +++ b/code/html/src/core.mjs @@ -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"; } diff --git a/code/html/src/curtain.mjs b/code/html/src/curtain.mjs index 7e900a9f..c87c5585 100644 --- a/code/html/src/curtain.mjs +++ b/code/html/src/curtain.mjs @@ -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} */ + (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() { diff --git a/code/html/src/debug.mjs b/code/html/src/debug.mjs index 5485cb1f..52d7acc2 100644 --- a/code/html/src/debug.mjs +++ b/code/html/src/debug.mjs @@ -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; } diff --git a/code/html/src/domoticz.mjs b/code/html/src/domoticz.mjs index db25279b..2794d886 100644 --- a/code/html/src/domoticz.mjs +++ b/code/html/src/domoticz.mjs @@ -1,6 +1,7 @@ import { createNodeList } from './relay.mjs'; import { variableListeners } from './settings.mjs'; +/** @returns {import('./settings.mjs').KeyValueListeners} */ function listeners() { return { "dczRelays": (_, value) => { diff --git a/code/html/src/errors.mjs b/code/html/src/errors.mjs index fab3019a..66ff36bc 100644 --- a/code/html/src/errors.mjs +++ b/code/html/src/errors.mjs @@ -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)); } diff --git a/code/html/src/garland.mjs b/code/html/src/garland.mjs index a2565549..75a8fd27 100644 --- a/code/html/src/garland.mjs +++ b/code/html/src/garland.mjs @@ -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", {}); }); } diff --git a/code/html/src/gpio.mjs b/code/html/src/gpio.mjs index 71f49cf1..ca6d1548 100644 --- a/code/html/src/gpio.mjs +++ b/code/html/src/gpio.mjs @@ -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"]); }, }; } diff --git a/code/html/src/ha.mjs b/code/html/src/ha.mjs index 8252fd76..e0793809 100644 --- a/code/html/src/ha.mjs +++ b/code/html/src/ha.mjs @@ -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); } diff --git a/code/html/src/index.mjs b/code/html/src/index.mjs index 49953c72..f72633c9 100644 --- a/code/html/src/index.mjs +++ b/code/html/src/index.mjs @@ -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") diff --git a/code/html/src/led.mjs b/code/html/src/led.mjs index 9989b686..756b3127 100644 --- a/code/html/src/led.mjs +++ b/code/html/src/led.mjs @@ -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); + }); }); } diff --git a/code/html/src/light.mjs b/code/html/src/light.mjs index b840ecd4..a2d01b24 100644 --- a/code/html/src/light.mjs +++ b/code/html/src/light.mjs @@ -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 = ` - - - `; + + `; - 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) => { diff --git a/code/html/src/lightfox.mjs b/code/html/src/lightfox.mjs index d177dd2f..5ef85bec 100644 --- a/code/html/src/lightfox.mjs +++ b/code/html/src/lightfox.mjs @@ -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); } diff --git a/code/html/src/ota.mjs b/code/html/src/ota.mjs index d09c7187..701cd77d 100644 --- a/code/html/src/ota.mjs +++ b/code/html/src/ota.mjs @@ -174,11 +174,7 @@ async function onFileChanged(event) { } /** - * @typedef {import('./settings.mjs').KeyValueListeners } KeyValueListeners - */ - -/** - * @returns {KeyValueListeners} + * @returns {import('./settings.mjs').KeyValueListeners} */ function listeners() { return { diff --git a/code/html/src/panel-garland.html b/code/html/src/panel-garland.html index d95ee8cc..6c2df726 100644 --- a/code/html/src/panel-garland.html +++ b/code/html/src/panel-garland.html @@ -10,17 +10,17 @@
- +
- +
- +
diff --git a/code/html/src/relay.mjs b/code/html/src/relay.mjs index c4f2811e..0bce70c2 100644 --- a/code/html/src/relay.mjs +++ b/code/html/src/relay.mjs @@ -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()); } diff --git a/code/html/src/rfbridge.mjs b/code/html/src/rfbridge.mjs index d32d694a..c9e2c249 100644 --- a/code/html/src/rfbridge.mjs +++ b/code/html/src/rfbridge.mjs @@ -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); }, }; } diff --git a/code/html/src/rfm69.mjs b/code/html/src/rfm69.mjs index 9fd68102..8d0bad05 100644 --- a/code/html/src/rfm69.mjs +++ b/code/html/src/rfm69.mjs @@ -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} 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); + }); }); } diff --git a/code/html/src/rules.mjs b/code/html/src/rules.mjs index b13ed1c0..d52cee78 100644 --- a/code/html/src/rules.mjs +++ b/code/html/src/rules.mjs @@ -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); + }); }); } diff --git a/code/html/src/schedule.mjs b/code/html/src/schedule.mjs index d415ea69..6ba2af23 100644 --- a/code/html/src/schedule.mjs +++ b/code/html/src/schedule.mjs @@ -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); + }); }); } diff --git a/code/html/src/sensor.mjs b/code/html/src/sensor.mjs index 70263bc5..00b21d84 100644 --- a/code/html/src/sensor.mjs +++ b/code/html/src/sensor.mjs @@ -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} */ + properties: new Map(), + + /** @type {Map} */ + errors: new Map(), + + /** @type {Map} */ + types: new Map(), + units: { - names: {}, - supported: {} + /** @type {Map} */ + supported: new Map(), + + /** @type {Map} */ + names: new Map(), }, - typePrefix: {}, - prefixType: {} + + /** @type {Map} */ + typePrefix: new Map(), + + /** @type {Map} */ + 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} */ + (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} */ + (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); } diff --git a/code/html/src/settings.mjs b/code/html/src/settings.mjs index 86cdeed9..b840f5b5 100644 --- a/code/html/src/settings.mjs +++ b/code/html/src/settings.mjs @@ -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?"); diff --git a/code/html/src/template-curtain.html b/code/html/src/template-curtain.html index 151935ea..10adeb2b 100644 --- a/code/html/src/template-curtain.html +++ b/code/html/src/template-curtain.html @@ -1,12 +1,12 @@