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 = `