yet another commit
This commit is contained in:
parent
b138466843
commit
95998a0d75
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ data/*.db
|
|||||||
data/*.db-*
|
data/*.db-*
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
deploy/
|
||||||
10
README.md
10
README.md
@ -22,7 +22,7 @@ Python historical data generation is **not** included.
|
|||||||
|
|
||||||
On startup, devices already in SQLite are restored. They reconnect only if they were left enabled (not disabled). New devices are created disconnected, with telemetry off.
|
On startup, devices already in SQLite are restored. They reconnect only if they were left enabled (not disabled). New devices are created disconnected, with telemetry off.
|
||||||
|
|
||||||
## Virtual plant
|
## Virtual Diverter
|
||||||
|
|
||||||
While MQTT is connected, the device polls ThingsBoard shared attributes every 5 seconds (and also receives live pushes). It understands:
|
While MQTT is connected, the device polls ThingsBoard shared attributes every 5 seconds (and also receives live pushes). It understands:
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ Behaviour:
|
|||||||
- At **Thermostat max (°C)** (default 65) heating stops, `rlyInSel_s` goes to 0, and `boostNowHw` is written back to ThingsBoard as 0
|
- At **Thermostat max (°C)** (default 65) heating stops, `rlyInSel_s` goes to 0, and `boostNowHw` is written back to ThingsBoard as 0
|
||||||
- Live state is published as `heatsink_s`, `rlyInSel_s`, `auxOn_s`, `auxRelay_s`
|
- Live state is published as `heatsink_s`, `rlyInSel_s`, `auxOn_s`, `auxRelay_s`
|
||||||
- **Battery** section is display-only for now: **Battery Charging** lamp stays off, **Charge rate (% / min)** defaults to 10
|
- **Battery** section is display-only for now: **Battery Charging** lamp stays off, **Charge rate (% / min)** defaults to 10
|
||||||
- Solar, grid, home load, hot water, aux, and battery cards set the energy balance in watts. Grid is calculated. Lamps are red for draw, green for supply, and grey for zero. Hot water watts apply only while heating, and aux watts only while aux is on. Those setpoints write the live power and flow telemetry keys. `loadPower_s` is home plus aux.
|
- Solar, grid, home load, hot water, aux, and battery cards set the energy balance in watts. Grid is calculated. Lamps are red for draw, green for supply, and grey for zero. Hot water watts apply only while heating, and aux watts only while aux is on. Those setpoints write the live power and flow telemetry keys. `loadPower_s` is home plus aux. Battery power rate is W/min and is `batteryPower_s` while the battery is on (negative when charging, positive when discharging). Charging is an extra load. Discharging is a supply.
|
||||||
|
|
||||||
## Telemetry keys
|
## Telemetry keys
|
||||||
|
|
||||||
@ -50,9 +50,9 @@ Each device can publish the following live `*_s` keys on `v1/devices/me/telemetr
|
|||||||
### Status
|
### Status
|
||||||
|
|
||||||
- `heatsink_s` — hot water / tank temperature (°C)
|
- `heatsink_s` — hot water / tank temperature (°C)
|
||||||
- `batterySoc_s` — battery state of charge (%)
|
- `batterySoc_s` — battery state of charge, actual / capacity × 100
|
||||||
- `soc_s` — SoC fallback (%)
|
- `soc_s` — SoC, actual / capacity × 100
|
||||||
- `batteryPercent_s` — battery percent fallback (%)
|
- `batteryPercent_s` — battery percent, actual / capacity × 100
|
||||||
- `auxOn_s` — aux on (0/1)
|
- `auxOn_s` — aux on (0/1)
|
||||||
- `auxRelay_s` — aux relay (0/1)
|
- `auxRelay_s` — aux relay (0/1)
|
||||||
- `rlyInSel_s` — control load heating (0/1)
|
- `rlyInSel_s` — control load heating (0/1)
|
||||||
|
|||||||
211
public/app.js
211
public/app.js
@ -232,22 +232,51 @@ function renderPlant(device) {
|
|||||||
const plant = device.plant || {};
|
const plant = device.plant || {};
|
||||||
els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
|
els.hwLamp.classList.toggle("on", Boolean(view.hwOn));
|
||||||
els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
|
els.auxLamp.classList.toggle("on", Boolean(view.auxOn));
|
||||||
els.battLamp.classList.toggle("on", Boolean(view.battCharging));
|
const batteryRow = (view.power?.rows || []).find((row) => row.id === "battery");
|
||||||
els.hwLampDetail.textContent = view.hwDetail || "Off";
|
els.battLamp.classList.toggle("draw", batteryRow?.state === "draw");
|
||||||
els.auxLampDetail.textContent = view.auxDetail || "Off";
|
els.battLamp.classList.toggle("supply", batteryRow?.state === "supply");
|
||||||
els.battLampDetail.textContent = view.battDetail || "Off";
|
const hwBoost = document.getElementById("hw-boost");
|
||||||
|
hwBoost.textContent = view.hwOn ? "Hot Water Boost Stop" : "Hot Water Boost Start";
|
||||||
|
hwBoost.classList.toggle("primary", !view.hwOn);
|
||||||
|
hwBoost.classList.toggle("danger", Boolean(view.hwOn));
|
||||||
|
const auxBoost = document.getElementById("aux-boost");
|
||||||
|
auxBoost.textContent = view.auxOn ? "Aux Boost Stop" : "Aux Boost Start";
|
||||||
|
auxBoost.classList.toggle("primary", !view.auxOn);
|
||||||
|
auxBoost.classList.toggle("danger", Boolean(view.auxOn));
|
||||||
|
const battToggle = document.getElementById("batt-toggle");
|
||||||
|
battToggle.textContent = plant.batteryOn ? "Battery On" : "Battery Off";
|
||||||
|
battToggle.classList.toggle("primary", !plant.batteryOn);
|
||||||
|
battToggle.classList.toggle("danger", Boolean(plant.batteryOn));
|
||||||
const heat = plant.heatRateCpm ?? 1;
|
const heat = plant.heatRateCpm ?? 1;
|
||||||
const thermo = plant.thermostatC ?? 65;
|
const thermo = plant.thermostatC ?? 65;
|
||||||
const charge = plant.chargeRatePpm ?? 10;
|
const charge = plant.chargeRateWpm ?? 500;
|
||||||
|
const batteryTotal = plant.batteryTotalW ?? 5000;
|
||||||
document.getElementById("heat-rate-current").textContent = String(heat);
|
document.getElementById("heat-rate-current").textContent = String(heat);
|
||||||
document.getElementById("thermostat-current").textContent = String(thermo);
|
document.getElementById("thermostat-current").textContent = String(thermo);
|
||||||
document.getElementById("charge-rate-current").textContent = String(charge);
|
document.getElementById("charge-rate-current").textContent = String(charge);
|
||||||
|
document.getElementById("battery-total-current").textContent = String(batteryTotal);
|
||||||
if (document.activeElement !== els.heatRate) els.heatRate.value = heat;
|
if (document.activeElement !== els.heatRate) els.heatRate.value = heat;
|
||||||
if (document.activeElement !== els.thermostat) els.thermostat.value = thermo;
|
if (document.activeElement !== els.thermostat) els.thermostat.value = thermo;
|
||||||
if (document.activeElement !== els.chargeRate) els.chargeRate.value = charge;
|
if (document.activeElement !== els.chargeRate) els.chargeRate.value = charge;
|
||||||
|
const batteryTotalInput = document.getElementById("battery-total");
|
||||||
|
if (document.activeElement !== batteryTotalInput) batteryTotalInput.value = batteryTotal;
|
||||||
|
const batteryActual = Math.max(0, Math.min(batteryTotal, Number(plant.batteryActualW) || 0));
|
||||||
|
document.getElementById("battery-actual-current").textContent = String(batteryActual);
|
||||||
|
const batteryActualInput = document.getElementById("battery-actual");
|
||||||
|
batteryActualInput.max = String(batteryTotal);
|
||||||
|
if (document.activeElement !== batteryActualInput) batteryActualInput.value = batteryActual;
|
||||||
const tank = device.telemetry?.heatsink_s;
|
const tank = device.telemetry?.heatsink_s;
|
||||||
els.tankTemp.textContent = tank == null ? "—" : `${Number(tank).toFixed(1)} °C`;
|
const tankInput = document.getElementById("tank-temp-input");
|
||||||
|
els.tankTemp.textContent = tank == null ? "—" : Number(tank).toFixed(1);
|
||||||
|
if (tankInput && document.activeElement !== tankInput && tank != null) tankInput.value = Number(tank);
|
||||||
renderPowerSim(device);
|
renderPowerSim(device);
|
||||||
|
setPlantStatus("hotWater", view.hwOn ? "Boost On" : "");
|
||||||
|
setPlantStatus("aux", view.auxOn ? "Boost On" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlantStatus(id, text) {
|
||||||
|
const box = document.querySelector(`[data-plant-status="${id}"]`);
|
||||||
|
if (box) box.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POWER_FIELDS = {
|
const POWER_FIELDS = {
|
||||||
@ -267,10 +296,17 @@ function renderPowerSim(device) {
|
|||||||
lamp.classList.toggle("draw", row.state === "draw");
|
lamp.classList.toggle("draw", row.state === "draw");
|
||||||
lamp.classList.toggle("supply", row.state === "supply");
|
lamp.classList.toggle("supply", row.state === "supply");
|
||||||
}
|
}
|
||||||
if (value) {
|
if (value) value.textContent = String(row.watts);
|
||||||
const direction = row.id === "grid" ? row.label : row.state === "supply" ? "supply" : row.state === "draw" ? "draw" : "";
|
if (row.id === "solar") setPlantStatus("solar", row.state === "supply" ? "Generating" : "");
|
||||||
const suffix = row.id === "grid" || row.id === "battery" ? ` ${direction}` : "";
|
if (row.id === "grid") {
|
||||||
value.textContent = `${row.watts} W${suffix}`.trim();
|
setPlantStatus("grid", row.state === "draw" ? "Importing" : row.state === "supply" ? "Exporting" : "");
|
||||||
|
}
|
||||||
|
if (row.id === "home") setPlantStatus("home", row.state === "draw" ? "Drawing" : "");
|
||||||
|
if (row.id === "battery") {
|
||||||
|
setPlantStatus(
|
||||||
|
"battery",
|
||||||
|
row.flowState === "draw" ? "Charging" : row.flowState === "supply" ? "Discharging" : "",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const input = document.querySelector(`[data-power-input="${row.id}"]`);
|
const input = document.querySelector(`[data-power-input="${row.id}"]`);
|
||||||
if (input && document.activeElement !== input && row.setpoint != null) {
|
if (input && document.activeElement !== input && row.setpoint != null) {
|
||||||
@ -288,9 +324,6 @@ async function savePowerRow(id) {
|
|||||||
if (!field) return;
|
if (!field) return;
|
||||||
const input = document.querySelector(`[data-power-input="${id}"]`);
|
const input = document.querySelector(`[data-power-input="${id}"]`);
|
||||||
const body = { [field]: Number(input.value) };
|
const body = { [field]: Number(input.value) };
|
||||||
if (id === "battery") {
|
|
||||||
body.batteryMode = document.getElementById("power-battery-mode").value === "supply" ? "supply" : "draw";
|
|
||||||
}
|
|
||||||
const data = await api(`/api/devices/${device.id}/plant`, {
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@ -324,7 +357,7 @@ function renderFields(device) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")}
|
${fieldRowsHtml(fields, values, enabled)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -361,7 +394,33 @@ function syncFieldValues(device) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldRowHtml(field, value, enabled) {
|
function fieldRowsHtml(fields, values, enabled) {
|
||||||
|
const blocks = [];
|
||||||
|
for (const field of fields) {
|
||||||
|
const last = blocks[blocks.length - 1];
|
||||||
|
if (field.share && last?.share === field.share) last.fields.push(field);
|
||||||
|
else blocks.push({ share: field.share || "", fields: [field] });
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
.map((block) => {
|
||||||
|
if (block.fields.length < 2) {
|
||||||
|
const field = block.fields[0];
|
||||||
|
return fieldRowHtml(field, values[field.key], enabled.has(field.key));
|
||||||
|
}
|
||||||
|
return block.fields
|
||||||
|
.map((field, index) => fieldRowHtml(field, values[field.key], enabled.has(field.key), {
|
||||||
|
share: true,
|
||||||
|
editor: index === 0,
|
||||||
|
span: block.fields.length,
|
||||||
|
inputKey: block.fields[0].key,
|
||||||
|
edge: index === 0 ? "start" : index === block.fields.length - 1 ? "end" : "mid",
|
||||||
|
}))
|
||||||
|
.join("");
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldRowHtml(field, value, enabled, share = null) {
|
||||||
const inactive = enabled ? "" : "inactive";
|
const inactive = enabled ? "" : "inactive";
|
||||||
const onToggle = `
|
const onToggle = `
|
||||||
<input
|
<input
|
||||||
@ -372,8 +431,9 @@ function fieldRowHtml(field, value, enabled) {
|
|||||||
aria-label="Include ${escapeHtml(field.key)} in telemetry"
|
aria-label="Include ${escapeHtml(field.key)} in telemetry"
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
|
const submitKey = share?.inputKey || field.key;
|
||||||
const submit = `
|
const submit = `
|
||||||
<button type="button" data-submit-key="${escapeHtml(field.key)}">update</button>
|
<button type="button" data-submit-key="${escapeHtml(submitKey)}">update</button>
|
||||||
`;
|
`;
|
||||||
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
|
const title = escapeHtml(field.unit ? `${field.label} (${field.unit})` : field.label);
|
||||||
|
|
||||||
@ -382,7 +442,7 @@ function fieldRowHtml(field, value, enabled) {
|
|||||||
const on = value === 1 || value === true || value === "1";
|
const on = value === 1 || value === true || value === "1";
|
||||||
editor = `
|
editor = `
|
||||||
<select
|
<select
|
||||||
data-key="${escapeHtml(field.key)}"
|
data-key="${escapeHtml(submitKey)}"
|
||||||
aria-label="New value for ${escapeHtml(field.key)}"
|
aria-label="New value for ${escapeHtml(field.key)}"
|
||||||
>
|
>
|
||||||
<option value="1" ${on ? "selected" : ""}>true</option>
|
<option value="1" ${on ? "selected" : ""}>true</option>
|
||||||
@ -394,7 +454,7 @@ function fieldRowHtml(field, value, enabled) {
|
|||||||
const step = field.step ?? (field.type === "integer" ? 1 : "any");
|
const step = field.step ?? (field.type === "integer" ? 1 : "any");
|
||||||
editor = `
|
editor = `
|
||||||
<input
|
<input
|
||||||
data-key="${escapeHtml(field.key)}"
|
data-key="${escapeHtml(submitKey)}"
|
||||||
type="number"
|
type="number"
|
||||||
step="${step}"
|
step="${step}"
|
||||||
${field.min != null ? `min="${field.min}"` : ""}
|
${field.min != null ? `min="${field.min}"` : ""}
|
||||||
@ -406,12 +466,16 @@ function fieldRowHtml(field, value, enabled) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareClass = share ? ` share-group share-${share.edge}` : "";
|
||||||
|
const editorCell = !share || share.editor
|
||||||
|
? `<td class="new-value"${share ? ` rowspan="${share.span}"` : ""}>${editor}</td>`
|
||||||
|
: "";
|
||||||
return `
|
return `
|
||||||
<tr class="field-row ${inactive}">
|
<tr class="field-row ${inactive}${shareClass}">
|
||||||
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
|
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
|
||||||
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
|
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
|
||||||
<td class="on-cell">${onToggle}</td>
|
<td class="on-cell">${onToggle}</td>
|
||||||
<td class="new-value">${editor}</td>
|
${editorCell}
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -785,7 +849,9 @@ async function savePlant() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
heatRateCpm: Number(els.heatRate.value),
|
heatRateCpm: Number(els.heatRate.value),
|
||||||
thermostatC: Number(els.thermostat.value),
|
thermostatC: Number(els.thermostat.value),
|
||||||
chargeRatePpm: Number(els.chargeRate.value),
|
chargeRateWpm: Number(els.chargeRate.value),
|
||||||
|
batteryTotalW: Number(document.getElementById("battery-total").value),
|
||||||
|
batteryActualW: Number(document.getElementById("battery-actual").value),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
upsertDevice(data.device);
|
upsertDevice(data.device);
|
||||||
@ -798,9 +864,92 @@ document.getElementById("dashboard").addEventListener("click", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const plantButton = event.target.closest("[data-plant-update]");
|
const plantButton = event.target.closest("[data-plant-update]");
|
||||||
if (plantButton) savePlant();
|
if (plantButton) {
|
||||||
|
if (plantButton.getAttribute("data-plant-update") === "tank") saveTankTemp();
|
||||||
|
else savePlant();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("dashboard").addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const input = event.target.closest(".plant-setting input");
|
||||||
|
if (!input) return;
|
||||||
|
const button = input.closest(".plant-setting")?.querySelector("[data-power-update], [data-plant-update]");
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault();
|
||||||
|
button.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function confirmIfDisconnected(device) {
|
||||||
|
if (device.connected) return true;
|
||||||
|
return askConfirm({
|
||||||
|
title: "MQTT",
|
||||||
|
message: "Device isn't connected. Turn on anyway?",
|
||||||
|
okLabel: "Turn on anyway",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("batt-toggle").addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const turningOn = !device.plant?.batteryOn;
|
||||||
|
if (turningOn && !(await confirmIfDisconnected(device))) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ batteryOn: turningOn }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("aux-boost").addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const turningOn = !device.plantView?.auxOn;
|
||||||
|
if (turningOn && !(await confirmIfDisconnected(device))) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ auxManual: turningOn }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("hw-boost").addEventListener("click", async () => {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const turningOn = !device.plantView?.hwOn;
|
||||||
|
if (turningOn) {
|
||||||
|
const tank = Number(device.telemetry?.heatsink_s);
|
||||||
|
const max = Number(device.plant?.thermostatC ?? 65);
|
||||||
|
if (Number.isFinite(tank) && tank >= max) {
|
||||||
|
els.confirmCancel.classList.add("hidden");
|
||||||
|
await askConfirm({
|
||||||
|
title: "Hot water",
|
||||||
|
message: "Max temp already reached",
|
||||||
|
okLabel: "OK",
|
||||||
|
});
|
||||||
|
els.confirmCancel.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmIfDisconnected(device))) return;
|
||||||
|
}
|
||||||
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ hwManual: turningOn }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveTankTemp() {
|
||||||
|
const device = selectedDevice();
|
||||||
|
if (!device) return;
|
||||||
|
const data = await api(`/api/devices/${device.id}/telemetry`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ heatsink_s: Number(document.getElementById("tank-temp-input").value) }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
}
|
||||||
|
|
||||||
els.publishNow.addEventListener("click", async () => {
|
els.publishNow.addEventListener("click", async () => {
|
||||||
await flushTelemetry();
|
await flushTelemetry();
|
||||||
const device = selectedDevice();
|
const device = selectedDevice();
|
||||||
@ -859,9 +1008,25 @@ function readNewValue(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitNewValue(key) {
|
async function submitNewValue(key) {
|
||||||
|
const field = state.schema.fields.find((item) => item.key === key);
|
||||||
const value = readNewValue(key);
|
const value = readNewValue(key);
|
||||||
if (value == null || Number.isNaN(value)) return;
|
if (value == null || Number.isNaN(value)) return;
|
||||||
state.pendingTelemetry[key] = value;
|
const shared = field?.share
|
||||||
|
? state.schema.fields.filter((item) => item.share === field.share)
|
||||||
|
: [field || { key }];
|
||||||
|
if (field?.share === "batteryLevel") {
|
||||||
|
const device = selectedDevice();
|
||||||
|
const capacity = Number(device?.plant?.batteryTotalW) || 0;
|
||||||
|
const actual = Math.max(0, Math.min(capacity, Math.round((value / 100) * capacity)));
|
||||||
|
if (device) {
|
||||||
|
const data = await api(`/api/devices/${device.id}/plant`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ batteryActualW: actual }),
|
||||||
|
});
|
||||||
|
upsertDevice(data.device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of shared) state.pendingTelemetry[item.key] = value;
|
||||||
await flushTelemetry();
|
await flushTelemetry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -101,15 +101,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="group plant-panel">
|
<section class="group plant-panel">
|
||||||
<h3>Virtual plant</h3>
|
<h3>Virtual Diverter</h3>
|
||||||
<p class="muted">Follows ThingsBoard shared attributes from the apps: one-shot boost (boostNowHw / boostNowAux) and HW/aux schedules. Schedule saves are polled every 5s while MQTT is connected.</p>
|
<p class="muted">Follows ThingsBoard shared attributes from the apps: one-shot boost (boostNowHw / boostNowAux) and HW/aux schedules. Schedule saves are polled every 5s while MQTT is connected.</p>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Solar</h4>
|
<h4>Solar</h4>
|
||||||
<div class="plant-setting">
|
|
||||||
<span class="lamp" data-power-lamp="solar" aria-hidden="true"></span>
|
<span class="lamp" data-power-lamp="solar" aria-hidden="true"></span>
|
||||||
|
<output class="plant-status" data-plant-status="solar"></output>
|
||||||
|
</div>
|
||||||
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value</span>
|
<span>Power Supply (W)</span>
|
||||||
<output data-power-value="solar">0 W</output>
|
<output data-power-value="solar">0</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
New value (W)
|
New value (W)
|
||||||
@ -119,22 +122,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Grid</h4>
|
<h4>Grid</h4>
|
||||||
<div class="plant-setting">
|
|
||||||
<span class="lamp" data-power-lamp="grid" aria-hidden="true"></span>
|
<span class="lamp" data-power-lamp="grid" aria-hidden="true"></span>
|
||||||
<div class="plant-current">
|
<output class="plant-status" data-plant-status="grid"></output>
|
||||||
<span>Current value</span>
|
|
||||||
<output data-power-value="grid">0 W</output>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="plant-setting">
|
||||||
|
<div class="plant-current">
|
||||||
|
<span>Power Draw (W)</span>
|
||||||
|
<output data-power-value="grid">0</output>
|
||||||
|
</div>
|
||||||
|
<p class="muted plant-note">Calculated from solar, home load, hot water, aux, and battery.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Home load</h4>
|
<h4>Home load</h4>
|
||||||
<div class="plant-setting">
|
|
||||||
<span class="lamp" data-power-lamp="home" aria-hidden="true"></span>
|
<span class="lamp" data-power-lamp="home" aria-hidden="true"></span>
|
||||||
|
<output class="plant-status" data-plant-status="home"></output>
|
||||||
|
</div>
|
||||||
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value</span>
|
<span>Power Draw (W)</span>
|
||||||
<output data-power-value="home">0 W</output>
|
<output data-power-value="home">0</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
New value (W)
|
New value (W)
|
||||||
@ -144,121 +154,115 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Hot water</h4>
|
<h4>Hot water</h4>
|
||||||
<div class="lamps">
|
|
||||||
<div class="lamp-card">
|
|
||||||
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
<div>
|
<output class="plant-status" data-plant-status="hotWater"></output>
|
||||||
<strong>Hot water boost</strong>
|
|
||||||
<small id="hw-lamp-detail">Off</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="hw-boost" class="plant-boost primary">Hot Water Boost Start</button>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value (°C)</span>
|
<span>Tank Temp °C</span>
|
||||||
<output id="tank-temp">—</output>
|
<output id="tank-temp">—</output>
|
||||||
</div>
|
</div>
|
||||||
|
<label>
|
||||||
|
New value
|
||||||
|
<input id="tank-temp-input" type="number" min="0" max="99" step="0.1" />
|
||||||
|
</label>
|
||||||
|
<button type="button" data-plant-update="tank">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value (°C / min)</span>
|
<span>Heat Rate (°C/min)</span>
|
||||||
<output id="heat-rate-current">1</output>
|
<output id="heat-rate-current">1</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Heat rate (°C / min)
|
New value
|
||||||
<input id="heat-rate" type="number" min="0" max="20" step="1" />
|
<input id="heat-rate" type="number" min="0" max="20" step="1" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-plant-update="heat">Update</button>
|
<button type="button" data-plant-update="heat">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value (°C)</span>
|
<span>Thermostat max (°C)</span>
|
||||||
<output id="thermostat-current">65</output>
|
<output id="thermostat-current">65</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Thermostat max (°C)
|
New value
|
||||||
<input id="thermostat" type="number" min="0" max="99" step="1" />
|
<input id="thermostat" type="number" min="0" max="99" step="1" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-plant-update="thermostat">Update</button>
|
<button type="button" data-plant-update="thermostat">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<span class="lamp" data-power-lamp="hotWater" aria-hidden="true"></span>
|
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value</span>
|
<span>Power Draw (W)</span>
|
||||||
<output data-power-value="hotWater">0 W</output>
|
<output data-power-value="hotWater">0</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
New value (W)
|
New value
|
||||||
<input id="power-hot-water" data-power-input="hotWater" type="number" min="0" max="100000" step="1" value="0" />
|
<input id="power-hot-water" data-power-input="hotWater" type="number" min="0" max="100000" step="1" value="1000" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-power-update="hotWater">Update</button>
|
<button type="button" data-power-update="hotWater">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Aux</h4>
|
<h4>Aux</h4>
|
||||||
<div class="lamps">
|
|
||||||
<div class="lamp-card">
|
|
||||||
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
<div>
|
<output class="plant-status" data-plant-status="aux"></output>
|
||||||
<strong>Aux boost / divert</strong>
|
|
||||||
<small id="aux-lamp-detail">Off</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="aux-boost" class="plant-boost primary">Aux Boost Start</button>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<span class="lamp" data-power-lamp="aux" aria-hidden="true"></span>
|
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value</span>
|
<span>Power Draw (W)</span>
|
||||||
<output data-power-value="aux">0 W</output>
|
<output data-power-value="aux">0</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
New value (W)
|
New value
|
||||||
<input id="power-aux" data-power-input="aux" type="number" min="0" max="100000" step="1" value="0" />
|
<input id="power-aux" data-power-input="aux" type="number" min="0" max="100000" step="1" value="1000" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-power-update="aux">Update</button>
|
<button type="button" data-power-update="aux">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="plant-card">
|
<section class="plant-card">
|
||||||
|
<div class="plant-card-head">
|
||||||
<h4>Battery</h4>
|
<h4>Battery</h4>
|
||||||
<div class="lamps">
|
|
||||||
<div class="lamp-card">
|
|
||||||
<span id="batt-lamp" class="lamp" aria-hidden="true"></span>
|
<span id="batt-lamp" class="lamp" aria-hidden="true"></span>
|
||||||
<div>
|
<output class="plant-status" data-plant-status="battery"></output>
|
||||||
<strong>Battery Charging</strong>
|
|
||||||
<small id="batt-lamp-detail">Off</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" id="batt-toggle" class="plant-boost primary">Battery Off</button>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value (% / min)</span>
|
<span>Power rate (W / min)</span>
|
||||||
<output id="charge-rate-current">10</output>
|
<output id="charge-rate-current">500</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Charge rate (% / min)
|
New value
|
||||||
<input id="charge-rate" type="number" min="0" max="100" step="1" />
|
<input id="charge-rate" type="number" min="0" max="100000" step="1" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-plant-update="charge">Update</button>
|
<button type="button" data-plant-update="charge">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="plant-setting">
|
<div class="plant-setting">
|
||||||
<span class="lamp" data-power-lamp="battery" aria-hidden="true"></span>
|
|
||||||
<div class="plant-current">
|
<div class="plant-current">
|
||||||
<span>Current value</span>
|
<span>Capacity (W)</span>
|
||||||
<output data-power-value="battery">0 W</output>
|
<output id="battery-total-current">5000</output>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
Direction
|
New value
|
||||||
<select id="power-battery-mode" data-power-mode="battery" aria-label="Battery direction">
|
<input id="battery-total" type="number" min="0" max="100000" step="1" value="5000" />
|
||||||
<option value="draw">Draw</option>
|
|
||||||
<option value="supply">Supply</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
|
<button type="button" data-plant-update="battery-total">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="plant-setting">
|
||||||
|
<div class="plant-current">
|
||||||
|
<span>Actual (W)</span>
|
||||||
|
<output id="battery-actual-current">0</output>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
New value (W)
|
New value
|
||||||
<input id="power-battery" data-power-input="battery" type="number" min="0" max="100000" step="1" value="0" />
|
<input id="battery-actual" type="number" min="0" max="100000" step="1" value="0" />
|
||||||
</label>
|
</label>
|
||||||
<button type="button" data-power-update="battery">Update</button>
|
<button type="button" data-plant-update="battery-actual">Update</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -455,13 +455,49 @@ select {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plant-card h4 {
|
.plant-card-head {
|
||||||
margin: 0 0 12px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card-head h4 {
|
||||||
|
margin: 0;
|
||||||
font-family: Outfit, sans-serif;
|
font-family: Outfit, sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plant-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 140px;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-elev-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-boost {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plant-card-head small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.lamps {
|
.lamps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
@ -543,10 +579,14 @@ select {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plant-setting .lamp {
|
.plant-setting input,
|
||||||
margin-bottom: 9px;
|
.plant-setting select {
|
||||||
|
width: 140px;
|
||||||
|
min-width: 140px;
|
||||||
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.plant-current {
|
.plant-current {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@ -554,6 +594,12 @@ select {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plant-note {
|
||||||
|
margin: 0 0 9px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.plant-current output {
|
.plant-current output {
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -647,6 +693,14 @@ select {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.telemetry-table tr.share-group td {
|
||||||
|
background: rgba(21, 36, 31, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-table tr.share-group td.new-value {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.key-on-check {
|
.key-on-check {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
@ -278,7 +278,7 @@ function restore() {
|
|||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
for (const device of devices.values()) {
|
for (const device of devices.values()) {
|
||||||
device.disconnect();
|
device.disconnect({ stopHeat: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
140
src/plant.js
140
src/plant.js
@ -16,13 +16,18 @@ const SHARED_KEYS = [
|
|||||||
const DEFAULT_PLANT = {
|
const DEFAULT_PLANT = {
|
||||||
heatRateCpm: 1,
|
heatRateCpm: 1,
|
||||||
thermostatC: 65,
|
thermostatC: 65,
|
||||||
chargeRatePpm: 10,
|
chargeRateWpm: 500,
|
||||||
|
batteryTotalW: 5000,
|
||||||
|
batteryActualW: 0,
|
||||||
|
batteryOn: false,
|
||||||
solarW: 0,
|
solarW: 0,
|
||||||
homeW: 0,
|
homeW: 0,
|
||||||
auxW: 0,
|
auxW: 1000,
|
||||||
batteryW: 0,
|
batteryW: 0,
|
||||||
batteryMode: "draw",
|
batteryMode: "draw",
|
||||||
hotWaterW: 0,
|
hotWaterW: 1000,
|
||||||
|
hwManual: null,
|
||||||
|
auxManual: null,
|
||||||
hwBoostUntil: null,
|
hwBoostUntil: null,
|
||||||
auxBoostUntil: null,
|
auxBoostUntil: null,
|
||||||
lastBoostNowHw: null,
|
lastBoostNowHw: null,
|
||||||
@ -100,16 +105,20 @@ function normalizePlant(input) {
|
|||||||
if (!input || typeof input !== "object") return next;
|
if (!input || typeof input !== "object") return next;
|
||||||
const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm));
|
const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm));
|
||||||
const thermo = num(input.thermostatC, next.thermostatC);
|
const thermo = num(input.thermostatC, next.thermostatC);
|
||||||
const charge = Math.trunc(num(input.chargeRatePpm, next.chargeRatePpm));
|
|
||||||
next.heatRateCpm = Math.max(0, Math.min(20, heat));
|
next.heatRateCpm = Math.max(0, Math.min(20, heat));
|
||||||
next.thermostatC = Math.max(0, Math.min(99, thermo));
|
next.thermostatC = Math.max(0, Math.min(99, thermo));
|
||||||
next.chargeRatePpm = Math.max(0, Math.min(100, charge));
|
next.batteryTotalW = watts(input.batteryTotalW, next.batteryTotalW);
|
||||||
|
next.chargeRateWpm = batteryRateW(input, next);
|
||||||
|
next.batteryActualW = Math.max(0, Math.min(next.batteryTotalW, num(input.batteryActualW, 0)));
|
||||||
|
next.batteryOn = input.batteryOn === true;
|
||||||
next.solarW = watts(input.solarW, next.solarW);
|
next.solarW = watts(input.solarW, next.solarW);
|
||||||
next.homeW = watts(input.homeW, next.homeW);
|
next.homeW = watts(input.homeW, next.homeW);
|
||||||
next.auxW = watts(input.auxW, next.auxW);
|
next.auxW = watts(input.auxW, next.auxW);
|
||||||
next.batteryW = watts(input.batteryW, next.batteryW);
|
next.batteryW = watts(input.batteryW, next.batteryW);
|
||||||
next.batteryMode = input.batteryMode === "supply" ? "supply" : "draw";
|
next.batteryMode = input.batteryMode === "supply" ? "supply" : "draw";
|
||||||
next.hotWaterW = watts(input.hotWaterW, next.hotWaterW);
|
next.hotWaterW = watts(input.hotWaterW, next.hotWaterW);
|
||||||
|
next.hwManual = input.hwManual === true ? true : input.hwManual === false ? false : null;
|
||||||
|
next.auxManual = input.auxManual === true ? true : input.auxManual === false ? false : null;
|
||||||
next.hwBoostUntil = input.hwBoostUntil || null;
|
next.hwBoostUntil = input.hwBoostUntil || null;
|
||||||
next.auxBoostUntil = input.auxBoostUntil || null;
|
next.auxBoostUntil = input.auxBoostUntil || null;
|
||||||
next.lastBoostNowHw = input.lastBoostNowHw ?? null;
|
next.lastBoostNowHw = input.lastBoostNowHw ?? null;
|
||||||
@ -121,6 +130,15 @@ function watts(value, fallback = 0) {
|
|||||||
return Math.max(0, Math.min(100000, Math.trunc(num(value, fallback))));
|
return Math.max(0, Math.min(100000, Math.trunc(num(value, fallback))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function batteryRateW(plant, fallbackPlant) {
|
||||||
|
if (plant.chargeRateWpm != null) return watts(plant.chargeRateWpm, fallbackPlant?.chargeRateWpm ?? 500);
|
||||||
|
if (plant.chargeRatePpm != null) {
|
||||||
|
const capacity = watts(plant.batteryTotalW, fallbackPlant?.batteryTotalW ?? 5000);
|
||||||
|
return watts((capacity * num(plant.chargeRatePpm, 0)) / 100, 0);
|
||||||
|
}
|
||||||
|
return watts(fallbackPlant?.chargeRateWpm, 500);
|
||||||
|
}
|
||||||
|
|
||||||
function takePower(demand, solarLeft, batteryLeft) {
|
function takePower(demand, solarLeft, batteryLeft) {
|
||||||
const fromSolar = Math.min(solarLeft, demand);
|
const fromSolar = Math.min(solarLeft, demand);
|
||||||
const afterSolar = demand - fromSolar;
|
const afterSolar = demand - fromSolar;
|
||||||
@ -141,57 +159,80 @@ function flowState(kind, wattsValue) {
|
|||||||
return "draw";
|
return "draw";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function coverWithSolar(demand, solarLeft) {
|
||||||
|
const fromSolar = Math.min(solarLeft, demand);
|
||||||
|
return { solar: fromSolar, remainder: demand - fromSolar, solarLeft: solarLeft - fromSolar };
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePowerSim({ plant, hwOn, auxOn }) {
|
function resolvePowerSim({ plant, hwOn, auxOn }) {
|
||||||
const solar = watts(plant.solarW);
|
const solar = watts(plant.solarW);
|
||||||
const home = watts(plant.homeW);
|
const home = watts(plant.homeW);
|
||||||
const aux = auxOn ? watts(plant.auxW) : 0;
|
const aux = auxOn ? watts(plant.auxW) : 0;
|
||||||
const hw = hwOn ? watts(plant.hotWaterW) : 0;
|
const hw = hwOn ? watts(plant.hotWaterW) : 0;
|
||||||
const batterySet = watts(plant.batteryW);
|
const capacity = watts(plant.batteryTotalW);
|
||||||
const batterySupply = plant.batteryMode === "supply";
|
const storedActual = Math.max(0, Math.min(capacity, num(plant.batteryActualW, 0)));
|
||||||
const battCharge = batterySupply ? 0 : batterySet;
|
const rate = plant.batteryOn ? batteryRateW(plant) : 0;
|
||||||
const battDischarge = batterySupply ? batterySet : 0;
|
const net = home + aux + hw - solar;
|
||||||
|
const batteryPower = rate > 0 && net < 0 && storedActual < capacity
|
||||||
|
? -rate
|
||||||
|
: rate > 0 && net > 0 && storedActual > 0
|
||||||
|
? rate
|
||||||
|
: 0;
|
||||||
|
const charge = batteryPower < 0 ? -batteryPower : 0;
|
||||||
|
const discharge = batteryPower > 0 ? batteryPower : 0;
|
||||||
|
|
||||||
let solarLeft = solar;
|
let solarLeft = solar;
|
||||||
let batteryLeft = battDischarge;
|
const homeSolar = coverWithSolar(home, solarLeft);
|
||||||
const homeTake = takePower(home, solarLeft, batteryLeft);
|
solarLeft = homeSolar.solarLeft;
|
||||||
solarLeft = homeTake.solarLeft;
|
const auxSolar = coverWithSolar(aux, solarLeft);
|
||||||
batteryLeft = homeTake.batteryLeft;
|
solarLeft = auxSolar.solarLeft;
|
||||||
const auxTake = takePower(aux, solarLeft, batteryLeft);
|
const hwSolar = coverWithSolar(hw, solarLeft);
|
||||||
solarLeft = auxTake.solarLeft;
|
solarLeft = hwSolar.solarLeft;
|
||||||
batteryLeft = auxTake.batteryLeft;
|
const battSolar = coverWithSolar(charge, solarLeft);
|
||||||
const hwTake = takePower(hw, solarLeft, batteryLeft);
|
solarLeft = battSolar.solarLeft;
|
||||||
solarLeft = hwTake.solarLeft;
|
|
||||||
batteryLeft = hwTake.batteryLeft;
|
|
||||||
const battTake = takePower(battCharge, solarLeft, batteryLeft);
|
|
||||||
solarLeft = battTake.solarLeft;
|
|
||||||
batteryLeft = battTake.batteryLeft;
|
|
||||||
|
|
||||||
const solarToGrid = solarLeft;
|
let batteryLeft = discharge;
|
||||||
const batteryToGrid = batteryLeft;
|
function coverRemainder(remainder) {
|
||||||
const gridImport = homeTake.grid + auxTake.grid + hwTake.grid + battTake.grid;
|
const fromBattery = Math.min(batteryLeft, remainder);
|
||||||
const gridExport = solarToGrid + batteryToGrid;
|
batteryLeft -= fromBattery;
|
||||||
|
return { battery: fromBattery, grid: remainder - fromBattery };
|
||||||
|
}
|
||||||
|
const homeGap = coverRemainder(homeSolar.remainder);
|
||||||
|
const auxGap = coverRemainder(auxSolar.remainder);
|
||||||
|
const hwGap = coverRemainder(hwSolar.remainder);
|
||||||
|
const batteryExport = batteryLeft;
|
||||||
|
|
||||||
|
const gridImport = homeGap.grid + auxGap.grid + hwGap.grid + battSolar.remainder;
|
||||||
|
const gridExport = solarLeft + batteryExport;
|
||||||
const gridNet = gridImport - gridExport;
|
const gridNet = gridImport - gridExport;
|
||||||
|
|
||||||
|
const homeTake = { solar: homeSolar.solar, battery: homeGap.battery, grid: homeGap.grid };
|
||||||
|
const auxTake = { solar: auxSolar.solar, battery: auxGap.battery, grid: auxGap.grid };
|
||||||
|
const hwTake = { solar: hwSolar.solar, battery: hwGap.battery, grid: hwGap.grid };
|
||||||
|
const batteryPercent = capacity > 0 ? Math.round((storedActual / capacity) * 1000) / 10 : 0;
|
||||||
const telemetry = {
|
const telemetry = {
|
||||||
solarPower_s: solar,
|
solarPower_s: solar,
|
||||||
loadPower_s: home + aux,
|
loadPower_s: home + aux,
|
||||||
hotWaterInPower_s: hw,
|
hotWaterInPower_s: hw,
|
||||||
batteryPower_s: battDischarge - battCharge,
|
batteryPower_s: batteryPower,
|
||||||
|
batteryPercent_s: batteryPercent,
|
||||||
|
batterySoc_s: batteryPercent,
|
||||||
|
soc_s: batteryPercent,
|
||||||
gridPower_s: gridNet,
|
gridPower_s: gridNet,
|
||||||
solarToLoadPower_s: homeTake.solar + auxTake.solar,
|
solarToLoadPower_s: homeTake.solar + auxTake.solar,
|
||||||
solarToHotWaterPower_s: hwTake.solar,
|
solarToHotWaterPower_s: hwTake.solar,
|
||||||
solarToBatteryPower_s: battTake.solar,
|
solarToBatteryPower_s: battSolar.solar,
|
||||||
solarToGridPower_s: solarToGrid,
|
solarToGridPower_s: solarLeft,
|
||||||
gridToLoadPower_s: homeTake.grid + auxTake.grid,
|
gridToLoadPower_s: homeTake.grid + auxTake.grid,
|
||||||
gridToHotWaterPower_s: hwTake.grid,
|
gridToHotWaterPower_s: hwTake.grid,
|
||||||
gridToBatteryPower_s: battTake.grid,
|
gridToBatteryPower_s: battSolar.remainder,
|
||||||
batteryToLoadPower_s: homeTake.battery + auxTake.battery,
|
batteryToLoadPower_s: homeTake.battery + auxTake.battery,
|
||||||
batteryToHotWaterPower_s: hwTake.battery,
|
batteryToHotWaterPower_s: hwTake.battery,
|
||||||
batteryToGridPower_s: batteryToGrid,
|
batteryToGridPower_s: batteryExport,
|
||||||
};
|
};
|
||||||
|
|
||||||
const gridState = gridNet > 0 ? "draw" : gridNet < 0 ? "supply" : "idle";
|
const gridState = gridNet > 0 ? "draw" : gridNet < 0 ? "supply" : "idle";
|
||||||
const batteryState = battCharge > 0 ? "draw" : battDischarge > 0 ? "supply" : "idle";
|
const batteryState = batteryPower < 0 ? "draw" : batteryPower > 0 ? "supply" : "idle";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
telemetry,
|
telemetry,
|
||||||
@ -206,20 +247,34 @@ function resolvePowerSim({ plant, hwOn, auxOn }) {
|
|||||||
calculated: true,
|
calculated: true,
|
||||||
},
|
},
|
||||||
{ id: "home", name: "Home load", watts: home, state: flowState("draw", home), setpoint: watts(plant.homeW) },
|
{ id: "home", name: "Home load", watts: home, state: flowState("draw", home), setpoint: watts(plant.homeW) },
|
||||||
{ id: "aux", name: "Aux", watts: aux, state: flowState("draw", aux), setpoint: watts(plant.auxW) },
|
{ id: "aux", name: "Aux", watts: watts(plant.auxW), state: flowState("draw", aux), setpoint: watts(plant.auxW) },
|
||||||
{
|
{
|
||||||
id: "battery",
|
id: "battery",
|
||||||
name: "Battery",
|
name: "Battery",
|
||||||
watts: batterySupply ? battDischarge : battCharge,
|
watts: Math.abs(storedActual),
|
||||||
state: batteryState,
|
state: plant.batteryOn ? batteryState : "idle",
|
||||||
setpoint: batterySet,
|
flowState: batteryState,
|
||||||
mode: batterySupply ? "supply" : "draw",
|
setpoint: watts(plant.batteryTotalW),
|
||||||
},
|
},
|
||||||
{ id: "hotWater", name: "Hot water", watts: hw, state: flowState("draw", hw), setpoint: watts(plant.hotWaterW) },
|
{ id: "hotWater", name: "Hot water", watts: watts(plant.hotWaterW), state: flowState("draw", hw), setpoint: watts(plant.hotWaterW) },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stepBatteryActual({ plant, hwOn, auxOn, dtSec }) {
|
||||||
|
const capacity = watts(plant.batteryTotalW);
|
||||||
|
let actual = Math.max(0, Math.min(capacity, num(plant.batteryActualW, 0)));
|
||||||
|
const rate = plant.batteryOn ? batteryRateW(plant) : 0;
|
||||||
|
if (rate <= 0) return { actual };
|
||||||
|
const solar = watts(plant.solarW);
|
||||||
|
const load = watts(plant.homeW) + (auxOn ? watts(plant.auxW) : 0) + (hwOn ? watts(plant.hotWaterW) : 0);
|
||||||
|
const net = load - solar;
|
||||||
|
const dtMin = Math.max(0, dtSec) / 60;
|
||||||
|
if (net > 0 && actual > 0) actual = Math.max(0, actual - rate * dtMin);
|
||||||
|
else if (net < 0 && actual < capacity) actual = Math.min(capacity, actual + rate * dtMin);
|
||||||
|
return { actual: Math.round(actual) };
|
||||||
|
}
|
||||||
|
|
||||||
function remainingMs(until, now) {
|
function remainingMs(until, now) {
|
||||||
if (!until) return 0;
|
if (!until) return 0;
|
||||||
return Math.max(0, until - now);
|
return Math.max(0, until - now);
|
||||||
@ -243,8 +298,12 @@ function evaluatePlant({ plant, attrs, tankC, now = Date.now(), nowMinutes = min
|
|||||||
const hwSchedule = hwScheduleActive(attrs, nowMinutes);
|
const hwSchedule = hwScheduleActive(attrs, nowMinutes);
|
||||||
const auxSchedule = auxScheduleActive(attrs, nowMinutes);
|
const auxSchedule = auxScheduleActive(attrs, nowMinutes);
|
||||||
const cutout = tankC >= plant.thermostatC;
|
const cutout = tankC >= plant.thermostatC;
|
||||||
const hwWanted = hwOneShot || hwSchedule;
|
let hwWanted = hwOneShot || hwSchedule;
|
||||||
const auxOn = auxOneShot || auxSchedule;
|
if (plant.hwManual === true) hwWanted = true;
|
||||||
|
if (plant.hwManual === false) hwWanted = false;
|
||||||
|
let auxOn = auxOneShot || auxSchedule;
|
||||||
|
if (plant.auxManual === true) auxOn = true;
|
||||||
|
if (plant.auxManual === false) auxOn = false;
|
||||||
const hwOn = hwWanted && !cutout;
|
const hwOn = hwWanted && !cutout;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -288,5 +347,6 @@ module.exports = {
|
|||||||
inScheduleWindow,
|
inScheduleWindow,
|
||||||
applyOneShot,
|
applyOneShot,
|
||||||
resolvePowerSim,
|
resolvePowerSim,
|
||||||
|
stepBatteryActual,
|
||||||
evaluatePlant,
|
evaluatePlant,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -39,9 +39,9 @@ const TELEMETRY_FIELDS = [
|
|||||||
{ key: "gridExport_s", label: "Grid export today", unit: "Wh", group: "energy", type: "number", default: 1400 },
|
{ key: "gridExport_s", label: "Grid export today", unit: "Wh", group: "energy", type: "number", default: 1400 },
|
||||||
|
|
||||||
{ key: "heatsink_s", label: "Hot water / tank temp", unit: "°C", group: "status", type: "number", default: 54.2, step: 0.1 },
|
{ key: "heatsink_s", label: "Hot water / tank temp", unit: "°C", group: "status", type: "number", default: 54.2, step: 0.1 },
|
||||||
{ key: "batterySoc_s", label: "Battery SoC", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
{ key: "batterySoc_s", label: "Battery SoC", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100, share: "batteryLevel" },
|
||||||
{ key: "soc_s", label: "SoC (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
{ key: "soc_s", label: "SoC (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100, share: "batteryLevel" },
|
||||||
{ key: "batteryPercent_s", label: "Battery percent (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100 },
|
{ key: "batteryPercent_s", label: "Battery percent (fallback)", unit: "%", group: "status", type: "number", default: 64, min: 0, max: 100, share: "batteryLevel" },
|
||||||
{ key: "auxOn_s", label: "Aux on", unit: "", group: "status", type: "boolean", default: 0 },
|
{ key: "auxOn_s", label: "Aux on", unit: "", group: "status", type: "boolean", default: 0 },
|
||||||
{ key: "auxRelay_s", label: "Aux relay", unit: "", group: "status", type: "boolean", default: 0 },
|
{ 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: "rlyInSel_s", label: "Control load heating", unit: "", group: "status", type: "boolean", default: 0 },
|
||||||
|
|||||||
@ -7,6 +7,7 @@ const {
|
|||||||
unwrapAttr,
|
unwrapAttr,
|
||||||
applyOneShot,
|
applyOneShot,
|
||||||
resolvePowerSim,
|
resolvePowerSim,
|
||||||
|
stepBatteryActual,
|
||||||
evaluatePlant,
|
evaluatePlant,
|
||||||
roundTenths,
|
roundTenths,
|
||||||
} = require("./plant");
|
} = require("./plant");
|
||||||
@ -42,13 +43,14 @@ class VirtualDevice {
|
|||||||
this.onTelemetryChange = null;
|
this.onTelemetryChange = null;
|
||||||
this.onPlantChange = null;
|
this.onPlantChange = null;
|
||||||
this.onSharedWrite = null;
|
this.onSharedWrite = null;
|
||||||
|
this.startHeatLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.disabled) return;
|
if (this.disabled) return;
|
||||||
if (this.connected || this.connecting) return;
|
if (this.connected || this.connecting) return;
|
||||||
this.wantedOnline = true;
|
this.wantedOnline = true;
|
||||||
this.stopPlantLoop();
|
this.stopAttrPoll();
|
||||||
this.endClient();
|
this.endClient();
|
||||||
|
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
@ -77,7 +79,7 @@ class VirtualDevice {
|
|||||||
this.client.subscribe("v1/devices/me/attributes");
|
this.client.subscribe("v1/devices/me/attributes");
|
||||||
this.client.subscribe("v1/devices/me/attributes/response/+");
|
this.client.subscribe("v1/devices/me/attributes/response/+");
|
||||||
this.requestSharedAttributes();
|
this.requestSharedAttributes();
|
||||||
this.startPlantLoop();
|
this.startAttrPoll();
|
||||||
if (this.telemetryEnabled) this.startTelemetry();
|
if (this.telemetryEnabled) this.startTelemetry();
|
||||||
this.publishTelemetry();
|
this.publishTelemetry();
|
||||||
});
|
});
|
||||||
@ -123,14 +125,15 @@ class VirtualDevice {
|
|||||||
client.end(true);
|
client.end(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect({ disabled = false } = {}) {
|
disconnect({ disabled = false, stopHeat = false } = {}) {
|
||||||
this.wantedOnline = false;
|
this.wantedOnline = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
if (disabled) this.disabled = true;
|
if (disabled) this.disabled = true;
|
||||||
this.lastConnectionResponse = "Disconnected";
|
this.lastConnectionResponse = "Disconnected";
|
||||||
this.stopTelemetry();
|
this.stopTelemetry();
|
||||||
this.stopPlantLoop();
|
this.stopAttrPoll();
|
||||||
|
if (disabled || stopHeat) this.stopHeatLoop();
|
||||||
this.endClient();
|
this.endClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,19 +193,25 @@ class VirtualDevice {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
startPlantLoop() {
|
startHeatLoop() {
|
||||||
this.stopPlantLoop();
|
if (this.plantTimer) return;
|
||||||
this.lastHeatAt = Date.now();
|
this.lastHeatAt = Date.now();
|
||||||
this.plantTimer = setInterval(() => this.tickPlant(), 1000);
|
this.plantTimer = setInterval(() => this.tickPlant(), 1000);
|
||||||
this.attrPollTimer = setInterval(() => this.requestSharedAttributes(), 5000);
|
|
||||||
this.tickPlant();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPlantLoop() {
|
stopHeatLoop() {
|
||||||
if (this.plantTimer) {
|
if (this.plantTimer) {
|
||||||
clearInterval(this.plantTimer);
|
clearInterval(this.plantTimer);
|
||||||
this.plantTimer = null;
|
this.plantTimer = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startAttrPoll() {
|
||||||
|
this.stopAttrPoll();
|
||||||
|
this.attrPollTimer = setInterval(() => this.requestSharedAttributes(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopAttrPoll() {
|
||||||
if (this.attrPollTimer) {
|
if (this.attrPollTimer) {
|
||||||
clearInterval(this.attrPollTimer);
|
clearInterval(this.attrPollTimer);
|
||||||
this.attrPollTimer = null;
|
this.attrPollTimer = null;
|
||||||
@ -287,6 +296,16 @@ class VirtualDevice {
|
|||||||
|
|
||||||
const hw = after.hwOn ? 1 : 0;
|
const hw = after.hwOn ? 1 : 0;
|
||||||
const aux = after.auxOn ? 1 : 0;
|
const aux = after.auxOn ? 1 : 0;
|
||||||
|
const batteryStep = stepBatteryActual({
|
||||||
|
plant: this.plant,
|
||||||
|
hwOn: after.hwOn,
|
||||||
|
auxOn: after.auxOn,
|
||||||
|
dtSec,
|
||||||
|
});
|
||||||
|
if (this.plant.batteryActualW !== batteryStep.actual) {
|
||||||
|
this.plant = { ...this.plant, batteryActualW: batteryStep.actual };
|
||||||
|
if (this.onPlantChange) this.onPlantChange(this);
|
||||||
|
}
|
||||||
const power = resolvePowerSim({ plant: this.plant, hwOn: after.hwOn, auxOn: after.auxOn });
|
const power = resolvePowerSim({ plant: this.plant, hwOn: after.hwOn, auxOn: after.auxOn });
|
||||||
const withPower = { ...this.telemetry, ...power.telemetry };
|
const withPower = { ...this.telemetry, ...power.telemetry };
|
||||||
const changed =
|
const changed =
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user