Merge PR #104: refactor: remove the CWU heater, the house has none

This commit is contained in:
root committed 2026-10-25 09:23:10 +01:00
commit 5bda4cb7ec
25 files changed
+49 -112

No files matched your search

+2 -3
View File
@@ -10,7 +10,7 @@ the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
`avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
@@ -18,8 +18,7 @@ program as the history shows it, not read from the controller: `weekdays[]` from
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`),
and `heaterOn` is also true while `zb_state` = 2.
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`).
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
@@ -22,7 +22,7 @@ final class InfluxSamples {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp,"
return "SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
@@ -43,8 +43,8 @@ final class InfluxSamples {
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
number(row, 5), number(row, 6), periods(row)));
number(row, 1), number(row, 2), number(row, 3),
number(row, 4), number(row, 5), periods(row)));
}
}
}
@@ -52,9 +52,9 @@ final class InfluxSamples {
}
private static Periods periods(JsonNode row) {
var targets = new Targets(number(row, 8), null, number(row, 9));
var setpoints = new Setpoints(whole(row, 10), whole(row, 11), whole(row, 12), whole(row, 13));
return Periods.of(whole(row, 7), targets, setpoints);
var targets = new Targets(number(row, 7), null, number(row, 8));
var setpoints = new Setpoints(whole(row, 9), whole(row, 10), whole(row, 11), whole(row, 12));
return Periods.of(whole(row, 6), targets, setpoints);
}
private static Integer whole(JsonNode row, int column) {
@@ -13,7 +13,6 @@ final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
@@ -39,7 +38,6 @@ final class DayTally {
compressorSeconds += on(s.compressor()) ? seconds : 0;
cwuSeconds += on(s.cwu()) ? seconds : 0;
coSeconds += on(s.co()) ? seconds : 0;
heaterSeconds += on(s.heater()) ? seconds : 0;
}
void start() {
@@ -50,7 +48,6 @@ final class DayTally {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
@@ -63,15 +60,15 @@ final class DayTally {
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
return new Day(date, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
@@ -23,7 +23,6 @@ import static home.backend.application.SnapshotFields.whole;
public class HeatPumpService {
private static final String DATABASE = "Dom_Pompa";
private static final int HEATER_STATE = 2;
private static final State[] STATES = State.values();
private final GatewayClient gateway;
@@ -49,7 +48,7 @@ public class HeatPumpService {
tenths(f, "target_cwu_temp"));
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), heaterOn(f),
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
@@ -60,11 +59,6 @@ public class HeatPumpService {
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
}
/** The heater output, or zb_state 2, which is the CWU heater too. */
private static boolean heaterOn(Map<String, Double> f) {
return on(f, "output_heater") || Integer.valueOf(HEATER_STATE).equals(whole(f, "zb_state"));
}
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
private static String alarmText(Integer code) {
if (code == null || code == 0) {
@@ -3,6 +3,6 @@ package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
Double outdoorTemp, Double cwuTemp, Periods periods) {
}
@@ -6,7 +6,7 @@ import java.time.Instant;
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent,
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
Targets targets, Setpoints setpoints, Periods periods) {
@@ -10,10 +10,10 @@ import java.util.List;
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
Double coHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -121,10 +121,10 @@ class ApiServerTest {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 9.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
@@ -256,7 +256,7 @@ class ApiServerTest {
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"output_compressor", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
@@ -543,7 +543,7 @@ class ApiServerTest {
List<HeatPumpSample> polls = new ArrayList<>();
for (int minute = 20; minute <= 125; minute += 5) {
Instant t = Instant.parse("2026-10-23T09:00:00Z").plus(Duration.ofMinutes(minute));
polls.add(new HeatPumpSample(t, null, null, null, null, null, null,
polls.add(new HeatPumpSample(t, null, null, null, null, null,
minute >= 25 && minute <= 60 ? comfort : economy));
}
return polls;
@@ -218,16 +218,16 @@ class InfluxHistoryRepositoryTest {
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp,"
assertEquals("SELECT output_compressor, output_cwu, output_co, outdoor_temp, cwu_temp,"
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9,
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 8.5, 43.9,
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
assertEquals(Period.ECONOMY, samples.get(0).periods().cwu());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null, Periods.UNKNOWN),
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, null, null, Periods.UNKNOWN),
samples.get(2));
}
@@ -41,7 +41,7 @@ class ProgramServiceTest {
}
private static HeatPumpSample poll(String utc, Periods periods) {
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, null, periods);
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, periods);
}
/** CWU economy 8, comfort 43; room economy 18, comfort 19, as on the real controller. */
@@ -67,7 +67,7 @@ class ProgramServiceTest {
List<HeatPumpSample> samples = new ArrayList<>();
Instant end = Instant.parse(to + ":00Z");
for (Instant t = Instant.parse(from + ":00Z"); !t.isAfter(end); t = t.plus(Duration.ofMinutes(POLL_MINUTES))) {
samples.add(new HeatPumpSample(t, null, null, null, null, null, null, periods));
samples.add(new HeatPumpSample(t, null, null, null, null, null, periods));
}
return samples;
}
@@ -72,13 +72,6 @@ class ServicesTest {
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpHeaterIsOnForTheOutputOrForZbState2() throws IOException {
assertTrue(heatPump(Map.of("output_heater", 100.0)).heaterOn());
assertTrue(heatPump(Map.of("output_heater", 0.0, "zb_state", 2.0)).heaterOn());
assertFalse(heatPump(Map.of("output_heater", 0.0, "zb_state", 0.0)).heaterOn());
}
@Test
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
@@ -107,12 +100,11 @@ class ServicesTest {
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
@@ -36,7 +36,7 @@ class StatsServiceTest {
}
private static HeatPumpSample compressor(String time, double stage) {
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN);
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, null, null, Periods.UNKNOWN);
}
private static Day day(StatsView view, String date) {
@@ -159,15 +159,14 @@ class StatsServiceTest {
@Test
void sumsEachOutputSeparately() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
Day d = day(view, "2026-10-23");
assertEquals(0.17, d.compressorHours());
assertEquals(0.08, d.cwuHours());
assertEquals(0.08, d.coHours());
assertEquals(0.08, d.heaterHours());
assertEquals(11.0, d.avgOutdoorTemp());
assertEquals(38.0, d.minCwuTemp());
assertEquals(40.0, d.maxCwuTemp());
@@ -176,13 +175,12 @@ class StatsServiceTest {
@Test
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 4.0, 45.0, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
assertEquals(0.17, view.totals().compressorHours());
assertEquals(0.08, view.totals().heaterHours());
assertEquals(7.0, view.totals().avgOutdoorTemp());
assertEquals(30.0, view.totals().minCwuTemp());
assertEquals(45.0, view.totals().maxCwuTemp());
@@ -191,7 +189,7 @@ class StatsServiceTest {
@Test
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
StatsView view = stats("7d", NOW,
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null, Periods.UNKNOWN),
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, Periods.UNKNOWN),
compressor("2026-10-23T10:05:00Z", 100));
assertEquals(0, day(view, "2026-10-23").compressorStarts());
@@ -3,7 +3,6 @@
"stale": true,
"state": null,
"compressorPercent": null,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
@@ -3,7 +3,6 @@
"stale": false,
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
@@ -7,7 +7,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -18,7 +17,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -29,7 +27,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -40,7 +37,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -51,7 +47,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -62,7 +57,6 @@
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
@@ -73,7 +67,6 @@
"compressorStarts": null,
"cwuHours": null,
"coHours": null,
"heaterHours": null,
"avgOutdoorTemp": null,
"minCwuTemp": null,
"maxCwuTemp": null
@@ -84,7 +77,6 @@
"compressorStarts": 1,
"cwuHours": 0.08,
"coHours": 0.0,
"heaterHours": 0.08,
"avgOutdoorTemp": 8.6,
"minCwuTemp": 43.0,
"maxCwuTemp": 46.0
@@ -1 +1 @@
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp","program","target_room_temp","target_cwu_temp","setpoint_co_economy","setpoint_co_comfort","setpoint_cwu_economy","setpoint_cwu_comfort"],"values":[[1792771200000,0,0,0,0,8.5,44.2,1,18.0,8.0,18,19,8,43],[1792771500000,100,100,0,0,8.5,43.9,1,19.0,43.0,18,19,8,43],[1792771800000,50,0,100,100,null,null,null,null,null,null,null,null,null]]}]}]}
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","outdoor_temp","cwu_temp","program","target_room_temp","target_cwu_temp","setpoint_co_economy","setpoint_co_comfort","setpoint_cwu_economy","setpoint_cwu_comfort"],"values":[[1792771200000,0,0,0,8.5,44.2,1,18.0,8.0,18,19,8,43],[1792771500000,100,100,0,8.5,43.9,1,19.0,43.0,18,19,8,43],[1792771800000,50,0,100,null,null,null,null,null,null,null,null,null]]}]}]}
+1 -1
View File
@@ -27,7 +27,7 @@ unless marked otherwise.
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater signal (heater not connected), 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater signal delay = 20 | read 2026-10-23 |
| Compressor | 4166 zwlZSt(II) = 20 min (stage 2 delay, matches history), 4165 zwlZSt(..) = 10 (likely stage 3, never seen), 4162 t_wylSpr = 5 min (minimum off, matches the 18:20 stop / 18:25 restart), 4160 t_zwlZalSpr = 20, 4161 t_zwlWylPdz = 20, 4163 t_minSpr = 160, 4164 t_maxSpr = 230 (units unknown); 4157 TminDz = −3, 4159 ∆TmaxDz = 10 | FC3 read 2026-10-25 |
| Compressor | 4166 zwlZSt(II) = 30 min (stage 2 delay; was 20, matching history, until the owner-approved one-time write of 2026-10-25, read back 30), 4165 zwlZSt(..) = 10 (likely stage 3, never seen), 4162 t_wylSpr = 5 min (minimum off, matches the 18:20 stop / 18:25 restart), 4160 t_zwlZalSpr = 20, 4161 t_zwlWylPdz = 20, 4163 t_minSpr = 160, 4164 t_maxSpr = 230 (units unknown); 4157 TminDz = −3, 4159 ∆TmaxDz = 10 | FC3 read 2026-10-25, 4166 written and read back 2026-10-25 |
| CWU extras | 4150 TpodbCWU = 5, 4151 t_podbCWU = 15, disinfection 4152–4154 = 7 days / 60 °C / 60 min (off: 4145 = 0), 4155/4156 t_zal/wylCCW = 2/2; 4246, 4247 read garbage (not on this firmware) | FC3 read 2026-10-25 |
Unknown: 4119 `WyCHL` reads −14308. Vendor-app findings, all unverified: [app-knowledge.md](app-knowledge.md).
+3 -4
View File
@@ -74,11 +74,10 @@ Layers in both apps, dependencies point inwards only:
Functional:
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
a minute per device (the MK01 takes one client). It also shows the period (comfort or economy) of CWU and room,
and the controller's heater signal (the heater is not connected).
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
a minute per device (the MK01 takes one client). It also shows the period (comfort or economy) of CWU and room.
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU and CO hours,
starts "at least" because polls are 5 minutes apart).
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
+1 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { celsius, degrees, HEATER_SIGNAL, number, periodText } from "./format";
import { celsius, degrees, number, periodText } from "./format";
import type { HeatPump, PiSensor } from "./types";
import { Card, Row } from "./ui";
@@ -37,7 +37,6 @@ export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | un
<Row label="Bufor CO" value={celsius(t.coBuffer)} />
<Row label="Bufor CO, cel" value={celsius(targets.co)} />
<Row label="Wyjście pompy" value={celsius(t.pumpOutlet)} />
{hp.heaterOn && <Row label="Grzałka" value={HEATER_SIGNAL} />}
</Group>
<Group title="Dolne źródło">
<Row label="Do pompy" value={celsius(t.groundIn)} />
+1 -5
View File
@@ -50,15 +50,11 @@ describe("Historia views", () => {
});
describe("Statystyki", () => {
it("shows the totals and the starts as a minimum, with the heater signal neutral", async () => {
it("shows the totals and the starts as a minimum", async () => {
await open();
expect(await screen.findByText("Załączenia sprężarki, co najmniej")).toBeInTheDocument();
expect(screen.getAllByText("0,2 h").length).toBeGreaterThan(0);
const tile = screen.getByText("Sygnał grzałki (niepodłączona)").closest("div")!;
expect(tile).toHaveTextContent("0,1 h");
expect(tile.className).not.toMatch(/alarm|run/);
expect(screen.queryByText("Prąd z grzałki jest drogi")).toBeNull();
expect(screen.queryByRole("status")).toBeNull();
});
-20
View File
@@ -174,24 +174,4 @@ describe("Overview", () => {
expect((await screen.findByRole("region", { name: "Teraz" })).children[0]).toHaveTextContent(/^Ciepła woda13,6 °Ccel 8,0 °C$/);
});
it("shows the heater as the controller's signal, without a warning or a power figure", async () => {
serveHeatPump({ ...fresh(example<HeatPump>("heat-pump")), heaterOn: true });
const Overview = await overview();
render(<Overview />);
expect(await screen.findByText("sprężarka, grzałka: sygnał sterownika (niepodłączona)")).toBeInTheDocument();
expect(screen.getByText("Grzałka").closest("section")).toHaveTextContent("sygnał sterownika (niepodłączona)");
expect(screen.queryByText(/Grzałka elektryczna/)).toBeNull();
expect(screen.queryByText(/0,9 kW/)).toBeNull();
});
it("says nothing about the heater while its output is off", async () => {
serveHeatPump(fresh(example("heat-pump")));
const Overview = await overview();
render(<Overview />);
expect(await screen.findByText("sprężarka")).toBeInTheDocument();
expect(screen.queryByText("Grzałka")).toBeNull();
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { celsius, HEATER_SIGNAL, hour, longDate, number, percent, periodText, power } from "./format";
import { celsius, hour, longDate, number, percent, periodText, power } from "./format";
import { Icon, type IconName } from "./icons";
import { flowing } from "./flow";
import { usePoll } from "./poll";
@@ -108,7 +108,7 @@ function Tile({ icon, title, detail, value, status }: {
function DeviceTiles({ hp }: { hp: HeatPump }) {
return (
<section aria-label="Stan urządzeń" className="grid gap-3 sm:grid-cols-2 sm:gap-4">
<Tile icon="fan" title="Pompa ciepła" detail={hp.heaterOn ? `sprężarka, grzałka: ${HEATER_SIGNAL}` : "sprężarka"}
<Tile icon="fan" title="Pompa ciepła" detail="sprężarka"
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)} />
<Tile icon="tank" title="Zbiornik CWU" detail={`cyrkulacja ${onOff(hp.pumps.circulation, "pracuje", "stoi")}`}
+3 -4
View File
@@ -28,9 +28,9 @@ export function takting(days: StatsDay[]) {
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
}
function Tile({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
function Tile({ label, value }: { label: string; value: string }) {
return (
<div className={`rounded-xl border border-edge bg-page p-3 ${wide ? "col-span-2 lg:col-span-1" : ""}`}>
<div className="rounded-xl border border-edge bg-page p-3">
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-soft">{label}</p>
</div>
@@ -40,12 +40,11 @@ function Tile({ label, value, wide = false }: { label: string; value: string; wi
function Totals({ stats }: { stats: StatsData }) {
const t = stats.totals;
return (
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-5">
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<Tile label="Sprężarka" value={hours(t.compressorHours)} />
<Tile label="Załączenia sprężarki, co najmniej" value={number(t.compressorStarts)} />
<Tile label="Ciepła woda (CWU)" value={hours(t.cwuHours)} />
<Tile label="Ogrzewanie (CO)" value={hours(t.coHours)} />
<Tile label="Sygnał grzałki (niepodłączona)" value={hours(t.heaterHours)} wide />
</div>
);
}
-3
View File
@@ -104,6 +104,3 @@ export function periodText(period: Period, programText: string | null): string |
if (period === "override") return programText ? `program: ${programText}` : "program inny niż tygodniowy";
return null;
}
/** The electric heater is not wired (owner, 2026-10-25): output 4115 is only the controller's signal. */
export const HEATER_SIGNAL = "sygnał sterownika (niepodłączona)";
-3
View File
@@ -16,7 +16,6 @@ export interface HeatPump {
stale: boolean;
state: PumpState | null;
compressorPercent: number | null;
heaterOn: boolean | null;
alarm: boolean;
alarmCode: number | null;
alarmText: string | null;
@@ -260,7 +259,6 @@ export interface StatsDay {
compressorStarts: number | null;
cwuHours: number | null;
coHours: number | null;
heaterHours: number | null;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;
@@ -271,7 +269,6 @@ export interface StatsTotals {
compressorStarts: number;
cwuHours: number;
coHours: number;
heaterHours: number;
avgOutdoorTemp: number | null;
minCwuTemp: number | null;
maxCwuTemp: number | null;