added fault keys

This commit is contained in:
Kris MacAir 2026-10-23 11:40:12 +01:00
parent 7b330cccec
commit d2846a3b54
9 changed files with 372 additions and 31 deletions

View File

@ -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

View File

@ -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 `
<section class="group">
<h3>${escapeHtml(group.title)}</h3>
@ -409,7 +412,7 @@ function renderFields(device) {
<th>key</th>
<th>value</th>
<th>On</th>
<th>new value</th>
${showNewValue ? "<th>new value</th>" : ""}
</tr>
</thead>
<tbody>
@ -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 = `<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>`;
if (field.type === "enum") {
valueCell = `<td class="field-value">${enumSelectHtml(field, value, "data-enum-key", { raw: true })}</td>`;
editor = "";
} else if (field.type === "boolean") {
const on = value === 1 || value === true || value === "1";
editor = `
<select
@ -523,19 +536,155 @@ function fieldRowHtml(field, value, enabled, share = null) {
}
const shareClass = share ? ` share-group share-${share.edge}` : "";
const editorCell = !share || share.editor
? `<td class="new-value"${share ? ` rowspan="${share.span}"` : ""}>${editor}</td>`
: "";
const editorCell = field.group === "faults"
? ""
: field.type === "enum"
? `<td class="new-value"></td>`
: !share || share.editor
? `<td class="new-value"${share ? ` rowspan="${share.span}"` : ""}>${editor}</td>`
: "";
return `
<tr class="field-row ${inactive}${shareClass}">
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
${valueCell}
<td class="on-cell">${onToggle}</td>
${editorCell}
</tr>
`;
}
const ENUM_PREFIXES = [
"AcBuckFaultNoBitShift",
"SensorFaultNoBitShift",
"SystemFaultNoBitShift",
"enumAcBuckState",
];
const ENUM_WORDS = {
Pwm: "PWM",
Adc: "ADC",
Zcd: "ZCD",
Ctrl: "control",
};
function enumLabel(name) {
let rest = String(name);
for (const prefix of ENUM_PREFIXES) {
if (rest.startsWith(prefix)) {
rest = rest.slice(prefix.length);
break;
}
}
const words = rest
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.trim()
.split(/\s+/)
.filter(Boolean);
return words
.map((word, index) => {
if (ENUM_WORDS[word]) {
const mapped = ENUM_WORDS[word];
return index === 0 ? mapped.charAt(0).toUpperCase() + mapped.slice(1) : mapped;
}
if (/[0-9]/.test(word)) return word;
const lower = word.toLowerCase();
return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower;
})
.join(" ");
}
function enumSelectHtml(field, value, attr, { raw = false } = {}) {
const options = (field.options || [])
.map((option) => {
const label = raw ? `${option.name} (${option.value})` : `${enumLabel(option.name)} (${option.value})`;
return `<option value="${option.value}"${Number(value) === option.value ? " selected" : ""}>${escapeHtml(label)}</option>`;
})
.join("");
return `<select ${attr}="${escapeHtml(field.key)}" aria-label="${escapeHtml(field.key)}">${options}</select>`;
}
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) => `
<div class="fault-row">
<span class="fault-key">${escapeHtml(field.key)}</span>
${enumSelectHtml(field, null, "data-fault-key")}
<button type="button" data-fault-publish="${escapeHtml(field.key)}">Publish</button>
<input class="fault-log" data-fault-log="${escapeHtml(field.key)}" readonly />
</div>
`,
)
.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("&", "&amp;")
@ -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();

View File

@ -291,6 +291,14 @@
<button type="button" data-plant-update="battery-actual">Update</button>
</div>
</section>
<section class="plant-card" id="fault-card">
<div class="fault-head">
<h4>Faults</h4>
<button type="button" id="fault-publish-all">Publish all</button>
<input id="fault-log-all" class="fault-log" data-fault-log="all" readonly />
</div>
<div id="fault-rows"></div>
</section>
</section>
<section class="group publish-panel">

View File

@ -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;

View File

@ -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,

View File

@ -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,
};

View File

@ -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) });
}));

View File

@ -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") {

View File

@ -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));