diff --git a/public/app.js b/public/app.js index f0bf5f3..d0452ba 100644 --- a/public/app.js +++ b/public/app.js @@ -450,6 +450,12 @@ function syncFieldValues(device) { select.value = String(values[key] ?? ""); } + for (const input of els.groups.querySelectorAll("[data-int-key]")) { + const key = input.dataset.intKey; + if (!key || document.activeElement === input || Object.prototype.hasOwnProperty.call(pending, key)) continue; + input.value = values[key] != null ? String(values[key]) : ""; + } + for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) { const key = toggle.dataset.publishKey; const on = enabled.has(key); @@ -507,6 +513,9 @@ function fieldRowHtml(field, value, enabled, share = null) { if (field.type === "enum") { valueCell = `${enumSelectHtml(field, value, "data-enum-key", { raw: true })}`; editor = ""; + } else if (field.group === "faults") { + valueCell = `${faultIntegerHtml(field, value, "data-int-key")}`; + editor = ""; } else if (field.type === "boolean") { const on = value === 1 || value === true || value === "1"; editor = ` @@ -604,6 +613,16 @@ function enumSelectHtml(field, value, attr, { raw = false } = {}) { return ``; } +function faultIntegerHtml(field, value, attr) { + const shown = value == null || value === "" ? "" : String(value); + return ``; +} + +function faultControlHtml(field, value) { + if (field.type === "enum") return enumSelectHtml(field, value, "data-fault-key"); + return faultIntegerHtml(field, value, "data-fault-key"); +} + function faultFields() { return state.schema.fields.filter((field) => field.group === "faults"); } @@ -616,7 +635,7 @@ function buildFaultRows() { (field) => `
${escapeHtml(field.key)} - ${enumSelectHtml(field, null, "data-fault-key")} + ${faultControlHtml(field, null)}
@@ -627,10 +646,10 @@ function buildFaultRows() { 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]); + for (const control of document.querySelectorAll("[data-fault-key]")) { + if (document.activeElement === control) continue; + const key = control.dataset.faultKey; + if (values[key] != null) control.value = String(values[key]); } const logs = state.faultLogs[device.id] || {}; for (const input of document.querySelectorAll("[data-fault-log]")) { @@ -666,8 +685,8 @@ async function publishFaultKeys(keys) { 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 control = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`); + body[key] = Number(control?.value); } const data = await api(`/api/devices/${device.id}/faults/publish`, { method: "POST", @@ -1154,9 +1173,11 @@ document.getElementById("aux-boost").addEventListener("click", async () => { }); 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)); + const control = event.target.closest("[data-fault-key]"); + if (!control) return; + const numeric = Number(control.value); + if (!Number.isFinite(numeric)) return; + await saveFaultValue(control.dataset.faultKey, Math.trunc(numeric)); }); document.getElementById("fault-card").addEventListener("click", async (event) => { @@ -1245,6 +1266,13 @@ els.groups.addEventListener("change", async (event) => { await saveFaultValue(enumSelect.dataset.enumKey, Number(enumSelect.value)); return; } + const intInput = event.target.closest("[data-int-key]"); + if (intInput) { + const numeric = Number(intInput.value); + if (!Number.isFinite(numeric)) return; + await saveFaultValue(intInput.dataset.intKey, Math.trunc(numeric)); + 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 63f7132..281a4ef 100644 --- a/public/index.html +++ b/public/index.html @@ -297,6 +297,7 @@ +

Publish writes each key as a client attribute and as telemetry.

diff --git a/public/styles.css b/public/styles.css index 795152b..1caa45c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -610,7 +610,8 @@ select { font-size: 12px; } -.fault-row select { +.fault-row select, +.fault-row input[type="number"] { width: 280px; min-width: 280px; max-width: 280px; @@ -621,7 +622,8 @@ select { min-width: 160px; } -.telemetry-table .field-value select { +.telemetry-table .field-value select, +.telemetry-table .field-value input[type="number"] { width: 100%; min-width: 560px; padding: 7px 10px; diff --git a/src/deviceManager.js b/src/deviceManager.js index 04eb083..405b516 100644 --- a/src/deviceManager.js +++ b/src/deviceManager.js @@ -1,7 +1,7 @@ const db = require("./db"); const thingsboard = require("./thingsboard"); const { VirtualDevice } = require("./virtualDevice"); -const { defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, telemetrySchema } = require("./telemetry"); +const { defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, telemetrySchema, X_STATE_KEYS } = require("./telemetry"); const { defaultPlant } = require("./plant"); const config = require("./config"); @@ -46,6 +46,12 @@ function bindDevice(device) { device.lastError = error.message; }); }; + device.onFaultPersist = (faults) => { + thingsboard.writeTimeseries(device.id, faults).catch((error) => { + device.lastError = error.message; + }); + }; + device.ensureXStates = () => thingsboard.ensureXStateClientAttributes(device.id, X_STATE_KEYS); devices.set(device.id, device); } @@ -221,7 +227,7 @@ function updateTelemetry(id, partial, { publish = true } = {}) { return device.status(); } -function publishFaults(id, partial) { +async function publishFaults(id, partial) { const device = getOrThrow(id); const clean = {}; for (const [key, raw] of Object.entries(partial || {})) { @@ -230,6 +236,11 @@ function publishFaults(id, partial) { } device.setTelemetry(clean); persistTelemetry(device); + try { + await thingsboard.ensureXStateClientAttributes(device.id, X_STATE_KEYS); + } catch (error) { + device.lastError = error.message; + } const sent = device.publishObject(clean); return { device: device.status(), sent }; } diff --git a/src/telemetry.js b/src/telemetry.js index 6c88704..2c2a4fb 100644 --- a/src/telemetry.js +++ b/src/telemetry.js @@ -2,31 +2,6 @@ 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", @@ -37,18 +12,6 @@ const AC_BUCK_STATES = enumIndex([ "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 }, @@ -99,25 +62,61 @@ const TELEMETRY_FIELDS = [ { 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: "xAcBuckFault", label: "AC buck fault", unit: "", group: "faults", type: "integer", default: null }, { 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 }, + { key: "xSensorFault", label: "Sensor fault", unit: "", group: "faults", type: "integer", default: null }, + { key: "xSysFault", label: "System fault", unit: "", group: "faults", type: "integer", default: null }, ]; 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: "faults", title: "Faults", subtitle: "xAcBuckState is an enum. The other three keys take an integer. Publish sends the value as a client attribute and as telemetry." }, { 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" }, { id: "validity", title: "Validity", subtitle: "vPwrReadings_s bitmask for live power keys" }, ]; +const FAULT_KEYS = TELEMETRY_FIELDS.filter((field) => field.group === "faults").map((field) => field.key); + +const X_STATE_KEYS = [ + "xSysFault", + "xSensorFault", + "xAcBuckFault", + "xAuxRelayStatus", + "xHoursLastHot", + "xThermoState", + "xHotWaterStatus", + "xCntlLoadState", + "xAcBuckState", +]; + function telemetryKeys() { return TELEMETRY_FIELDS.map((field) => field.key); } +function pickFaultTelemetry(payload) { + const faults = {}; + for (const key of FAULT_KEYS) { + const value = payload?.[key]; + if (value == null || (typeof value === "number" && Number.isNaN(value))) continue; + faults[key] = value; + } + return faults; +} + +function withXStateDefaults(payload, telemetry = {}) { + const body = { ...(payload || {}) }; + for (const key of X_STATE_KEYS) { + const current = body[key]; + if (current != null && !(typeof current === "number" && Number.isNaN(current))) continue; + const existing = telemetry[key]; + const numeric = existing == null || existing === "" ? 0 : Number(existing); + body[key] = Number.isFinite(numeric) ? Math.trunc(numeric) : 0; + } + return body; +} + function defaultTelemetry() { const values = {}; for (const field of TELEMETRY_FIELDS) { @@ -184,7 +183,11 @@ function telemetrySchema() { module.exports = { TELEMETRY_FIELDS, GROUPS, + FAULT_KEYS, + X_STATE_KEYS, telemetryKeys, + pickFaultTelemetry, + withXStateDefaults, defaultTelemetry, defaultPublishedKeys, normalizeTelemetry, diff --git a/src/thingsboard.js b/src/thingsboard.js index 71ab671..f5993e6 100644 --- a/src/thingsboard.js +++ b/src/thingsboard.js @@ -312,6 +312,61 @@ async function writeSharedAttributes(deviceId, attrs) { } } +async function getClientAttributes(deviceId, keys) { + try { + return await withAuth(async (token) => { + const { data } = await http.get(`/api/plugins/telemetry/DEVICE/${deviceId}/values/attributes/CLIENT_SCOPE`, { + headers: authHeaders(token), + params: { keys: keys.join(",") }, + }); + const values = {}; + for (const item of data || []) { + if (item?.key != null && item.value != null && item.value !== "") { + values[item.key] = item.value; + } + } + return values; + }); + } catch (error) { + throw tbError(error, "Failed to read ThingsBoard client attributes"); + } +} + +async function writeClientAttributes(deviceId, attrs) { + try { + await withAuth(async (token) => { + await http.post(`/api/plugins/telemetry/${deviceId}/CLIENT_SCOPE`, attrs, { + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + }); + }); + } catch (error) { + throw tbError(error, "Failed to write ThingsBoard client attributes"); + } +} + +async function ensureXStateClientAttributes(deviceId, keys) { + const existing = await getClientAttributes(deviceId, keys); + const missing = {}; + for (const key of keys) { + if (existing[key] == null) missing[key] = 0; + } + if (!Object.keys(missing).length) return missing; + await writeClientAttributes(deviceId, missing); + return missing; +} + +async function writeTimeseries(deviceId, values) { + try { + await withAuth(async (token) => { + await http.post(`/api/plugins/telemetry/DEVICE/${deviceId}/timeseries/ANY`, values, { + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + }); + }); + } catch (error) { + throw tbError(error, "Failed to write ThingsBoard timeseries"); + } +} + module.exports = { createDevice, deleteDevice, @@ -321,5 +376,8 @@ module.exports = { getDeviceCredentials, adoptDeviceByName, writeSharedAttributes, + writeClientAttributes, + ensureXStateClientAttributes, + writeTimeseries, ping, }; diff --git a/src/virtualDevice.js b/src/virtualDevice.js index 22a69d0..561133c 100644 --- a/src/virtualDevice.js +++ b/src/virtualDevice.js @@ -1,6 +1,6 @@ const mqtt = require("mqtt"); const config = require("./config"); -const { normalizeTelemetry, normalizePublishedKeys } = require("./telemetry"); +const { normalizeTelemetry, normalizePublishedKeys, pickFaultTelemetry, withXStateDefaults } = require("./telemetry"); const { SHARED_KEYS, normalizePlant, @@ -43,6 +43,7 @@ class VirtualDevice { this.onTelemetryChange = null; this.onPlantChange = null; this.onSharedWrite = null; + this.onFaultPersist = null; this.startHeatLoop(); } @@ -80,8 +81,20 @@ class VirtualDevice { this.client.subscribe("v1/devices/me/attributes/response/+"); this.requestSharedAttributes(); this.startAttrPoll(); - if (this.telemetryEnabled) this.startTelemetry(); - this.publishTelemetry(); + const startPublishing = () => { + if (this.telemetryEnabled) this.startTelemetry(); + this.publishTelemetry(); + }; + if (this.ensureXStates) { + this.ensureXStates() + .then(startPublishing) + .catch((error) => { + this.lastError = error.message; + startPublishing(); + }); + } else { + startPublishing(); + } }); this.client.on("reconnect", () => { @@ -405,10 +418,11 @@ class VirtualDevice { publishTelemetry() { if (!this.client || !this.connected) return; - const payload = this.telemetryPayload(); + const payload = withXStateDefaults(this.telemetryPayload(), this.telemetry); if (!Object.keys(payload).length) return; this.client.publish("v1/devices/me/telemetry", JSON.stringify(payload), { qos: 1 }); this.lastPublishedAt = Date.now(); + this.persistFaultChannels(payload); } publishKeys(keys) { @@ -425,11 +439,19 @@ class VirtualDevice { body[key] = value; } if (!Object.keys(body).length) return false; - this.client.publish("v1/devices/me/telemetry", JSON.stringify(body), { qos: 1 }); + const outbound = withXStateDefaults(body, this.telemetry); + this.client.publish("v1/devices/me/telemetry", JSON.stringify(outbound), { qos: 1 }); this.lastPublishedAt = Date.now(); + this.persistFaultChannels(outbound); return true; } + persistFaultChannels(payload) { + const faults = pickFaultTelemetry(payload); + if (!Object.keys(faults).length || !this.onFaultPersist) return; + this.onFaultPersist(faults); + } + publishAttributes(attrs) { if (!this.client || !this.connected) return; this.client.publish("v1/devices/me/attributes", JSON.stringify(attrs));