diff --git a/.gitignore b/.gitignore index 0ab237b..bdbd55f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ data/*.db data/*.db-* .env .DS_Store +deploy/ \ No newline at end of file diff --git a/README.md b/README.md index 8bce3d2..d83e8a0 100644 --- a/README.md +++ b/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. -## Virtual plant +## Virtual Diverter 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 - 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 -- 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 @@ -50,9 +50,9 @@ Each device can publish the following live `*_s` keys on `v1/devices/me/telemetr ### Status - `heatsink_s` — hot water / tank temperature (°C) -- `batterySoc_s` — battery state of charge (%) -- `soc_s` — SoC fallback (%) -- `batteryPercent_s` — battery percent fallback (%) +- `batterySoc_s` — battery state of charge, actual / capacity × 100 +- `soc_s` — SoC, actual / capacity × 100 +- `batteryPercent_s` — battery percent, actual / capacity × 100 - `auxOn_s` — aux on (0/1) - `auxRelay_s` — aux relay (0/1) - `rlyInSel_s` — control load heating (0/1) diff --git a/public/app.js b/public/app.js index c961806..34dd42e 100644 --- a/public/app.js +++ b/public/app.js @@ -232,22 +232,51 @@ function renderPlant(device) { const plant = device.plant || {}; els.hwLamp.classList.toggle("on", Boolean(view.hwOn)); els.auxLamp.classList.toggle("on", Boolean(view.auxOn)); - els.battLamp.classList.toggle("on", Boolean(view.battCharging)); - els.hwLampDetail.textContent = view.hwDetail || "Off"; - els.auxLampDetail.textContent = view.auxDetail || "Off"; - els.battLampDetail.textContent = view.battDetail || "Off"; + const batteryRow = (view.power?.rows || []).find((row) => row.id === "battery"); + els.battLamp.classList.toggle("draw", batteryRow?.state === "draw"); + els.battLamp.classList.toggle("supply", batteryRow?.state === "supply"); + 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 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("thermostat-current").textContent = String(thermo); 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.thermostat) els.thermostat.value = thermo; 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; - 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); + 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 = { @@ -267,10 +296,17 @@ function renderPowerSim(device) { lamp.classList.toggle("draw", row.state === "draw"); lamp.classList.toggle("supply", row.state === "supply"); } - if (value) { - const direction = row.id === "grid" ? row.label : row.state === "supply" ? "supply" : row.state === "draw" ? "draw" : ""; - const suffix = row.id === "grid" || row.id === "battery" ? ` ${direction}` : ""; - value.textContent = `${row.watts} W${suffix}`.trim(); + if (value) value.textContent = String(row.watts); + if (row.id === "solar") setPlantStatus("solar", row.state === "supply" ? "Generating" : ""); + if (row.id === "grid") { + 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}"]`); if (input && document.activeElement !== input && row.setpoint != null) { @@ -288,9 +324,6 @@ async function savePowerRow(id) { if (!field) return; const input = document.querySelector(`[data-power-input="${id}"]`); 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`, { method: "PATCH", body: JSON.stringify(body), @@ -324,7 +357,7 @@ function renderFields(device) { - ${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")} + ${fieldRowsHtml(fields, values, enabled)} @@ -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 onToggle = ` `; + const submitKey = share?.inputKey || field.key; const submit = ` - + `; 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"; editor = ` ${editor}` + : ""; return ` - + ${escapeHtml(field.key)} ${escapeHtml(displayValue(value))} ${onToggle} - ${editor} + ${editorCell} `; } @@ -785,7 +849,9 @@ async function savePlant() { body: JSON.stringify({ heatRateCpm: Number(els.heatRate.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); @@ -798,9 +864,92 @@ document.getElementById("dashboard").addEventListener("click", (event) => { return; } 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 () => { await flushTelemetry(); const device = selectedDevice(); @@ -859,9 +1008,25 @@ function readNewValue(key) { } async function submitNewValue(key) { + const field = state.schema.fields.find((item) => item.key === key); const value = readNewValue(key); 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(); } diff --git a/public/index.html b/public/index.html index 8287a79..712643e 100644 --- a/public/index.html +++ b/public/index.html @@ -101,15 +101,18 @@
-

Virtual plant

+

Virtual Diverter

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.

-

Solar

-
+
+

Solar

+ +
+
- Current value - 0 W + Power Supply (W) + 0
-

Grid

-
+
+

Grid

+ +
+
- Current value - 0 W + Power Draw (W) + 0
+

Calculated from solar, home load, hot water, aux, and battery.

-

Home load

-
+
+

Home load

+ +
+
- Current value - 0 W + Power Draw (W) + 0
-

Hot water

-
-
- -
- Hot water boost - Off -
-
+
+

Hot water

+ +
+
- Current value (°C) + Tank Temp °C —
+ +
- Current value (°C / min) + Heat Rate (°C/min) 1
- Current value (°C) + Thermostat max (°C) 65
-
- Current value - 0 W + Power Draw (W) + 0
-

Aux

-
-
- -
- Aux boost / divert - Off -
-
+
+

Aux

+ +
+
-
- Current value - 0 W + Power Draw (W) + 0
-

Battery

-
-
- -
- Battery Charging - Off -
-
+
+

Battery

+ +
+
- Current value (% / min) - 10 + Power rate (W / min) + 500
-
- Current value - 0 W + Capacity (W) + 5000
+ +
+
+
+ Actual (W) + 0 +
- +
diff --git a/public/styles.css b/public/styles.css index 227410e..c5bf0fe 100644 --- a/public/styles.css +++ b/public/styles.css @@ -455,13 +455,49 @@ select { margin-top: 14px; } -.plant-card h4 { - margin: 0 0 12px; +.plant-card-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.plant-card-head h4 { + margin: 0; font-family: Outfit, sans-serif; font-weight: 600; 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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); @@ -543,10 +579,14 @@ select { margin-top: 12px; } -.plant-setting .lamp { - margin-bottom: 9px; +.plant-setting input, +.plant-setting select { + width: 140px; + min-width: 140px; + max-width: 140px; } + .plant-current { display: grid; gap: 6px; @@ -554,6 +594,12 @@ select { color: var(--muted); } +.plant-note { + margin: 0 0 9px; + max-width: 280px; + font-size: 13px; +} + .plant-current output { min-width: 140px; margin: 0; @@ -647,6 +693,14 @@ select { 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 { width: 18px; height: 18px; diff --git a/src/deviceManager.js b/src/deviceManager.js index 0f74eda..f75b118 100644 --- a/src/deviceManager.js +++ b/src/deviceManager.js @@ -278,7 +278,7 @@ function restore() { function shutdown() { for (const device of devices.values()) { - device.disconnect(); + device.disconnect({ stopHeat: true }); } } diff --git a/src/plant.js b/src/plant.js index e3ea1e2..b75475e 100644 --- a/src/plant.js +++ b/src/plant.js @@ -16,13 +16,18 @@ const SHARED_KEYS = [ const DEFAULT_PLANT = { heatRateCpm: 1, thermostatC: 65, - chargeRatePpm: 10, + chargeRateWpm: 500, + batteryTotalW: 5000, + batteryActualW: 0, + batteryOn: false, solarW: 0, homeW: 0, - auxW: 0, + auxW: 1000, batteryW: 0, batteryMode: "draw", - hotWaterW: 0, + hotWaterW: 1000, + hwManual: null, + auxManual: null, hwBoostUntil: null, auxBoostUntil: null, lastBoostNowHw: null, @@ -100,16 +105,20 @@ function normalizePlant(input) { if (!input || typeof input !== "object") return next; const heat = Math.trunc(num(input.heatRateCpm ?? input.heatRateCps, next.heatRateCpm)); 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.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.homeW = watts(input.homeW, next.homeW); next.auxW = watts(input.auxW, next.auxW); next.batteryW = watts(input.batteryW, next.batteryW); next.batteryMode = input.batteryMode === "supply" ? "supply" : "draw"; 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.auxBoostUntil = input.auxBoostUntil || 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)))); } +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) { const fromSolar = Math.min(solarLeft, demand); const afterSolar = demand - fromSolar; @@ -141,57 +159,80 @@ function flowState(kind, wattsValue) { 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 }) { const solar = watts(plant.solarW); const home = watts(plant.homeW); const aux = auxOn ? watts(plant.auxW) : 0; const hw = hwOn ? watts(plant.hotWaterW) : 0; - const batterySet = watts(plant.batteryW); - const batterySupply = plant.batteryMode === "supply"; - const battCharge = batterySupply ? 0 : batterySet; - const battDischarge = batterySupply ? batterySet : 0; + const capacity = watts(plant.batteryTotalW); + const storedActual = Math.max(0, Math.min(capacity, num(plant.batteryActualW, 0))); + const rate = plant.batteryOn ? batteryRateW(plant) : 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 batteryLeft = battDischarge; - const homeTake = takePower(home, solarLeft, batteryLeft); - solarLeft = homeTake.solarLeft; - batteryLeft = homeTake.batteryLeft; - const auxTake = takePower(aux, solarLeft, batteryLeft); - solarLeft = auxTake.solarLeft; - batteryLeft = auxTake.batteryLeft; - const hwTake = takePower(hw, solarLeft, batteryLeft); - solarLeft = hwTake.solarLeft; - batteryLeft = hwTake.batteryLeft; - const battTake = takePower(battCharge, solarLeft, batteryLeft); - solarLeft = battTake.solarLeft; - batteryLeft = battTake.batteryLeft; + const homeSolar = coverWithSolar(home, solarLeft); + solarLeft = homeSolar.solarLeft; + const auxSolar = coverWithSolar(aux, solarLeft); + solarLeft = auxSolar.solarLeft; + const hwSolar = coverWithSolar(hw, solarLeft); + solarLeft = hwSolar.solarLeft; + const battSolar = coverWithSolar(charge, solarLeft); + solarLeft = battSolar.solarLeft; - const solarToGrid = solarLeft; - const batteryToGrid = batteryLeft; - const gridImport = homeTake.grid + auxTake.grid + hwTake.grid + battTake.grid; - const gridExport = solarToGrid + batteryToGrid; + let batteryLeft = discharge; + function coverRemainder(remainder) { + const fromBattery = Math.min(batteryLeft, remainder); + 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 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 = { solarPower_s: solar, loadPower_s: home + aux, hotWaterInPower_s: hw, - batteryPower_s: battDischarge - battCharge, + batteryPower_s: batteryPower, + batteryPercent_s: batteryPercent, + batterySoc_s: batteryPercent, + soc_s: batteryPercent, gridPower_s: gridNet, solarToLoadPower_s: homeTake.solar + auxTake.solar, solarToHotWaterPower_s: hwTake.solar, - solarToBatteryPower_s: battTake.solar, - solarToGridPower_s: solarToGrid, + solarToBatteryPower_s: battSolar.solar, + solarToGridPower_s: solarLeft, gridToLoadPower_s: homeTake.grid + auxTake.grid, gridToHotWaterPower_s: hwTake.grid, - gridToBatteryPower_s: battTake.grid, + gridToBatteryPower_s: battSolar.remainder, batteryToLoadPower_s: homeTake.battery + auxTake.battery, batteryToHotWaterPower_s: hwTake.battery, - batteryToGridPower_s: batteryToGrid, + batteryToGridPower_s: batteryExport, }; 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 { telemetry, @@ -206,20 +247,34 @@ function resolvePowerSim({ plant, hwOn, auxOn }) { calculated: true, }, { 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", name: "Battery", - watts: batterySupply ? battDischarge : battCharge, - state: batteryState, - setpoint: batterySet, - mode: batterySupply ? "supply" : "draw", + watts: Math.abs(storedActual), + state: plant.batteryOn ? batteryState : "idle", + flowState: batteryState, + 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) { if (!until) return 0; 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 auxSchedule = auxScheduleActive(attrs, nowMinutes); const cutout = tankC >= plant.thermostatC; - const hwWanted = hwOneShot || hwSchedule; - const auxOn = auxOneShot || auxSchedule; + let hwWanted = hwOneShot || hwSchedule; + 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; return { @@ -288,5 +347,6 @@ module.exports = { inScheduleWindow, applyOneShot, resolvePowerSim, + stepBatteryActual, evaluatePlant, }; diff --git a/src/telemetry.js b/src/telemetry.js index be12d20..7f24386 100644 --- a/src/telemetry.js +++ b/src/telemetry.js @@ -39,9 +39,9 @@ const TELEMETRY_FIELDS = [ { 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: "batterySoc_s", label: "Battery SoC", 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 }, - { key: "batteryPercent_s", label: "Battery percent (fallback)", 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, share: "batteryLevel" }, + { 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: "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 }, diff --git a/src/virtualDevice.js b/src/virtualDevice.js index 9b26c65..9134c8d 100644 --- a/src/virtualDevice.js +++ b/src/virtualDevice.js @@ -7,6 +7,7 @@ const { unwrapAttr, applyOneShot, resolvePowerSim, + stepBatteryActual, evaluatePlant, roundTenths, } = require("./plant"); @@ -42,13 +43,14 @@ class VirtualDevice { this.onTelemetryChange = null; this.onPlantChange = null; this.onSharedWrite = null; + this.startHeatLoop(); } connect() { if (this.disabled) return; if (this.connected || this.connecting) return; this.wantedOnline = true; - this.stopPlantLoop(); + this.stopAttrPoll(); this.endClient(); this.connecting = true; @@ -77,7 +79,7 @@ class VirtualDevice { this.client.subscribe("v1/devices/me/attributes"); this.client.subscribe("v1/devices/me/attributes/response/+"); this.requestSharedAttributes(); - this.startPlantLoop(); + this.startAttrPoll(); if (this.telemetryEnabled) this.startTelemetry(); this.publishTelemetry(); }); @@ -123,14 +125,15 @@ class VirtualDevice { client.end(true); } - disconnect({ disabled = false } = {}) { + disconnect({ disabled = false, stopHeat = false } = {}) { this.wantedOnline = false; this.connecting = false; this.connected = false; if (disabled) this.disabled = true; this.lastConnectionResponse = "Disconnected"; this.stopTelemetry(); - this.stopPlantLoop(); + this.stopAttrPoll(); + if (disabled || stopHeat) this.stopHeatLoop(); this.endClient(); } @@ -190,19 +193,25 @@ class VirtualDevice { ); } - startPlantLoop() { - this.stopPlantLoop(); + startHeatLoop() { + if (this.plantTimer) return; this.lastHeatAt = Date.now(); this.plantTimer = setInterval(() => this.tickPlant(), 1000); - this.attrPollTimer = setInterval(() => this.requestSharedAttributes(), 5000); - this.tickPlant(); } - stopPlantLoop() { + stopHeatLoop() { if (this.plantTimer) { clearInterval(this.plantTimer); this.plantTimer = null; } + } + + startAttrPoll() { + this.stopAttrPoll(); + this.attrPollTimer = setInterval(() => this.requestSharedAttributes(), 5000); + } + + stopAttrPoll() { if (this.attrPollTimer) { clearInterval(this.attrPollTimer); this.attrPollTimer = null; @@ -287,6 +296,16 @@ class VirtualDevice { const hw = after.hwOn ? 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 withPower = { ...this.telemetry, ...power.telemetry }; const changed =