added fault keys
This commit is contained in:
parent
7b330cccec
commit
d2846a3b54
@ -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
|
||||
|
||||
184
public/app.js
184
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 `
|
||||
<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("&", "&")
|
||||
@ -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();
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) });
|
||||
}));
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user