Modified fault key input format
This commit is contained in:
parent
d2846a3b54
commit
2a43c5211a
@ -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 = `<td class="field-value">${enumSelectHtml(field, value, "data-enum-key", { raw: true })}</td>`;
|
||||
editor = "";
|
||||
} else if (field.group === "faults") {
|
||||
valueCell = `<td class="field-value">${faultIntegerHtml(field, value, "data-int-key")}</td>`;
|
||||
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 `<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() {
|
||||
return state.schema.fields.filter((field) => field.group === "faults");
|
||||
}
|
||||
@ -616,7 +635,7 @@ function buildFaultRows() {
|
||||
(field) => `
|
||||
<div class="fault-row">
|
||||
<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>
|
||||
<input class="fault-log" data-fault-log="${escapeHtml(field.key)}" readonly />
|
||||
</div>
|
||||
@ -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();
|
||||
|
||||
@ -297,6 +297,7 @@
|
||||
<button type="button" id="fault-publish-all">Publish all</button>
|
||||
<input id="fault-log-all" class="fault-log" data-fault-log="all" readonly />
|
||||
</div>
|
||||
<p class="muted">Publish writes each key as a client attribute and as telemetry.</p>
|
||||
<div id="fault-rows"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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();
|
||||
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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user