From d2846a3b54b73e67bcd6d4a7bfba4c0fd2444684 Mon Sep 17 00:00:00 2001 From: Kris MacAir Date: Fri, 23 Oct 2026 11:40:12 +0100 Subject: [PATCH] added fault keys --- deploy/ship.sh | 1 + public/app.js | 184 +++++++++++++++++++++++++++++++++++++++++-- public/index.html | 8 ++ public/styles.css | 45 +++++++++++ src/deviceManager.js | 18 ++++- src/plant.js | 7 +- src/routes.js | 8 +- src/telemetry.js | 67 ++++++++++++++++ src/virtualDevice.js | 65 ++++++++++----- 9 files changed, 372 insertions(+), 31 deletions(-) diff --git a/deploy/ship.sh b/deploy/ship.sh index 01c112e..978b32c 100755 --- a/deploy/ship.sh +++ b/deploy/ship.sh @@ -54,6 +54,7 @@ fi # Attestation indexes from Docker Desktop make `docker save` fail, so both images are single-platform. echo "Building $IMAGE for $PLATFORM" docker build --platform "$PLATFORM" --provenance=false --sbom=false -t "$IMAGE" . +echo "Node.js in $IMAGE: $(docker run --rm --platform "$PLATFORM" --entrypoint node "$IMAGE" -v)" echo "Preparing caddy:2 for $PLATFORM" docker build --platform "$PLATFORM" --provenance=false --sbom=false -t caddy:2 - <<'EOF' FROM caddy:2 diff --git a/public/app.js b/public/app.js index 254006b..f0bf5f3 100644 --- a/public/app.js +++ b/public/app.js @@ -9,6 +9,7 @@ const state = { health: null, pendingTelemetry: {}, applyingRemote: false, + faultLogs: {}, }; const els = { @@ -288,6 +289,7 @@ function renderPlant(device) { els.tankTemp.textContent = tank == null ? "—" : Number(tank).toFixed(1); if (tankInput && document.activeElement !== tankInput && tank != null) tankInput.value = Number(tank); renderPowerSim(device); + renderFaultSelects(device); setPlantStatus("hotWater", view.hwOn ? "Boost On" : ""); setPlantStatus("aux", view.auxOn ? "Boost On" : ""); renderScheduleBoard("hw", view.hwSchedules, view.hwScheduleMode); @@ -398,6 +400,7 @@ function renderFields(device) { els.groups.innerHTML = state.schema.groups .map((group) => { const fields = state.schema.fields.filter((field) => field.group === group.id); + const showNewValue = group.id !== "faults"; return `

${escapeHtml(group.title)}

