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-*
|
||||
.env
|
||||
.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.
|
||||
|
||||
## 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)
|
||||
|
||||
211
public/app.js
211
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) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${fields.map((field) => fieldRowHtml(field, values[field.key], enabled.has(field.key))).join("")}
|
||||
${fieldRowsHtml(fields, values, enabled)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 onToggle = `
|
||||
<input
|
||||
@ -372,8 +431,9 @@ function fieldRowHtml(field, value, enabled) {
|
||||
aria-label="Include ${escapeHtml(field.key)} in telemetry"
|
||||
/>
|
||||
`;
|
||||
const submitKey = share?.inputKey || field.key;
|
||||
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);
|
||||
|
||||
@ -382,7 +442,7 @@ function fieldRowHtml(field, value, enabled) {
|
||||
const on = value === 1 || value === true || value === "1";
|
||||
editor = `
|
||||
<select
|
||||
data-key="${escapeHtml(field.key)}"
|
||||
data-key="${escapeHtml(submitKey)}"
|
||||
aria-label="New value for ${escapeHtml(field.key)}"
|
||||
>
|
||||
<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");
|
||||
editor = `
|
||||
<input
|
||||
data-key="${escapeHtml(field.key)}"
|
||||
data-key="${escapeHtml(submitKey)}"
|
||||
type="number"
|
||||
step="${step}"
|
||||
${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 `
|
||||
<tr class="field-row ${inactive}">
|
||||
<tr class="field-row ${inactive}${shareClass}">
|
||||
<td class="key" title="${title}">${escapeHtml(field.key)}</td>
|
||||
<td class="field-value" data-value-key="${escapeHtml(field.key)}">${escapeHtml(displayValue(value))}</td>
|
||||
<td class="on-cell">${onToggle}</td>
|
||||
<td class="new-value">${editor}</td>
|
||||
${editorCell}
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -101,15 +101,18 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<section class="plant-card">
|
||||
<h4>Solar</h4>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-card-head">
|
||||
<h4>Solar</h4>
|
||||
<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">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="solar">0 W</output>
|
||||
<span>Power Supply (W)</span>
|
||||
<output data-power-value="solar">0</output>
|
||||
</div>
|
||||
<label>
|
||||
New value (W)
|
||||
@ -119,22 +122,29 @@
|
||||
</div>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
<h4>Grid</h4>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-card-head">
|
||||
<h4>Grid</h4>
|
||||
<span class="lamp" data-power-lamp="grid" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="grid"></output>
|
||||
</div>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="grid">0 W</output>
|
||||
<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>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
<h4>Home load</h4>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-card-head">
|
||||
<h4>Home load</h4>
|
||||
<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">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="home">0 W</output>
|
||||
<span>Power Draw (W)</span>
|
||||
<output data-power-value="home">0</output>
|
||||
</div>
|
||||
<label>
|
||||
New value (W)
|
||||
@ -144,121 +154,115 @@
|
||||
</div>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
<h4>Hot water</h4>
|
||||
<div class="lamps">
|
||||
<div class="lamp-card">
|
||||
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>Hot water boost</strong>
|
||||
<small id="hw-lamp-detail">Off</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plant-card-head">
|
||||
<h4>Hot water</h4>
|
||||
<span id="hw-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="hotWater"></output>
|
||||
</div>
|
||||
<button type="button" id="hw-boost" class="plant-boost primary">Hot Water Boost Start</button>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (°C)</span>
|
||||
<span>Tank Temp °C</span>
|
||||
<output id="tank-temp">—</output>
|
||||
</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 class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (°C / min)</span>
|
||||
<span>Heat Rate (°C/min)</span>
|
||||
<output id="heat-rate-current">1</output>
|
||||
</div>
|
||||
<label>
|
||||
Heat rate (°C / min)
|
||||
New value
|
||||
<input id="heat-rate" type="number" min="0" max="20" step="1" />
|
||||
</label>
|
||||
<button type="button" data-plant-update="heat">Update</button>
|
||||
</div>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (°C)</span>
|
||||
<span>Thermostat max (°C)</span>
|
||||
<output id="thermostat-current">65</output>
|
||||
</div>
|
||||
<label>
|
||||
Thermostat max (°C)
|
||||
New value
|
||||
<input id="thermostat" type="number" min="0" max="99" step="1" />
|
||||
</label>
|
||||
<button type="button" data-plant-update="thermostat">Update</button>
|
||||
</div>
|
||||
<div class="plant-setting">
|
||||
<span class="lamp" data-power-lamp="hotWater" aria-hidden="true"></span>
|
||||
<div class="plant-current">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="hotWater">0 W</output>
|
||||
<span>Power Draw (W)</span>
|
||||
<output data-power-value="hotWater">0</output>
|
||||
</div>
|
||||
<label>
|
||||
New value (W)
|
||||
<input id="power-hot-water" data-power-input="hotWater" type="number" min="0" max="100000" step="1" value="0" />
|
||||
New value
|
||||
<input id="power-hot-water" data-power-input="hotWater" type="number" min="0" max="100000" step="1" value="1000" />
|
||||
</label>
|
||||
<button type="button" data-power-update="hotWater">Update</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
<h4>Aux</h4>
|
||||
<div class="lamps">
|
||||
<div class="lamp-card">
|
||||
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>Aux boost / divert</strong>
|
||||
<small id="aux-lamp-detail">Off</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plant-card-head">
|
||||
<h4>Aux</h4>
|
||||
<span id="aux-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="aux"></output>
|
||||
</div>
|
||||
<button type="button" id="aux-boost" class="plant-boost primary">Aux Boost Start</button>
|
||||
<div class="plant-setting">
|
||||
<span class="lamp" data-power-lamp="aux" aria-hidden="true"></span>
|
||||
<div class="plant-current">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="aux">0 W</output>
|
||||
<span>Power Draw (W)</span>
|
||||
<output data-power-value="aux">0</output>
|
||||
</div>
|
||||
<label>
|
||||
New value (W)
|
||||
<input id="power-aux" data-power-input="aux" type="number" min="0" max="100000" step="1" value="0" />
|
||||
New value
|
||||
<input id="power-aux" data-power-input="aux" type="number" min="0" max="100000" step="1" value="1000" />
|
||||
</label>
|
||||
<button type="button" data-power-update="aux">Update</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="plant-card">
|
||||
<h4>Battery</h4>
|
||||
<div class="lamps">
|
||||
<div class="lamp-card">
|
||||
<span id="batt-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<div>
|
||||
<strong>Battery Charging</strong>
|
||||
<small id="batt-lamp-detail">Off</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plant-card-head">
|
||||
<h4>Battery</h4>
|
||||
<span id="batt-lamp" class="lamp" aria-hidden="true"></span>
|
||||
<output class="plant-status" data-plant-status="battery"></output>
|
||||
</div>
|
||||
<button type="button" id="batt-toggle" class="plant-boost primary">Battery Off</button>
|
||||
<div class="plant-setting">
|
||||
<div class="plant-current">
|
||||
<span>Current value (% / min)</span>
|
||||
<output id="charge-rate-current">10</output>
|
||||
<span>Power rate (W / min)</span>
|
||||
<output id="charge-rate-current">500</output>
|
||||
</div>
|
||||
<label>
|
||||
Charge rate (% / min)
|
||||
<input id="charge-rate" type="number" min="0" max="100" step="1" />
|
||||
New value
|
||||
<input id="charge-rate" type="number" min="0" max="100000" step="1" />
|
||||
</label>
|
||||
<button type="button" data-plant-update="charge">Update</button>
|
||||
</div>
|
||||
<div class="plant-setting">
|
||||
<span class="lamp" data-power-lamp="battery" aria-hidden="true"></span>
|
||||
<div class="plant-current">
|
||||
<span>Current value</span>
|
||||
<output data-power-value="battery">0 W</output>
|
||||
<span>Capacity (W)</span>
|
||||
<output id="battery-total-current">5000</output>
|
||||
</div>
|
||||
<label>
|
||||
Direction
|
||||
<select id="power-battery-mode" data-power-mode="battery" aria-label="Battery direction">
|
||||
<option value="draw">Draw</option>
|
||||
<option value="supply">Supply</option>
|
||||
</select>
|
||||
New value
|
||||
<input id="battery-total" type="number" min="0" max="100000" step="1" value="5000" />
|
||||
</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>
|
||||
New value (W)
|
||||
<input id="power-battery" data-power-input="battery" type="number" min="0" max="100000" step="1" value="0" />
|
||||
New value
|
||||
<input id="battery-actual" type="number" min="0" max="100000" step="1" value="0" />
|
||||
</label>
|
||||
<button type="button" data-power-update="battery">Update</button>
|
||||
<button type="button" data-plant-update="battery-actual">Update</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -278,7 +278,7 @@ function restore() {
|
||||
|
||||
function shutdown() {
|
||||
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 = {
|
||||
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,
|
||||
};
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
Reference in New Issue
Block a user