Modified fault key input format

This commit is contained in:
Monisha P H 2026-10-26 16:00:14 +11:00
parent d2846a3b54
commit 2a43c5211a
7 changed files with 185 additions and 60 deletions

View File

@ -450,6 +450,12 @@ function syncFieldValues(device) {
select.value = String(values[key] ?? ""); 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]")) { for (const toggle of els.groups.querySelectorAll("[data-publish-key]")) {
const key = toggle.dataset.publishKey; const key = toggle.dataset.publishKey;
const on = enabled.has(key); const on = enabled.has(key);
@ -507,6 +513,9 @@ function fieldRowHtml(field, value, enabled, share = null) {
if (field.type === "enum") { if (field.type === "enum") {
valueCell = `<td class="field-value">${enumSelectHtml(field, value, "data-enum-key", { raw: true })}</td>`; valueCell = `<td class="field-value">${enumSelectHtml(field, value, "data-enum-key", { raw: true })}</td>`;
editor = ""; editor = "";
} else if (field.group === "faults") {
valueCell = `<td class="field-value">${faultIntegerHtml(field, value, "data-int-key")}</td>`;
editor = "";
} else if (field.type === "boolean") { } else if (field.type === "boolean") {
const on = value === 1 || value === true || value === "1"; const on = value === 1 || value === true || value === "1";
editor = ` editor = `
@ -604,6 +613,16 @@ function enumSelectHtml(field, value, attr, { raw = false } = {}) {
return `<select ${attr}="${escapeHtml(field.key)}" aria-label="${escapeHtml(field.key)}">${options}</select>`; return `<select ${attr}="${escapeHtml(field.key)}" aria-label="${escapeHtml(field.key)}">${options}</select>`;
} }
function faultIntegerHtml(field, value, attr) {
const shown = value == null || value === "" ? "" : String(value);
return `<input type="number" step="1" ${attr}="${escapeHtml(field.key)}" value="${escapeHtml(shown)}" aria-label="${escapeHtml(field.key)}" />`;
}
function faultControlHtml(field, value) {
if (field.type === "enum") return enumSelectHtml(field, value, "data-fault-key");
return faultIntegerHtml(field, value, "data-fault-key");
}
function faultFields() { function faultFields() {
return state.schema.fields.filter((field) => field.group === "faults"); return state.schema.fields.filter((field) => field.group === "faults");
} }
@ -616,7 +635,7 @@ function buildFaultRows() {
(field) => ` (field) => `
<div class="fault-row"> <div class="fault-row">
<span class="fault-key">${escapeHtml(field.key)}</span> <span class="fault-key">${escapeHtml(field.key)}</span>
${enumSelectHtml(field, null, "data-fault-key")} ${faultControlHtml(field, null)}
<button type="button" data-fault-publish="${escapeHtml(field.key)}">Publish</button> <button type="button" data-fault-publish="${escapeHtml(field.key)}">Publish</button>
<input class="fault-log" data-fault-log="${escapeHtml(field.key)}" readonly /> <input class="fault-log" data-fault-log="${escapeHtml(field.key)}" readonly />
</div> </div>
@ -627,10 +646,10 @@ function buildFaultRows() {
function renderFaultSelects(device) { function renderFaultSelects(device) {
const values = device.telemetry || {}; const values = device.telemetry || {};
for (const select of document.querySelectorAll("[data-fault-key]")) { for (const control of document.querySelectorAll("[data-fault-key]")) {
if (document.activeElement === select) continue; if (document.activeElement === control) continue;
const key = select.dataset.faultKey; const key = control.dataset.faultKey;
if (values[key] != null) select.value = String(values[key]); if (values[key] != null) control.value = String(values[key]);
} }
const logs = state.faultLogs[device.id] || {}; const logs = state.faultLogs[device.id] || {};
for (const input of document.querySelectorAll("[data-fault-log]")) { for (const input of document.querySelectorAll("[data-fault-log]")) {
@ -666,8 +685,8 @@ async function publishFaultKeys(keys) {
if (!device) return; if (!device) return;
const body = {}; const body = {};
for (const key of keys) { for (const key of keys) {
const select = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`); const control = document.querySelector(`[data-fault-key="${CSS.escape(key)}"]`);
body[key] = Number(select?.value); body[key] = Number(control?.value);
} }
const data = await api(`/api/devices/${device.id}/faults/publish`, { const data = await api(`/api/devices/${device.id}/faults/publish`, {
method: "POST", method: "POST",
@ -1154,9 +1173,11 @@ document.getElementById("aux-boost").addEventListener("click", async () => {
}); });
document.getElementById("fault-card").addEventListener("change", async (event) => { document.getElementById("fault-card").addEventListener("change", async (event) => {
const select = event.target.closest("[data-fault-key]"); const control = event.target.closest("[data-fault-key]");
if (!select) return; if (!control) return;
await saveFaultValue(select.dataset.faultKey, Number(select.value)); 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) => { 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)); await saveFaultValue(enumSelect.dataset.enumKey, Number(enumSelect.value));
return; 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]"); const publishToggle = event.target.closest("[data-publish-key]");
if (!publishToggle) return; if (!publishToggle) return;
const device = selectedDevice(); const device = selectedDevice();

View File

@ -297,6 +297,7 @@
<button type="button" id="fault-publish-all">Publish all</button> <button type="button" id="fault-publish-all">Publish all</button>
<input id="fault-log-all" class="fault-log" data-fault-log="all" readonly /> <input id="fault-log-all" class="fault-log" data-fault-log="all" readonly />
</div> </div>
<p class="muted">Publish writes each key as a client attribute and as telemetry.</p>
<div id="fault-rows"></div> <div id="fault-rows"></div>
</section> </section>
</section> </section>

View File

@ -610,7 +610,8 @@ select {
font-size: 12px; font-size: 12px;
} }
.fault-row select { .fault-row select,
.fault-row input[type="number"] {
width: 280px; width: 280px;
min-width: 280px; min-width: 280px;
max-width: 280px; max-width: 280px;
@ -621,7 +622,8 @@ select {
min-width: 160px; min-width: 160px;
} }
.telemetry-table .field-value select { .telemetry-table .field-value select,
.telemetry-table .field-value input[type="number"] {
width: 100%; width: 100%;
min-width: 560px; min-width: 560px;
padding: 7px 10px; padding: 7px 10px;

View File

@ -1,7 +1,7 @@
const db = require("./db"); const db = require("./db");
const thingsboard = require("./thingsboard"); const thingsboard = require("./thingsboard");
const { VirtualDevice } = require("./virtualDevice"); 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 { defaultPlant } = require("./plant");
const config = require("./config"); const config = require("./config");
@ -46,6 +46,12 @@ function bindDevice(device) {
device.lastError = error.message; 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); devices.set(device.id, device);
} }
@ -221,7 +227,7 @@ function updateTelemetry(id, partial, { publish = true } = {}) {
return device.status(); return device.status();
} }
function publishFaults(id, partial) { async function publishFaults(id, partial) {
const device = getOrThrow(id); const device = getOrThrow(id);
const clean = {}; const clean = {};
for (const [key, raw] of Object.entries(partial || {})) { for (const [key, raw] of Object.entries(partial || {})) {
@ -230,6 +236,11 @@ function publishFaults(id, partial) {
} }
device.setTelemetry(clean); device.setTelemetry(clean);
persistTelemetry(device); persistTelemetry(device);
try {
await thingsboard.ensureXStateClientAttributes(device.id, X_STATE_KEYS);
} catch (error) {
device.lastError = error.message;
}
const sent = device.publishObject(clean); const sent = device.publishObject(clean);
return { device: device.status(), sent }; return { device: device.status(), sent };
} }

View File

@ -2,31 +2,6 @@ function enumIndex(names) {
return names.map((name, value) => ({ name, value })); 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([ const AC_BUCK_STATES = enumIndex([
"enumAcBuckStateFault", "enumAcBuckStateFault",
"enumAcBuckStateStandby", "enumAcBuckStateStandby",
@ -37,18 +12,6 @@ const AC_BUCK_STATES = enumIndex([
"enumAcBuckStateRampDownToStandby", "enumAcBuckStateRampDownToStandby",
]); ]);
const SYS_FAULTS = enumIndex([
"SystemFaultNoBitShiftGridVoltage",
"SystemFaultNoBitShiftGridFrequency",
"SystemFaultNoBitShiftTemperature",
"SystemFaultNoBitShiftPowerSupply",
"SystemFaultNoBitShiftHotWaterCurrentHighIn",
"SystemFaultNoBitShiftHotWaterCurrentHighOut",
"SystemFaultNoBitShiftSensorFault",
"SystemFaultNoBitShiftRS485Fault",
"SystemFaultNoBitShiftReboot",
]);
const TELEMETRY_FIELDS = [ const TELEMETRY_FIELDS = [
{ key: "solarPower_s", label: "Solar generation", unit: "W", group: "power", type: "number", default: 3200 }, { 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 }, { 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: "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: "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: "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: "xSensorFault", label: "Sensor fault", unit: "", group: "faults", type: "integer", default: null },
{ key: "xSysFault", label: "System fault", unit: "", group: "faults", type: "enum", default: null, options: SYS_FAULTS }, { key: "xSysFault", label: "System fault", unit: "", group: "faults", type: "integer", default: null },
]; ];
const GROUPS = [ const GROUPS = [
{ id: "status", title: "Status", subtitle: "Tank temp, battery SoC, aux / control-load flags" }, { 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: "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: "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: "energy", title: "Energy today", subtitle: "Watt-hours for daily totals" },
{ id: "validity", title: "Validity", subtitle: "vPwrReadings_s bitmask for live power keys" }, { 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() { function telemetryKeys() {
return TELEMETRY_FIELDS.map((field) => field.key); 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() { function defaultTelemetry() {
const values = {}; const values = {};
for (const field of TELEMETRY_FIELDS) { for (const field of TELEMETRY_FIELDS) {
@ -184,7 +183,11 @@ function telemetrySchema() {
module.exports = { module.exports = {
TELEMETRY_FIELDS, TELEMETRY_FIELDS,
GROUPS, GROUPS,
FAULT_KEYS,
X_STATE_KEYS,
telemetryKeys, telemetryKeys,
pickFaultTelemetry,
withXStateDefaults,
defaultTelemetry, defaultTelemetry,
defaultPublishedKeys, defaultPublishedKeys,
normalizeTelemetry, normalizeTelemetry,

View File

@ -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 = { module.exports = {
createDevice, createDevice,
deleteDevice, deleteDevice,
@ -321,5 +376,8 @@ module.exports = {
getDeviceCredentials, getDeviceCredentials,
adoptDeviceByName, adoptDeviceByName,
writeSharedAttributes, writeSharedAttributes,
writeClientAttributes,
ensureXStateClientAttributes,
writeTimeseries,
ping, ping,
}; };

View File

@ -1,6 +1,6 @@
const mqtt = require("mqtt"); const mqtt = require("mqtt");
const config = require("./config"); const config = require("./config");
const { normalizeTelemetry, normalizePublishedKeys } = require("./telemetry"); const { normalizeTelemetry, normalizePublishedKeys, pickFaultTelemetry, withXStateDefaults } = require("./telemetry");
const { const {
SHARED_KEYS, SHARED_KEYS,
normalizePlant, normalizePlant,
@ -43,6 +43,7 @@ class VirtualDevice {
this.onTelemetryChange = null; this.onTelemetryChange = null;
this.onPlantChange = null; this.onPlantChange = null;
this.onSharedWrite = null; this.onSharedWrite = null;
this.onFaultPersist = null;
this.startHeatLoop(); this.startHeatLoop();
} }
@ -80,8 +81,20 @@ class VirtualDevice {
this.client.subscribe("v1/devices/me/attributes/response/+"); this.client.subscribe("v1/devices/me/attributes/response/+");
this.requestSharedAttributes(); this.requestSharedAttributes();
this.startAttrPoll(); this.startAttrPoll();
const startPublishing = () => {
if (this.telemetryEnabled) this.startTelemetry(); if (this.telemetryEnabled) this.startTelemetry();
this.publishTelemetry(); this.publishTelemetry();
};
if (this.ensureXStates) {
this.ensureXStates()
.then(startPublishing)
.catch((error) => {
this.lastError = error.message;
startPublishing();
});
} else {
startPublishing();
}
}); });
this.client.on("reconnect", () => { this.client.on("reconnect", () => {
@ -405,10 +418,11 @@ class VirtualDevice {
publishTelemetry() { publishTelemetry() {
if (!this.client || !this.connected) return; if (!this.client || !this.connected) return;
const payload = this.telemetryPayload(); const payload = withXStateDefaults(this.telemetryPayload(), this.telemetry);
if (!Object.keys(payload).length) return; if (!Object.keys(payload).length) return;
this.client.publish("v1/devices/me/telemetry", JSON.stringify(payload), { qos: 1 }); this.client.publish("v1/devices/me/telemetry", JSON.stringify(payload), { qos: 1 });
this.lastPublishedAt = Date.now(); this.lastPublishedAt = Date.now();
this.persistFaultChannels(payload);
} }
publishKeys(keys) { publishKeys(keys) {
@ -425,11 +439,19 @@ class VirtualDevice {
body[key] = value; body[key] = value;
} }
if (!Object.keys(body).length) return false; 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.lastPublishedAt = Date.now();
this.persistFaultChannels(outbound);
return true; return true;
} }
persistFaultChannels(payload) {
const faults = pickFaultTelemetry(payload);
if (!Object.keys(faults).length || !this.onFaultPersist) return;
this.onFaultPersist(faults);
}
publishAttributes(attrs) { publishAttributes(attrs) {
if (!this.client || !this.connected) return; if (!this.client || !this.connected) return;
this.client.publish("v1/devices/me/attributes", JSON.stringify(attrs)); this.client.publish("v1/devices/me/attributes", JSON.stringify(attrs));