@@ -409,7 +412,7 @@ function renderFields(device) { key value On - new value + ${showNewValue ? "new value" : ""} @@ -441,6 +444,12 @@ function syncFieldValues(device) { cell.textContent = displayValue(values[key]); } + for (const select of els.groups.querySelectorAll("[data-enum-key]")) { + const key = select.dataset.enumKey; + if (!key || document.activeElement === select || Object.prototype.hasOwnProperty.call(pending, key)) continue; + select.value = String(values[key] ?? ""); + } + for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) { const key = toggle.dataset.publishKey; const on = enabled.has(key); @@ -494,7 +503,11 @@ function fieldRowHtml(field, value, enabled, share = null) { const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label); let editor; - if (field.type === "boolean") { + let valueCell = `${escapeHtml(displayValue(value))}`; + if (field.type === "enum") { + valueCell = `${enumSelectHtml(field, value, "data-enum-key", { raw: true })}`; + editor = ""; + } else if (field.type === "boolean") { const on = value === 1 || value === true || value === "1"; editor = ` ${options}`; +} + +function faultFields() { + return state.schema.fields.filter((field) => field.group === "faults"); +} + +function buildFaultRows() { + const root = document.getElementById("fault-rows"); + if (!root) return; + root.innerHTML = faultFields() + .map( + (field) => ` +
+ ${escapeHtml(field.key)} + ${enumSelectHtml(field, null, "data-fault-key")} + + +
+ `, + ) + .join(""); +} + +function renderFaultSelects(device) { + const values = device.telemetry || {}; + for (const select of document.querySelectorAll("[data-fault-key]")) { + if (document.activeElement === select) continue; + const key = select.dataset.faultKey; + if (values[key] != null) select.value = String(values[key]); + } + const logs = state.faultLogs[device.id] || {}; + for (const input of document.querySelectorAll("[data-fault-log]")) { + input.value = logs[input.dataset.faultLog] || ""; + } +} + +function faultOptionName(key, value) { + const field = state.schema.fields.find((item) => item.key === key); + const option = (field?.options || []).find((item) => item.value === Number(value)); + return option ? `${enumLabel(option.name)} (${option.value})` : String(value); +} + +function rememberFaultLog(deviceId, slot, text) { + if (!state.faultLogs[deviceId]) state.faultLogs[deviceId] = {}; + state.faultLogs[deviceId][slot] = text; + const input = document.querySelector(`[data-fault-log="${CSS.escape(slot)}"]`); + if (input) input.value = text; +} + +async function saveFaultValue(key, value) { + const device = selectedDevice(); + if (!device) return; + const data = await api(`/api/devices/${device.id}/telemetry?publish=0`, { + method: "PATCH", + body: JSON.stringify({ [key]: value }), + }); + upsertDevice(data.device); +} + +async function publishFaultKeys(keys) { + const device = selectedDevice(); + if (!device) return; + const body = {}; + for (const key of keys) { + const select = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`); + body[key] = Number(select?.value); + } + const data = await api(`/api/devices/${device.id}/faults/publish`, { + method: "POST", + body: JSON.stringify(body), + }); + upsertDevice(data.device); + const time = new Date().toLocaleTimeString(); + const sent = data.sent ? time : `${time} not connected`; + const detail = keys.map((key) => `${key} ${faultOptionName(key, body[key])}`).join(" · "); + const line = data.sent ? `${time} ${detail}` : `${sent} — ${detail}`; + if (keys.length === 1) rememberFaultLog(device.id, keys[0], line); + else { + rememberFaultLog(device.id, "all", line); + for (const key of keys) rememberFaultLog(device.id, key, `${data.sent ? time : sent} ${faultOptionName(key, body[key])}`); + } +} + function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") @@ -598,6 +747,7 @@ async function loadStatic() { ) .join(""); if (preferredId) els.addProfile.value = preferredId; + buildFaultRows(); } els.loginForm.addEventListener("submit", async (event) => { @@ -1003,6 +1153,23 @@ document.getElementById("aux-boost").addEventListener("click", async () => { upsertDevice(data.device); }); +document.getElementById("fault-card").addEventListener("change", async (event) => { + const select = event.target.closest("[data-fault-key]"); + if (!select) return; + await saveFaultValue(select.dataset.faultKey, Number(select.value)); +}); + +document.getElementById("fault-card").addEventListener("click", async (event) => { + const button = event.target.closest("[data-fault-publish]"); + if (button) { + await publishFaultKeys([button.dataset.faultPublish]); + return; + } + if (event.target.id === "fault-publish-all") { + await publishFaultKeys(faultFields().map((field) => field.key)); + } +}); + document.getElementById("hw-boost").addEventListener("click", async () => { const device = selectedDevice(); if (!device) return; @@ -1073,6 +1240,11 @@ els.publishDeselectAll.addEventListener("click", async () => { }); els.groups.addEventListener("change", async (event) => { + const enumSelect = event.target.closest("[data-enum-key]"); + if (enumSelect) { + await saveFaultValue(enumSelect.dataset.enumKey, Number(enumSelect.value)); + return; + } const publishToggle = event.target.closest("[data-publish-key]"); if (!publishToggle) return; const device = selectedDevice(); diff --git a/public/index.html b/public/index.html index c396ac6..63f7132 100644 --- a/public/index.html +++ b/public/index.html @@ -291,6 +291,14 @@
+
+
+

Faults

+ + +
+
+
diff --git a/public/styles.css b/public/styles.css index f0418d5..795152b 100644 --- a/public/styles.css +++ b/public/styles.css @@ -582,6 +582,51 @@ select { to { background: var(--bg-elev-2); } } +.fault-head, +.fault-row { + display: flex; + align-items: center; + gap: 8px; +} + +.fault-head { + margin-bottom: 12px; +} + +.fault-head h4 { + margin: 0; + min-width: 130px; +} + +.fault-row { + margin-top: 8px; +} + +.fault-key { + width: 130px; + flex-shrink: 0; + color: var(--info); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.fault-row select { + width: 280px; + min-width: 280px; + max-width: 280px; +} + +.fault-log { + flex: 1; + min-width: 160px; +} + +.telemetry-table .field-value select { + width: 100%; + min-width: 560px; + padding: 7px 10px; +} + .plant-boost { display: block; width: 250px; diff --git a/src/deviceManager.js b/src/deviceManager.js index 3c0a7b0..04eb083 100644 --- a/src/deviceManager.js +++ b/src/deviceManager.js @@ -213,14 +213,27 @@ function setPublishedKeys(id, keys) { return device.status(); } -function updateTelemetry(id, partial) { +function updateTelemetry(id, partial, { publish = true } = {}) { const device = getOrThrow(id); device.setTelemetry(partial); persistTelemetry(device); - if (device.connected) device.publishTelemetry(); + if (publish && device.connected) device.publishTelemetry(); return device.status(); } +function publishFaults(id, partial) { + const device = getOrThrow(id); + const clean = {}; + for (const [key, raw] of Object.entries(partial || {})) { + const value = Math.trunc(Number(raw)); + if (Number.isFinite(value)) clean[key] = value; + } + device.setTelemetry(clean); + persistTelemetry(device); + const sent = device.publishObject(clean); + return { device: device.status(), sent }; +} + function startTelemetry(id) { const device = getOrThrow(id); device.startTelemetry(); @@ -317,6 +330,7 @@ module.exports = { remove, removeMany, updateTelemetry, + publishFaults, setPublishedKeys, startTelemetry, stopTelemetry, diff --git a/src/plant.js b/src/plant.js index 77336f1..051eed6 100644 --- a/src/plant.js +++ b/src/plant.js @@ -106,10 +106,15 @@ function applyOneShot(plant, untilKey, lastKey, manualKey, minutes, command = fa // Attribute polls repeat the same value. A pushed update is an app command, // including Stop while a local emulator button still holds the relay on. if (!command && plant[lastKey] === mins) return plant; + // Local Stop holds the relay off. A repeat of the minutes just cancelled + // (poll or ThingsBoard echoing the old shared attribute) must not start it again. + if (plant[manualKey] === false && mins > 0 && plant[lastKey] === mins) return plant; + if (!command && plant[manualKey] === false && mins > 0) return plant; const initialSync = !command && plant[lastKey] == null && mins === 0; + const keepManualOff = mins === 0 && plant[manualKey] === false; return { ...plant, - [manualKey]: initialSync ? plant[manualKey] : null, + [manualKey]: initialSync || keepManualOff ? plant[manualKey] : null, [lastKey]: mins, [untilKey]: mins > 0 ? Date.now() + mins * 60_000 : null, }; diff --git a/src/routes.js b/src/routes.js index 03f3493..7cd56ef 100644 --- a/src/routes.js +++ b/src/routes.js @@ -101,10 +101,16 @@ router.patch("/devices/:id/published-keys", asyncHandler(async (req, res) => { })); router.patch("/devices/:id/telemetry", asyncHandler(async (req, res) => { - const device = deviceManager.updateTelemetry(req.params.id, req.body || {}); + const publish = req.query.publish !== "0"; + const device = deviceManager.updateTelemetry(req.params.id, req.body || {}, { publish }); res.json({ device }); })); +router.post("/devices/:id/faults/publish", asyncHandler(async (req, res) => { + const result = deviceManager.publishFaults(req.params.id, req.body || {}); + res.json(result); +})); + router.post("/devices/:id/telemetry/start", asyncHandler(async (req, res) => { res.json({ device: deviceManager.startTelemetry(req.params.id) }); })); diff --git a/src/telemetry.js b/src/telemetry.js index 2f6664d..6c88704 100644 --- a/src/telemetry.js +++ b/src/telemetry.js @@ -1,3 +1,54 @@ +function enumIndex(names) { + return names.map((name, value) => ({ name, value })); +} + +const SENSOR_FAULTS = enumIndex([ + "SensorFaultNoBitShiftVoltageGrid", + "SensorFaultNoBitShiftVoltageCtrl", + "SensorFaultNoBitShiftVoltageHotWater", + "SensorFaultNoBitShiftCurrentGrid", + "SensorFaultNoBitShiftCurrentSolar", + "SensorFaultNoBitShiftCurrentLoad", + "SensorFaultNoBitShiftCurrentHotWaterIn", + "SensorFaultNoBitShiftCurrentHotWaterOut", +]); + +const AC_BUCK_FAULTS = enumIndex([ + "AcBuckFaultNoBitShiftSystemFault", + "AcBuckFaultNoBitShiftPwmBreakInput", + "AcBuckFaultNoBitShiftSettingsUpdated", + "AcBuckFaultNoBitShiftOutputVoltageWhenNotRequested", + "AcBuckFaultNoBitShiftOutputCurrentWhenNotRequested", + "AcBuckFaultNoBitShiftVoltageGridHigh", + "AcBuckFaultNoBitShiftCurrentHotWaterInHigh", + "AcBuckFaultNoBitShiftCurrentHotWaterOutHigh", + "AcBuckFaultNoBitShiftAdcCycleReadCountTooHigh", + "AcBuckFaultNoBitShiftZcdFault", + "AcBuckFaultNoBitShiftPowerSupplyLow", +]); + +const AC_BUCK_STATES = enumIndex([ + "enumAcBuckStateFault", + "enumAcBuckStateStandby", + "enumAcBuckStateManualOverrides", + "enumAcBuckStateBoostOverrides", + "enumAcBuckStateThermostatOpen", + "enumAcBuckStateExportPowerMode", + "enumAcBuckStateRampDownToStandby", +]); + +const SYS_FAULTS = enumIndex([ + "SystemFaultNoBitShiftGridVoltage", + "SystemFaultNoBitShiftGridFrequency", + "SystemFaultNoBitShiftTemperature", + "SystemFaultNoBitShiftPowerSupply", + "SystemFaultNoBitShiftHotWaterCurrentHighIn", + "SystemFaultNoBitShiftHotWaterCurrentHighOut", + "SystemFaultNoBitShiftSensorFault", + "SystemFaultNoBitShiftRS485Fault", + "SystemFaultNoBitShiftReboot", +]); + const TELEMETRY_FIELDS = [ { key: "solarPower_s", label: "Solar generation", unit: "W", group: "power", type: "number", default: 3200 }, { key: "loadPower_s", label: "Home load", unit: "W", group: "power", type: "number", default: 1500 }, @@ -47,10 +98,16 @@ const TELEMETRY_FIELDS = [ { key: "auxRelay_s", label: "Aux relay", unit: "", group: "status", type: "boolean", default: 0 }, { key: "rlyInSel_s", label: "Control load heating", unit: "", group: "status", type: "boolean", default: 0 }, { key: "xCntlLoadState", label: "Control load state", unit: "", group: "status", type: "integer", default: 0 }, + + { key: "xAcBuckFault", label: "AC buck fault", unit: "", group: "faults", type: "enum", default: null, options: AC_BUCK_FAULTS }, + { key: "xAcBuckState", label: "AC buck state", unit: "", group: "faults", type: "enum", default: null, options: AC_BUCK_STATES }, + { key: "xSensorFault", label: "Sensor fault", unit: "", group: "faults", type: "enum", default: null, options: SENSOR_FAULTS }, + { key: "xSysFault", label: "System fault", unit: "", group: "faults", type: "enum", default: null, options: SYS_FAULTS }, ]; const GROUPS = [ { id: "status", title: "Status", subtitle: "Tank temp, battery SoC, aux / control-load flags" }, + { id: "faults", title: "Faults", subtitle: "Same enum as the Faults card. Publish sends the enum index." }, { id: "power", title: "Power", subtitle: "Instantaneous watts published as *_s" }, { id: "flows", title: "Energy flows", subtitle: "Node-to-node power used by the live flow UI" }, { id: "energy", title: "Energy today", subtitle: "Watt-hours for daily totals" }, @@ -64,6 +121,7 @@ function telemetryKeys() { function defaultTelemetry() { const values = {}; for (const field of TELEMETRY_FIELDS) { + if (field.default == null) continue; values[field.key] = field.default; } return values; @@ -99,6 +157,15 @@ function normalizeTelemetry(input) { next[field.key] = raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "on" ? 1 : 0; continue; } + if (field.type === "enum") { + if (raw == null || raw === "") continue; + const numeric = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(numeric)) continue; + const value = Math.trunc(numeric); + const known = (field.options || []).some((option) => option.value === value); + if (known) next[field.key] = value; + continue; + } const numeric = typeof raw === "number" ? raw : Number(raw); if (!Number.isFinite(numeric)) continue; if (field.type === "integer") { diff --git a/src/virtualDevice.js b/src/virtualDevice.js index 983922d..22a69d0 100644 --- a/src/virtualDevice.js +++ b/src/virtualDevice.js @@ -172,14 +172,22 @@ class VirtualDevice { } setTelemetry(partial) { - this.telemetry = normalizeTelemetry({ ...this.telemetry, ...partial }); + const clean = {}; + for (const [key, value] of Object.entries(partial || {})) { + if (value != null) clean[key] = value; + } + this.telemetry = normalizeTelemetry({ ...this.telemetry, ...clean }); if (partial && Object.prototype.hasOwnProperty.call(partial, "heatsink_s")) { this.heatTankC = Number(this.telemetry.heatsink_s) || 0; } } setPlant(partial) { + const stoppingHw = partial?.hwManual === false; + const stoppingAux = partial?.auxManual === false; this.plant = normalizePlant({ ...this.plant, ...partial }); + if (stoppingHw) this.expireOneShot("hw"); + if (stoppingAux) this.expireOneShot("aux"); this.tickPlant(); if (this.onPlantChange) this.onPlantChange(this); } @@ -280,22 +288,17 @@ class VirtualDevice { } expireOneShot(channel) { - if (channel === "hw") { - if (!this.plant.hwBoostUntil && this.plant.lastBoostNowHw === 0) return; - this.plant = { - ...this.plant, - hwBoostUntil: null, - lastBoostNowHw: 0, - }; - if (this.onSharedWrite) this.onSharedWrite({ boostNowHw: 0 }); - } else { - if (!this.plant.auxBoostUntil && this.plant.lastBoostNowAux === 0) return; - this.plant = { - ...this.plant, - auxBoostUntil: null, - lastBoostNowAux: 0, - }; - if (this.onSharedWrite) this.onSharedWrite({ boostNowAux: 0 }); + const hw = channel === "hw"; + const untilKey = hw ? "hwBoostUntil" : "auxBoostUntil"; + const attrKey = hw ? "boostNowHw" : "boostNowAux"; + const sharedMinutes = Number(this.sharedAttributes[attrKey]); + const boostStillSet = Boolean(this.plant[untilKey]) || sharedMinutes > 0; + if (!boostStillSet) return; + // Keep lastBoostNow* at the cancelled minutes so a repeat of that value does not restart the relay. + this.plant = { ...this.plant, [untilKey]: null }; + if (sharedMinutes > 0) { + this.sharedAttributes = { ...this.sharedAttributes, [attrKey]: 0 }; + if (this.onSharedWrite) this.onSharedWrite({ [attrKey]: 0 }); } if (this.onPlantChange) this.onPlantChange(this); } @@ -388,14 +391,15 @@ class VirtualDevice { telemetryPayload() { const payload = {}; for (const key of this.publishedKeys) { + const value = this.telemetry[key]; + if (value != null) payload[key] = value; + } + // Plant state. Always send these, even if an older saved key list omitted them. + for (const key of ["auxPower_s", "rlyInSel_s", "auxOn_s", "auxRelay_s", "heatsink_s", "hotWaterInPower_s"]) { if (Object.prototype.hasOwnProperty.call(this.telemetry, key)) { payload[key] = this.telemetry[key]; } } - // Aux watts are plant state. Always send them, even if an older saved key list omitted auxPower_s. - if (Object.prototype.hasOwnProperty.call(this.telemetry, "auxPower_s")) { - payload.auxPower_s = this.telemetry.auxPower_s; - } return payload; } @@ -407,6 +411,25 @@ class VirtualDevice { this.lastPublishedAt = Date.now(); } + publishKeys(keys) { + const payload = {}; + for (const key of keys) payload[key] = this.telemetry[key]; + return this.publishObject(payload); + } + + publishObject(payload) { + if (!this.client || !this.connected) return false; + const body = {}; + for (const [key, value] of Object.entries(payload || {})) { + if (value == null || (typeof value === "number" && Number.isNaN(value))) continue; + body[key] = value; + } + if (!Object.keys(body).length) return false; + this.client.publish("v1/devices/me/telemetry", JSON.stringify(body), { qos: 1 }); + this.lastPublishedAt = Date.now(); + return true; + } + publishAttributes(attrs) { if (!this.client || !this.connected) return; this.client.publish("v1/devices/me/attributes", JSON.stringify(attrs));