Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326db85114 | ||
|
|
ead74ebdec | ||
|
|
f99ae01626 | ||
|
|
a804923290 | ||
|
|
cc0495fc46 | ||
|
|
b3fb7f8b40 | ||
|
|
33aead7352 | ||
|
|
ee13c00bbb | ||
|
|
2e94cc1429 | ||
|
|
0895125e9c | ||
|
|
36bee5ca08 | ||
|
|
fa678473e3 | ||
|
|
b46d820fb2 | ||
|
|
f61c25f449 | ||
|
|
eec29666eb | ||
|
|
6d9e92ee22 | ||
|
|
bb22afcd35 | ||
|
|
ae9376b24d | ||
|
|
31b668f313 | ||
|
|
36875dd103 | ||
|
|
c8ada0e6a8 | ||
|
|
0e3a77047b | ||
|
|
93fedb198c |
No files matched your search
+12
-2
@@ -3,6 +3,7 @@
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy` (example `src/test/resources/api/energy.json`) is the house electricity now. `housePowerW` is the whole house by balance, because the Deye `load` registers cover only its backup output (`backupOutputW`): Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, the grid being the house meter's `total_power_active` when live, else the Deye `grid_power_w`; floored at 0, null when any of these readings is missing or stale. `today.houseKwh` is Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged (Deye day counters), null when any is missing.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
@@ -10,9 +11,16 @@ 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
|
||||
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
|
||||
`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`).
|
||||
|
||||
`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).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
@@ -126,7 +134,9 @@ needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `s
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
|
||||
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
|
||||
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
|
||||
@@ -19,6 +19,7 @@ import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
@@ -60,6 +61,7 @@ final class Backend {
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
new ProgramService(influx, clock),
|
||||
weather(influxSettings, clock), commands,
|
||||
Authentication.create(settings.auth(), clock));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
@@ -56,8 +57,11 @@ public class ApiServer {
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
/**
|
||||
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
|
||||
* preset, by setpoint name.
|
||||
*/
|
||||
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
@@ -88,7 +92,7 @@ public class ApiServer {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
|
||||
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
@@ -131,6 +135,7 @@ public class ApiServer {
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
route("/api/program", exchange -> handleProgram(exchange, program));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
@@ -314,14 +319,17 @@ public class ApiServer {
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
|
||||
user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (SetpointService.InvalidHoursException e) {
|
||||
send(exchange, 400, error("invalid_hours"));
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
@@ -410,6 +418,18 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("program unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
@@ -19,7 +22,9 @@ 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";
|
||||
}
|
||||
|
||||
@@ -38,14 +43,25 @@ 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)));
|
||||
number(row, 1), number(row, 2), number(row, 3),
|
||||
number(row, 4), number(row, 5), periods(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Periods periods(JsonNode row) {
|
||||
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) {
|
||||
Double value = number(row, column);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -69,7 +69,7 @@ public class EnergyDetailsService {
|
||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
||||
group("Odbiór domu",
|
||||
group("Wyjście zapasowe falownika",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
@@ -81,14 +81,14 @@ public class EnergyDetailsService {
|
||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||
group("Dziś",
|
||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
||||
field("load_today_kwh", "Zużycie domu", "kWh", 1),
|
||||
field("load_today_kwh", "Wyjście zapasowe", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Zużycie domu", "kWh", 0),
|
||||
field("load_total_kwh", "Wyjście zapasowe", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
|
||||
@@ -11,9 +11,20 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
/**
|
||||
* F7: house electricity from the Deye inverter, the Fronius PV inverter and the house meter.
|
||||
* The Deye "load" registers cover only its backup output, so the house is a balance:
|
||||
* <ul>
|
||||
* <li>now = Deye PV1 + PV2 + Fronius PV + Deye battery (+discharge, − charge) + grid, where grid is the
|
||||
* house meter's total active power when it is live, else the Deye's grid power; floored at 0, null when
|
||||
* any of those readings is missing or stale;</li>
|
||||
* <li>today = Deye PV + Fronius day energy + grid bought − grid sold + battery discharged − battery charged
|
||||
* (Deye day counters), null when any is missing.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public class EnergyService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
@@ -30,17 +41,42 @@ public class EnergyService {
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
Map<String, Double> meter = SnapshotFields.live(snapshot, "house-meter", "PRAD2", clock.instant()).fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), houseKwh(d, froniusKwh));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
housePowerW(d, f, meter.getOrDefault("total_power_active", d.get("grid_power_w"))),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private static Double housePowerW(Map<String, Double> deye, Map<String, Double> fronius, Double gridW) {
|
||||
Double pv1 = deye.get("pv1_power_w");
|
||||
Double pv2 = deye.get("pv2_power_w");
|
||||
Double pvFronius = fronius.get("pv_power_w");
|
||||
Double battery = deye.get("battery_power_w");
|
||||
if (Stream.of(pv1, pv2, pvFronius, battery, gridW).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, pv1 + pv2 + pvFronius + battery + gridW);
|
||||
}
|
||||
|
||||
private static Double houseKwh(Map<String, Double> deye, Double froniusKwh) {
|
||||
Double pv = deye.get("pv_today_kwh");
|
||||
Double bought = deye.get("grid_bought_today_kwh");
|
||||
Double sold = deye.get("grid_sold_today_kwh");
|
||||
Double discharged = deye.get("battery_discharge_today_kwh");
|
||||
Double charged = deye.get("battery_charge_today_kwh");
|
||||
if (Stream.of(pv, froniusKwh, bought, sold, discharged, charged).anyMatch(Objects::isNull)) {
|
||||
return null;
|
||||
}
|
||||
return round(Math.max(0, pv + froniusKwh + bought - sold + discharged - charged));
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
@@ -43,7 +44,11 @@ public class HeatPumpService {
|
||||
|
||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
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"),
|
||||
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")),
|
||||
@@ -51,10 +56,7 @@ public class HeatPumpService {
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp")),
|
||||
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import home.backend.domain.ProgramView.Weekday;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* F6: the weekly program as the history shows it, without asking the controller (its program
|
||||
* registers sit behind a shared selector, so reading them would mean writing). A transition between
|
||||
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
|
||||
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
|
||||
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
|
||||
* of time needs no special case. Today is left out: it is not over.
|
||||
*/
|
||||
public class ProgramService {
|
||||
|
||||
private static final int DEFAULT_DAYS = 14;
|
||||
private static final int MIN_DAYS = 7;
|
||||
private static final int MAX_DAYS = 30;
|
||||
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
|
||||
private static final int STEP_MINUTES = 5;
|
||||
private static final int MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
private record Span(int from, int to) {
|
||||
}
|
||||
|
||||
/** What the history says about one circuit: days with a known period and their comfort spans. */
|
||||
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
|
||||
}
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public ProgramService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param days how many past days to read, 7 to 30; null means 14
|
||||
* @throws IllegalArgumentException for anything else
|
||||
*/
|
||||
public ProgramView program(String days) throws IOException {
|
||||
int length = length(days);
|
||||
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
|
||||
List<HeatPumpSample> samples = repository.samples(
|
||||
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
|
||||
today.atStartOfDay(StatsService.ZONE).toInstant());
|
||||
Observed cwu = observe(samples, Periods::cwu);
|
||||
Observed room = observe(samples, Periods::room);
|
||||
List<Weekday> weekdays = new ArrayList<>();
|
||||
for (int day = 1; day <= 7; day++) {
|
||||
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
|
||||
}
|
||||
return new ProgramView(length, weekdays);
|
||||
}
|
||||
|
||||
private static int length(String days) {
|
||||
if (days == null) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
try {
|
||||
int length = Integer.parseInt(days);
|
||||
if (length >= MIN_DAYS && length <= MAX_DAYS) {
|
||||
return length;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below
|
||||
}
|
||||
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
|
||||
}
|
||||
|
||||
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
|
||||
Set<LocalDate> days = new TreeSet<>();
|
||||
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
|
||||
Period previous = null;
|
||||
Instant previousTime = null;
|
||||
Instant comfortStart = null;
|
||||
for (HeatPumpSample sample : samples) {
|
||||
Period period = circuit.apply(sample.periods());
|
||||
if (period != Period.COMFORT && period != Period.ECONOMY) {
|
||||
previous = null;
|
||||
comfortStart = null;
|
||||
continue;
|
||||
}
|
||||
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
|
||||
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
|
||||
if (!adjacent) {
|
||||
comfortStart = null;
|
||||
} else if (period != previous) {
|
||||
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
|
||||
if (period == Period.COMFORT) {
|
||||
comfortStart = boundary;
|
||||
} else if (comfortStart != null) {
|
||||
addSpans(comfort, comfortStart, boundary);
|
||||
comfortStart = null;
|
||||
}
|
||||
}
|
||||
previous = period;
|
||||
previousTime = sample.t();
|
||||
}
|
||||
return new Observed(days, comfort);
|
||||
}
|
||||
|
||||
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
|
||||
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
|
||||
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
|
||||
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
|
||||
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
|
||||
if (spanEnd > spanStart) {
|
||||
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime rounded(LocalDateTime time) {
|
||||
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
|
||||
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
|
||||
}
|
||||
|
||||
private static int minutes(LocalDateTime time) {
|
||||
return time.getHour() * 60 + time.getMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
|
||||
* least two days show it.
|
||||
*/
|
||||
private static Circuit circuit(Observed observed, int weekday) {
|
||||
List<List<Span>> patterns = observed.days().stream()
|
||||
.filter(date -> date.getDayOfWeek().getValue() == weekday)
|
||||
.map(date -> observed.comfort().getOrDefault(date, List.of()))
|
||||
.toList();
|
||||
if (patterns.isEmpty()) {
|
||||
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
|
||||
}
|
||||
List<Span> best = null;
|
||||
int bestCount = 0;
|
||||
for (List<Span> pattern : patterns) {
|
||||
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
|
||||
if (count >= bestCount) {
|
||||
best = pattern;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
|
||||
best.stream().map(ProgramService::interval).toList());
|
||||
}
|
||||
|
||||
/** Equal within one step at both ends of every span. */
|
||||
private static boolean agree(List<Span> a, List<Span> b) {
|
||||
if (a.size() != b.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|
||||
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Interval interval(Span span) {
|
||||
return new Interval(hhmm(span.from()), hhmm(span.to()));
|
||||
}
|
||||
|
||||
private static String hhmm(int minutes) {
|
||||
return String.format("%02d:%02d", minutes / 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,16 @@ import java.util.stream.Collectors;
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
|
||||
public static final int MAX_PRESET_HOURS = 4;
|
||||
|
||||
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
|
||||
public static class InvalidHoursException extends IllegalArgumentException {
|
||||
InvalidHoursException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
@@ -53,12 +63,15 @@ public class SetpointService {
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
|
||||
String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
@@ -68,7 +81,7 @@ public class SetpointService {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
@@ -87,6 +100,19 @@ public class SetpointService {
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static List<Change> withHours(List<Change> changes, Integer hours) {
|
||||
if (hours == null) {
|
||||
return changes;
|
||||
}
|
||||
if (hours < 1 || hours > MAX_PRESET_HOURS) {
|
||||
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
|
||||
}
|
||||
if (changes.stream().anyMatch(c -> c.hours() == null)) {
|
||||
throw new InvalidHoursException("this preset has no hours to choose");
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
|
||||
@@ -5,17 +5,18 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* − charge. {@code housePowerW} is the whole house by balance; {@code backupOutputW} only the Deye's
|
||||
* backup (EPS) output. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
|
||||
*/
|
||||
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
|
||||
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
|
||||
Double batteryPowerW, Double batterySocPct, Double housePowerW, Double backupOutputW, Today today) {
|
||||
|
||||
/** One inverter; time is null and stale true when the gateway has no reading of it. */
|
||||
public record Source(String name, Instant time, boolean stale) {
|
||||
}
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius, house is the energy balance. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double houseKwh) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
Double outdoorTemp, Double cwuTemp) {
|
||||
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co,
|
||||
Double outdoorTemp, Double cwuTemp, Periods periods) {
|
||||
}
|
||||
@@ -6,9 +6,9 @@ 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) {
|
||||
Targets targets, Setpoints setpoints, Periods periods) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/** Which period of the weekly program a circuit is in, read from its target (see {@link Periods}). */
|
||||
public enum Period {
|
||||
COMFORT, ECONOMY, UNKNOWN, OVERRIDE;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
|
||||
/**
|
||||
* The period of CWU and room, from the target the controller works to now (verified on history,
|
||||
* docs/frisko/history-2026-10.md). Only the weekly program (register 4226 = 1) switches between
|
||||
* economy and comfort; any other program overrides both. {@code programText} names such a program.
|
||||
*/
|
||||
public record Periods(Period cwu, Period room, Integer program, String programText) {
|
||||
|
||||
public static final Periods UNKNOWN = new Periods(Period.UNKNOWN, Period.UNKNOWN, null, null);
|
||||
private static final int WEEKLY = 1;
|
||||
private static final double SAME_TARGET = 0.05;
|
||||
|
||||
public static Periods of(Integer program, Targets targets, Setpoints setpoints) {
|
||||
if (program == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
return new Periods(
|
||||
period(program, targets.cwu(), setpoints.cwuEconomy(), setpoints.cwuComfort()),
|
||||
period(program, targets.room(), setpoints.coEconomy(), setpoints.coComfort()),
|
||||
program, program == WEEKLY ? null : programText(program));
|
||||
}
|
||||
|
||||
private static Period period(int program, Double target, Integer economy, Integer comfort) {
|
||||
if (program != WEEKLY) {
|
||||
return Period.OVERRIDE;
|
||||
}
|
||||
if (target == null || economy == null || comfort == null || economy.equals(comfort)) {
|
||||
return Period.UNKNOWN;
|
||||
}
|
||||
if (Math.abs(target - comfort) < SAME_TARGET) {
|
||||
return Period.COMFORT;
|
||||
}
|
||||
return Math.abs(target - economy) < SAME_TARGET ? Period.ECONOMY : Period.UNKNOWN;
|
||||
}
|
||||
|
||||
private static String programText(int program) {
|
||||
return switch (program) {
|
||||
case 0 -> "Ferie";
|
||||
case 2 -> "Party";
|
||||
case 3 -> "Rozruch";
|
||||
case 4 -> "Stop";
|
||||
case 5 -> "Dezynfekcja";
|
||||
default -> "Program " + program;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The weekly program as the history shows it (F6): per weekday the comfort intervals of CWU and room.
|
||||
* Nothing is read from the controller's program registers. {@code weekdays} runs Monday (1) to Sunday (7).
|
||||
*/
|
||||
public record ProgramView(int days, List<Weekday> weekdays) {
|
||||
|
||||
public record Weekday(int day, Circuit cwu, Circuit room) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code observedDays}: days of this weekday with known periods; {@code agreeingDays}: how many of them
|
||||
* show {@code comfort} (within 5 minutes).
|
||||
*/
|
||||
public record Circuit(Confidence confidence, int observedDays, int agreeingDays, List<Interval> comfort) {
|
||||
}
|
||||
|
||||
/** Local times "HH:MM"; an interval that runs to midnight ends at "24:00". */
|
||||
public record Interval(String from, String to) {
|
||||
}
|
||||
|
||||
public enum Confidence {
|
||||
CONFIRMED, SEEN_ONCE, NO_DATA;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
@@ -39,6 +40,9 @@ import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -53,6 +57,7 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
@@ -116,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),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||
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;
|
||||
@@ -206,6 +211,7 @@ class ApiServerTest {
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new ProgramService((from, to) -> samples.samples(from, to), clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(from, to) -> exampleMeasuredPv(), clock),
|
||||
() -> {
|
||||
@@ -250,10 +256,10 @@ 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));
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
@@ -274,7 +280,7 @@ class ApiServerTest {
|
||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
"frequency", 50.01, "import_energy_active", 67514.27, "total_power_active", 388.78), energyTime))),
|
||||
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
@@ -528,6 +534,38 @@ class ApiServerTest {
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programMatchesExample() throws Exception {
|
||||
// Friday 2026-10-23, CEST: CWU comfort from 11:25 (09:25 UTC) to 12:05.
|
||||
Periods economy = Periods.of(1, new Targets(18.0, 31.0, 8.0), new Setpoints(18, 19, 8, 43));
|
||||
Periods comfort = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 8, 43));
|
||||
samples = (from, to) -> {
|
||||
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,
|
||||
minute >= 25 && minute <= 60 ? comfort : economy));
|
||||
}
|
||||
return polls;
|
||||
};
|
||||
|
||||
assertMatchesExample("/api/program?days=7", "program");
|
||||
}
|
||||
|
||||
@Test
|
||||
void programNeedsDaysFromSevenToThirty() throws Exception {
|
||||
assertEquals(200, get("/api/program").statusCode());
|
||||
assertEquals(400, get("/api/program?days=3").statusCode());
|
||||
assertEquals(400, get("/api/program?days=x").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programIs502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/program").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
@@ -825,6 +863,25 @@ class ApiServerTest {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timedPresetTakesOwnHoursAndValue() throws Exception {
|
||||
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidHoursAre400AndSendNothing() throws Exception {
|
||||
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
|
||||
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
|
||||
}
|
||||
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
|
||||
assertEquals(400, held.statusCode());
|
||||
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
|
||||
@@ -2,6 +2,8 @@ package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -216,13 +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),
|
||||
samples.get(1));
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||
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, null, null, Periods.UNKNOWN),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ProgramServiceTest {
|
||||
|
||||
/** Tuesday 2026-10-27 12:00 in Warsaw; the Sunday before it, 10-25, is the day of the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-27T11:00:00Z");
|
||||
private static final int POLL_MINUTES = 5;
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private ProgramView program(String days, List<HeatPumpSample> samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return samples;
|
||||
};
|
||||
return new ProgramService(repository, Clock.fixed(NOW, ZoneOffset.UTC)).program(days);
|
||||
}
|
||||
|
||||
private static HeatPumpSample poll(String utc, Periods 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. */
|
||||
private static Periods weekly(double cwuTarget, double roomTarget) {
|
||||
return program(1, cwuTarget, roomTarget, 8, 43);
|
||||
}
|
||||
|
||||
private static Periods program(int program, double cwuTarget, double roomTarget, int cwuEconomy, int cwuComfort) {
|
||||
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget),
|
||||
new Setpoints(18, 19, cwuEconomy, cwuComfort));
|
||||
}
|
||||
|
||||
private static Periods economy() {
|
||||
return weekly(8.0, 18.0);
|
||||
}
|
||||
|
||||
private static Periods cwuComfort() {
|
||||
return weekly(43.0, 18.0);
|
||||
}
|
||||
|
||||
/** Polls every 5 minutes from {@code from} to {@code to} (UTC, "2026-10-19T09:00"), all of them {@code periods}. */
|
||||
private static List<HeatPumpSample> polls(String from, String to, Periods periods) {
|
||||
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, periods));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static List<HeatPumpSample> concat(List<HeatPumpSample>... parts) {
|
||||
return Arrays.stream(parts).flatMap(List::stream).toList();
|
||||
}
|
||||
|
||||
/** "2026-10-19T10:25" for a local time of that date, given the offset from UTC. */
|
||||
private static String utc(String date, String local, int offsetHours, int plusMinutes) {
|
||||
int total = Integer.parseInt(local.substring(0, 2)) * 60 + Integer.parseInt(local.substring(3))
|
||||
- offsetHours * 60 + plusMinutes;
|
||||
return String.format("%sT%02d:%02d", date, total / 60, total % 60);
|
||||
}
|
||||
|
||||
/** CWU economy, then comfort from {@code from} to {@code to} (local), then economy, polled every 5 minutes. */
|
||||
private static List<HeatPumpSample> cwuDay(String date, String from, String to, int offsetHours) {
|
||||
return concat(
|
||||
polls(utc(date, from, offsetHours, -30), utc(date, from, offsetHours, -5), economy()),
|
||||
polls(utc(date, from, offsetHours, 0), utc(date, to, offsetHours, -5), cwuComfort()),
|
||||
polls(utc(date, to, offsetHours, 0), utc(date, to, offsetHours, 30), economy()));
|
||||
}
|
||||
|
||||
private static Circuit monday(ProgramView view) {
|
||||
return view.weekdays().get(0).cwu();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysBeforeToday() throws IOException {
|
||||
ProgramView view = program("14", List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(Instant.parse("2026-10-12T22:00:00Z"), askedFrom);
|
||||
assertEquals(Instant.parse("2026-10-26T23:00:00Z"), askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToFourteenDaysAndListsMondayToSunday() throws IOException {
|
||||
ProgramView view = program(null, List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), view.weekdays().stream().map(ProgramView.Weekday::day).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDaysOutsideSevenToThirty() {
|
||||
for (String days : new String[] {"6", "31", "abc", ""}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> program(days, List.of()), days);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesNoDataForEveryWeekday() throws IOException {
|
||||
Circuit circuit = monday(program("14", List.of()));
|
||||
|
||||
assertEquals(Confidence.NO_DATA, circuit.confidence());
|
||||
assertEquals(0, circuit.observedDays());
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBoundaryIsHalfwayBetweenTheTwoPollsRoundedToFiveMinutes() throws IOException {
|
||||
// Economy at 11:23:48 local (CEST), comfort at 11:28:48: the change was about 11:26, so 11:25.
|
||||
// Comfort at 12:04:56, economy at 12:09:56: about 12:07, so 12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
List.of(poll("2026-10-19T09:23:48Z", economy()), poll("2026-10-19T09:28:48Z", cwuComfort())),
|
||||
polls("2026-10-19T09:33", "2026-10-19T10:00", cwuComfort()),
|
||||
List.of(poll("2026-10-19T10:04:56Z", cwuComfort()), poll("2026-10-19T10:09:56Z", economy())));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIntervalsOnTwoMondaysWithinFiveMinutesAreConfirmed() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "11:30", "12:05", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(2, circuit.agreeingDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentIntervalsOnTwoMondaysAreSeenOnceAndTheLatestIsShown() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(1, circuit.agreeingDays());
|
||||
assertEquals(List.of(new Interval("13:00", "14:00")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMajorityPatternWinsOverOneOddDay() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-12", "11:25", "12:05", 2), cwuDay("2026-10-19", "11:25", "12:05", 2),
|
||||
cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("30", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDayWithTheChangeToWinterTimeKeepsLocalClockTimes() throws IOException {
|
||||
// Sunday 2026-10-18 is CEST (UTC+2), Sunday 2026-10-25 CET (UTC+1): the same local 11:25-12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-18", "11:25", "12:05", 2), cwuDay("2026-10-25", "11:25", "12:05", 1));
|
||||
|
||||
Circuit sunday = program("14", samples).weekdays().get(6).cwu();
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, sunday.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), sunday.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIntervalAcrossMidnightIsSplitOverBothDays() throws IOException {
|
||||
// Comfort from 22:55 local Sunday to 01:05 local Monday (CET: UTC+1).
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-11-01T21:50", "2026-11-01T21:50", economy()),
|
||||
polls("2026-11-01T21:55", "2026-11-02T00:00", cwuComfort()),
|
||||
polls("2026-11-02T00:05", "2026-11-02T00:10", economy()));
|
||||
Clock later = Clock.fixed(Instant.parse("2026-11-04T11:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
ProgramView view = new ProgramService((from, to) -> samples, later).program("14");
|
||||
|
||||
assertEquals(List.of(new Interval("22:55", "24:00")), view.weekdays().get(6).cwu().comfort());
|
||||
assertEquals(List.of(new Interval("00:00", "01:05")), view.weekdays().get(0).cwu().comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ourOwnWritesLeaveNoComfortIntervalButTheDayStaysObserved() throws IOException {
|
||||
// Economy raised 8 -> 45 -> 43 (equal to comfort, so the periods are ambiguous) -> 8, as in the
|
||||
// 2026-10-24 tests, all during the economy period.
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T11:00:00Z", program(1, 8.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T11:05:00Z", program(1, 45.0, 18.0, 45, 43)),
|
||||
poll("2026-10-19T11:10:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:15:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:20:00Z", program(1, 8.0, 18.0, 8, 43)));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWriteInsideComfortDropsTheIntervalInsteadOfGuessingItsEnd() throws IOException {
|
||||
// Economy written 8 -> 43 inside the comfort period makes the CWU periods ambiguous for two polls.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", economy()),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:05", cwuComfort()),
|
||||
polls("2026-10-19T10:10", "2026-10-19T10:15", program(1, 43.0, 18.0, 43, 43)),
|
||||
polls("2026-10-19T10:20", "2026-10-19T10:55", cwuComfort()),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:05", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherProgramBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:05:00Z", program(2, 43.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T10:10:00Z", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapInTheDataBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:40:00Z", economy()));
|
||||
|
||||
assertEquals(List.of(), monday(program("14", samples)).comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomIsReadFromItsOwnTargets() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", weekly(8.0, 18.0)),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:55", weekly(8.0, 19.0)),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:00", weekly(8.0, 18.0)));
|
||||
|
||||
ProgramView view = program("14", samples);
|
||||
|
||||
assertEquals(List.of(), monday(view).comfort());
|
||||
assertEquals(List.of(new Interval("12:00", "13:00")), view.weekdays().get(0).room().comfort());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
@@ -71,14 +72,39 @@ class ServicesTest {
|
||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0, "target_room_temp", 18.0, "setpoint_co_economy", 18.0,
|
||||
"setpoint_co_comfort", 19.0));
|
||||
|
||||
assertEquals(Period.COMFORT, view.periods().cwu());
|
||||
assertEquals(Period.ECONOMY, view.periods().room());
|
||||
assertNull(view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpOtherProgramOverridesBothPeriodsAndIsNamed() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 2.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0));
|
||||
|
||||
assertEquals(Period.OVERRIDE, view.periods().cwu());
|
||||
assertEquals(Period.OVERRIDE, view.periods().room());
|
||||
assertEquals("Party", view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutProgramHasUnknownPeriods() throws IOException {
|
||||
assertEquals(Period.UNKNOWN, heatPump(Map.of("target_cwu_temp", 43.0)).periods().cwu());
|
||||
}
|
||||
|
||||
@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());
|
||||
@@ -402,7 +428,8 @@ class ServicesTest {
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
assertNull(view.housePowerW());
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -415,6 +442,82 @@ class ServicesTest {
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static Map<String, Double> deye(double pv1, double pv2, double battery, double grid) {
|
||||
return Map.of("pv1_power_w", pv1, "pv2_power_w", pv2, "battery_power_w", battery, "grid_power_w", grid,
|
||||
"load_power_w", 0.0);
|
||||
}
|
||||
|
||||
private static EnergyView house(Map<String, Double> deye, double froniusW, double meterW) throws IOException {
|
||||
DeviceSnapshot meter = device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("total_power_active", meterW));
|
||||
return energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", froniusW)), meter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerBalancesPvBatteryAndMeterWhileExporting() throws IOException {
|
||||
EnergyView view = house(deye(2638.0, 3000.0, -15.0, -5768.0), 444.0, -5762.0);
|
||||
|
||||
assertEquals(305.0, view.housePowerW());
|
||||
assertEquals(0.0, view.backupOutputW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerAtNightIsImportPlusBatteryDischarge() throws IOException {
|
||||
EnergyView view = house(deye(0.0, 0.0, 800.0, 1000.0), 0.0, 1000.0);
|
||||
|
||||
assertEquals(1800.0, view.housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerIsNeverNegative() throws IOException {
|
||||
assertEquals(0.0, house(deye(0.0, 0.0, -500.0, 100.0), 0.0, 100.0).housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerFallsBackToDeyeGridWhenTheMeterIsStale() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var meter = new DeviceSnapshot("house-meter", true, old, null, List.of(new Measurement("electricity",
|
||||
"PRAD2", Map.of("total_power_active", 9999.0), old)));
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye(1000.0, 0.0, 0.0, -600.0)),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0)), meter);
|
||||
|
||||
assertEquals(600.0, view.housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void housePowerIsNullWhenASourceIsMissing() throws IOException {
|
||||
Map<String, Double> deye = deye(1000.0, 0.0, 0.0, -600.0);
|
||||
assertNull(energy(device("deye", "home", "deye", deye)).housePowerW());
|
||||
assertNull(energy(device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
|
||||
var noGrid = Map.of("pv1_power_w", 1.0, "pv2_power_w", 1.0, "battery_power_w", 1.0);
|
||||
assertNull(energy(device("deye", "home", "deye", noGrid),
|
||||
device("fronius", "home", "fronius", Map.of("pv_power_w", 200.0))).housePowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void houseTodayIsPvPlusNetGridPlusNetBattery() throws IOException {
|
||||
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
|
||||
"battery_discharge_today_kwh", 5.0, "battery_charge_today_kwh", 2.1);
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
|
||||
|
||||
assertEquals(24.8, view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void houseTodayIsNullWhenACounterIsMissing() throws IOException {
|
||||
var deye = Map.of("pv_today_kwh", 29.9, "grid_bought_today_kwh", 4.4, "grid_sold_today_kwh", 16.8,
|
||||
"battery_discharge_today_kwh", 5.0);
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", deye),
|
||||
device("fronius", "home", "fronius", Map.of("energy_day_wh", 4400.0)));
|
||||
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
@@ -506,8 +609,9 @@ class ServicesTest {
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.batteryPowerW());
|
||||
assertNull(view.batterySocPct());
|
||||
assertNull(view.loadPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
assertNull(view.housePowerW());
|
||||
assertNull(view.backupOutputW());
|
||||
assertNull(view.today().houseKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -127,15 +127,38 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
|
||||
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
|
||||
for (int hours : List.of(0, 5, -1, 72)) {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetHasNoHoursToChoose() {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
@@ -144,7 +167,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
service.applyPreset("away", RETURN, null, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
@@ -152,7 +175,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
@@ -160,7 +183,7 @@ class SetpointServiceTest {
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -168,14 +191,14 @@ class SetpointServiceTest {
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -184,8 +207,8 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -194,7 +217,7 @@ class SetpointServiceTest {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -204,7 +227,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -213,7 +236,7 @@ class SetpointServiceTest {
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -225,7 +248,7 @@ class SetpointServiceTest {
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -239,7 +262,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -250,7 +273,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -260,7 +283,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -270,7 +293,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -280,7 +303,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -292,7 +315,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -35,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);
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, null, null, Periods.UNKNOWN);
|
||||
}
|
||||
|
||||
private static Day day(StatsView view, String date) {
|
||||
@@ -158,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),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
|
||||
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());
|
||||
@@ -175,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),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
|
||||
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());
|
||||
@@ -190,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),
|
||||
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());
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class PeriodsTest {
|
||||
|
||||
/** CWU economy 8 and comfort 43, room economy 18 and comfort 19. */
|
||||
private static Periods of(Integer program, double cwuTarget, double roomTarget) {
|
||||
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget), new Setpoints(18, 19, 8, 43));
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetEqualToAComfortSetpointIsComfort() {
|
||||
Periods periods = of(1, 43.0, 19.0);
|
||||
|
||||
assertEquals(Period.COMFORT, periods.cwu());
|
||||
assertEquals(Period.COMFORT, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetEqualToAnEconomySetpointIsEconomy() {
|
||||
Periods periods = of(1, 8.0, 18.0);
|
||||
|
||||
assertEquals(Period.ECONOMY, periods.cwu());
|
||||
assertEquals(Period.ECONOMY, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetBetweenTheSetpointsIsUnknown() {
|
||||
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).cwu());
|
||||
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalSetpointsAreUnknownBecauseTheTargetIsTheSameEitherWay() {
|
||||
Periods periods = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 43, 43));
|
||||
|
||||
assertEquals(Period.UNKNOWN, periods.cwu());
|
||||
assertEquals(Period.ECONOMY, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProgramOtherThanWeeklyOverridesAndIsNamed() {
|
||||
String[] names = {"Ferie", null, "Party", "Rozruch", "Stop", "Dezynfekcja"};
|
||||
for (int program = 0; program < names.length; program++) {
|
||||
if (program != 1) {
|
||||
Periods periods = of(program, 43.0, 19.0);
|
||||
assertEquals(Period.OVERRIDE, periods.cwu());
|
||||
assertEquals(Period.OVERRIDE, periods.room());
|
||||
assertEquals(names[program], periods.programText());
|
||||
}
|
||||
}
|
||||
assertEquals("Program 9", of(9, 8.0, 18.0).programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void weeklyProgramHasNoText() {
|
||||
assertNull(of(1, 8.0, 18.0).programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingValuesAreUnknown() {
|
||||
assertEquals(Periods.UNKNOWN, Periods.of(null, new Targets(null, null, null), new Setpoints(null, null, null, null)));
|
||||
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, null), new Setpoints(18, 19, 8, 43)).cwu());
|
||||
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, 8.0), new Setpoints(null, null, 8, null)).cwu());
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodSerialisesAsLowerCaseId() {
|
||||
assertEquals("comfort", Period.COMFORT.id());
|
||||
assertEquals("override", Period.OVERRIDE.id());
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "title": "Wyjście zapasowe falownika", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
@@ -22,7 +22,7 @@
|
||||
] },
|
||||
{ "title": "Dziś", "items": [
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Wyjście zapasowe", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
|
||||
@@ -49,6 +49,7 @@
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Razem", "value": 389.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"gridPowerW": null,
|
||||
"batteryPowerW": null,
|
||||
"batterySocPct": null,
|
||||
"loadPowerW": null,
|
||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
|
||||
"housePowerW": null,
|
||||
"backupOutputW": null,
|
||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "houseKwh": null }
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"gridPowerW": 645.0,
|
||||
"batteryPowerW": -24.0,
|
||||
"batterySocPct": 99.0,
|
||||
"loadPowerW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
|
||||
"housePowerW": 364.78,
|
||||
"backupOutputW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "houseKwh": 28.4 }
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"stale": true,
|
||||
"state": null,
|
||||
"compressorPercent": null,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"alarmCode": null,
|
||||
"alarmText": null,
|
||||
@@ -13,5 +12,6 @@
|
||||
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
||||
},
|
||||
"targets": { "room": null, "co": null, "cwu": null },
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null },
|
||||
"periods": { "cwu": "unknown", "room": "unknown", "program": null, "programText": null }
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"stale": false,
|
||||
"state": "IDLE",
|
||||
"compressorPercent": 0,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"alarmCode": null,
|
||||
"alarmText": null,
|
||||
@@ -13,5 +12,6 @@
|
||||
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
||||
},
|
||||
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 },
|
||||
"periods": { "cwu": "economy", "room": "economy", "program": 1, "programText": null }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "hours": 3, "values": { "cwu_economy": 47 } }
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"days": 7,
|
||||
"weekdays": [
|
||||
{ "day": 1, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 2, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 3, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 4, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 5, "cwu": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1,
|
||||
"comfort": [ { "from": "11:25", "to": "12:05" } ] },
|
||||
"room": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1, "comfort": [] } },
|
||||
{ "day": 6, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 7, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } }
|
||||
]
|
||||
}
|
||||
@@ -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 @@
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":1.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,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,6 +1,6 @@
|
||||
# ADR-009: Web UI reachable from Krosno only, without a login for now
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
Status: superseded by ADR-016 · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR-016: Web UI reachable from the internet behind the sign-in
|
||||
|
||||
Status: accepted · 2026-10-24 · supersedes ADR-009
|
||||
|
||||
## Context
|
||||
|
||||
ADR-009 kept the UI on the Krosno network without a login. ADR-014 added a sign-in (local
|
||||
accounts, the first one created in the app, and Keycloak realm `home`). My father should see
|
||||
and change the house from anywhere, e.g. while away (ADR-012).
|
||||
|
||||
## Decision
|
||||
|
||||
- Traefik no longer filters by address for the app: every `/api/` call needs a session (ADR-014),
|
||||
`/api/live` and the static UI excepted.
|
||||
- Only `/api/auth/setup` keeps an allowlist (the Krosno ISP pools, the WireGuard range and the
|
||||
Kraków LAN), on its own router with a higher priority. Setup is open only while no account
|
||||
exists, so a lost users file must not hand the house to the first visitor.
|
||||
- Keycloak realm `home` has self-registration off; only accounts added by hand can sign in, so
|
||||
no role is required (the backend logs that every realm user may sign in).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Path variants of `/api/auth/setup` (encoded, doubled or dotted segments, suffixes) were probed
|
||||
from outside the allowlist on 2026-10-24: Traefik answers 403 or the backend 404; none reaches
|
||||
setup.
|
||||
- Login throttling (5 failures per user and per address in 15 min) now faces the internet; anyone
|
||||
who knows a username can lock it for 15 minutes. Keycloak stays as the other way in.
|
||||
- The routers live in homelab-compose (`home-pump/compose.yaml`), not in this repository.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Frisko RX910-SE21: what history shows (2026-10)
|
||||
|
||||
Sources, all read-only: `Dom_Pompa.heat_pump` (2026-10-24, 167 samples, 5 min), legacy `POMPA`
|
||||
(8 temperatures, 5 min, Feb-Oct 2026), `electricity.PRAD2` (house meter, 1 min), `setpoint_audit`.
|
||||
Times are Europe/Warsaw; 2026-10-24 was CEST (DST ended 2026-10-25 03:00). Sampling gives +-5 min
|
||||
unless a meter step is quoted (+-1 min). Only one day of `heat_pump` exists so far.
|
||||
|
||||
## Period detection (economy or comfort)
|
||||
|
||||
Verified from history, 2026-10-24, 167 samples. Rule: compare the target with the two setpoints.
|
||||
|
||||
| Circuit | Target / economy / comfort | Result |
|
||||
|---|---|---|
|
||||
| CWU | 4203 vs 4143 / 4144 (8 / 43) | 133 economy, 27 comfort, 0 neither outside our writes |
|
||||
| Room | 4201 vs 4131 / 4132 (18 / 19) | 130 economy, 30 comfort, 0 neither |
|
||||
|
||||
- During our 4143 writes (13:20:19 to 13:53:33: 45, 47, 43, 8) the CWU rule is wrong: target = the
|
||||
economy value we wrote; once economy = 43 = comfort (13:29:47 on) it is ambiguous (5 samples).
|
||||
Exclude samples within a few minutes after a `setpoint_audit` write, or report "unknown".
|
||||
- Ambiguity only occurs when two setpoints are equal; the target is then the same either way.
|
||||
- "Neither" never happened (no Party, Ferie, disinfection: 4226 = 1 on every sample).
|
||||
- 4226 = 1 all day with the weekly program active: the PDF numbering (1 = Tygodn) holds.
|
||||
- Target changes at the period boundary within one poll; the first sample after it already shows it.
|
||||
|
||||
## Weekly program (hypothesis from one observed day plus 7 weeks of cycles)
|
||||
|
||||
2026-10-24 (Saturday), from `heat_pump`: CWU target 43 in 11:25-~12:05 and 17:55-~19:10, else 8.
|
||||
|
||||
| Circuit | Comfort start | Comfort end | Evidence |
|
||||
|---|---|---|---|
|
||||
| CWU | 11:25 (11:23:48..11:28:48) | ~12:05 (12:04:56..12:09:56) | 4203; meter step +1.4 kW 11:24:55 |
|
||||
| CWU | 17:55 (17:53:23..17:58:23) | ~19:10 (19:08:50..19:13:50) | 4203; meter step +1.7 kW 17:55:23 |
|
||||
| Room | ~12:07 (12:04:56..12:09:56) | ~14:55 (14:52:53..14:57:53) | 4201 = 19 vs 18 |
|
||||
|
||||
- Legacy `POMPA`, 2026-09-10..10-24: compressor cycles begin at 11:25 and 17:55 on all seven weekdays
|
||||
(43 morning, 33 evening cycles; the evening one is missing only when the tank is already warm).
|
||||
Strong evidence that CWU comfort is the same every day. Not read from the controller (4169-4176).
|
||||
- Unknown: room intervals on other days (CO never ran on 10-24, `output_co` = 0 all day); a possible
|
||||
third interval; Party/Ferie use. Reading 4167-4177 needs the shared selector.
|
||||
- The 5-minute "11:30" and "18:00" in older months are a sampling-phase artefact (first poll after start).
|
||||
|
||||
## The controller clock does not follow DST (verified from history)
|
||||
|
||||
- Cycles began at 11:30 / 18:00 until 2026-03-28; from 03-29 (DST start) at 12:30 / 19:00 local, i.e.
|
||||
one hour late, until 2026-04-06; from 04-07 back at 11:30 / 18:00. So the clock stayed on CET after
|
||||
the change and was set by hand on about 04-06.
|
||||
- Prediction (hypothesis, testable on 2026-10-26): the controller still runs CEST after 10-25 03:00,
|
||||
so comfort periods start one hour early in local time (about 10:25 and 16:55) until the clock is set.
|
||||
The app should not assume program times are local civil time.
|
||||
|
||||
## Heater signal (output_heater 4115, zb_state 4228)
|
||||
|
||||
The electric CWU heater is NOT physically connected (owner, 2026-10-25). Output 4115 = 100 and
|
||||
`zb_state` 2 only mean the controller's heater signal after 20 min of a CWU cycle; nothing is heated
|
||||
by it. Below, "heater" means that signal. The meter step at +20 min is the compressor's second stage
|
||||
(verified 2026-10-25): on 10-24 at 18:14:13 stage 4114 read 33, at 18:19:13 66; the meter rose at
|
||||
18:15 by ~940 W, split evenly over the phases (+330 / +300 / +310 W), a three-phase load, not a
|
||||
single-phase heater. The controller raises the stage and the heater signal together.
|
||||
|
||||
Episodes in `heat_pump` (10-24); meter steps from `electricity.PRAD2` `total_power_active`.
|
||||
|
||||
| Episode | On (sample window) | Off | Preceding CWU run | State at start |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 11:43:48..11:48:48 | 12:04:56..12:09:56 (period end) | compressor since 11:24:55, ~20-24 min | stage 33 → 66, cwu 28 / target 43, outlet 39, outdoor 11 |
|
||||
| 2 | 18:14:13..18:19:13 (meter 18:15:13, +937 W) | ~18:20 (meter -3.7 kW) | compressor since 17:55:23, 19.8 min | stage 33 → 66, cwu 40.6 / 43, outlet 46 then 51.7 |
|
||||
|
||||
Verified from history:
|
||||
- The heater joins 20.0 min (+-0.1) after the compressor starts a CWU cycle, if the cycle is still on.
|
||||
Meter: nine evenings (10-09, 10, 15, 16, 17, 19, 22, 23, 24) show a step of 665-961 W (mean ~890 W,
|
||||
about 0.3 kW per phase) at +20.0 min. The five evenings whose cycle ended after 12-15 min (10-02, 03,
|
||||
04, 06, 21) show none. Mornings 10-06 and 10-23 also show +640 and +756 W at +20 min (PV noise
|
||||
makes mornings unreliable). Matches 4147 = 20.
|
||||
- It is not triggered by the outlet limit: at heater start the outlet was 39 (morning) and 46 (evening),
|
||||
below TmaxPc 52. Not by CO (`output_co` 0), disinfection (4226 = 1), alarm (0), pressure switch (0)
|
||||
or outdoor temperature (4.6-11.9 C). The cwu temperature differs by 12 C between the two starts.
|
||||
- `zb_state` = 2 in all 6 samples with `output_heater` = 100 and 0 otherwise: it is the CWU heater ("zbCW").
|
||||
- The heater ends with the compressor: at the period end (episode 1) or at the TmaxPc stop (episode 2:
|
||||
outlet 51.7, compressor and heater off, outlet 42.1 five minutes later, cwu 42.2 below target).
|
||||
The compressor restarts ~18:25:30 (stage 33) without the heater (not 20 min) and stops at 18:30 when
|
||||
cwu reaches 43.0. Evening heater runs last 1-10 min (meter), ending at the outlet limit.
|
||||
- The 0.67-0.96 kW step is stage 2 (66 %) over stage 1 (33 %). Over the house base (~1.0 kW on 10-24
|
||||
evening) the pump draws +1.9..2.5 kW at stage 1 (rising as the outlet warms) and +3.4..3.7 kW at
|
||||
stage 2. It lasts until the compressor stops.
|
||||
- At an outlet of 52 C (TmaxPc 4158) the controller stops the compressor because the outlet is too hot
|
||||
(owner, 2026-10-25; seen 18:19-18:20 at 51.7 C); it restarts at stage 1 once the outlet has cooled.
|
||||
|
||||
Hypothesis: the signal delay is measured from the compressor start of a CWU request, not from a
|
||||
temperature condition. A cycle that finishes within 20 min never raises it.
|
||||
|
||||
## CWU heating cycles (legacy POMPA, 2026-09-10..10-24, outlet and cwu rise)
|
||||
|
||||
- Start: 11:25 (43 cycles) and 17:55 (33 cycles), meter-confirmed: they begin with the comfort period.
|
||||
- Duration, 5-min resolution: morning median 35 min (15-50), evening median 25 min (15-50).
|
||||
- Start cwu: morning 13-36 C (median 26), evening 18-44 C (median 36). Cold starts run 40-50 min.
|
||||
- End cwu: 43-44.2 C when the target is reached (overshoot <= 1.2 C), observed 25 evening and 21
|
||||
morning times. Other morning cycles end at 38.7-42 C because comfort ends at ~12:05.
|
||||
- Heating continues to the target, not for a fixed time. The sensor keeps rising 0.5-0.7 C for 5-10 min
|
||||
after the compressor stops (stratification): do not read the end of a cycle from cwu alone.
|
||||
- `output_cwu` stays 100 for 5-10 min after the compressor stops; `pump_mode` can read 0 while the
|
||||
compressor runs (10-24 13:36, 18:24, 18:29). Do not use 4227 alone to show "heating CWU".
|
||||
- Start condition is unclear: cycles began at cwu 40.8-42.8 C (10-02, 03, 04, 06) and a test at 41.4 vs
|
||||
target 45 started within 5 min, yet 10-19/20 mornings (38.2, 39.2 C) and 10-20 evening (38.9 C)
|
||||
had none. HistCWU = 5 does not explain both; not resolved.
|
||||
- No restart inside a comfort period was seen (cwu stayed above 42.7 until the period ended).
|
||||
- The 10-24 13:21-13:45 cycle (cwu 41.4 to 43.2) was caused by our test writes, not by the program.
|
||||
@@ -24,9 +24,11 @@ unless marked otherwise.
|
||||
| Temperatures | 4065 outdoor, 4066 indoor, 4067 CO/buffer, 4069 CWU, 4070/4071 ground in/out, 4072 pump outlet, 4199 avg outdoor | X10 |
|
||||
| Targets now | 4201 room, 4202 CO supply, 4203 CWU | X10; show economy vs comfort period |
|
||||
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | |
|
||||
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater, 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
|
||||
| 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 delay = 20 | read 2026-10-23 |
|
||||
| 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) = 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).
|
||||
|
||||
@@ -44,6 +46,11 @@ the Frisko app. Party mode (4140 = 2, hours in 4142) is a simpler "heat now" ove
|
||||
Slow morning rises with the pump off come from the solar collector.
|
||||
- The pump outlet tops out at ~52 °C (TmaxPc), so the pump alone reaches ~47–48 °C in the tank.
|
||||
Higher CWU values in history occur only in summer (solar).
|
||||
- Setting CWU above what the pump reaches risks the electric heater (after the delay) and
|
||||
- Setting CWU above what the pump reaches raises the controller's heater signal (after the delay; the heater is not connected) and risks
|
||||
pressure-switch trips; three trips in one cycle latch a fault until manual reset (MR208-PC+ docs).
|
||||
- Frisko phone app: direct Modbus TCP, LAN only, 200–300 ms between requests, 5 s timeout.
|
||||
- History analysis of 2026-10 ([history-2026-10.md](history-2026-10.md)): the heater signal comes 20 min into a
|
||||
CWU cycle (4147), `zb_state` 2 = that signal. The heater is not connected (owner, 2026-10-25); at the
|
||||
same moment the compressor goes from stage 1 to 2 (the +0.9 kW meter step, three-phase); at an outlet
|
||||
of 52 °C (TmaxPc) it stops; 4227 is unreliable for "heating CWU"; the controller clock
|
||||
does not follow DST; period = target compared with the setpoints, valid only with 4226 = 1.
|
||||
+1
-1
@@ -38,7 +38,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
| Milestone | Scope | Done when |
|
||||
|---|---|---|
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, sign-in (ADR-014), on the internet since v2.0.0 (ADR-016). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M4b Battery settings | F8 (ADR-013): Time of Use table and backup reserve from the Bateria view, FC16 with read-back, refused on stale or changed state; on branch/master and off until `deyeCommands.enabled` (gateway/README.md: Enabling battery commands) | Father changes the battery program from the app; first live write watched |
|
||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||
|
||||
+5
-4
@@ -74,10 +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).
|
||||
- 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
|
||||
@@ -92,7 +92,8 @@ Functional:
|
||||
through the same checks.
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- F6 Show the weekly program as the history shows it (comfort intervals per weekday, from the polls; nothing is
|
||||
asked of the controller, since reading its program needs selector writes). Editing it stays out.
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
|
||||
|
||||
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, number } from "./format";
|
||||
import { celsius, degrees, number, periodText } from "./format";
|
||||
import type { HeatPump, PiSensor } from "./types";
|
||||
import { Card, Row } from "./ui";
|
||||
|
||||
@@ -30,6 +30,7 @@ export function TemperatureDetails({ hp, pi }: { hp: HeatPump; pi: PiSensor | un
|
||||
<Row label="Ciśnienie" value={reading(pi?.pressure, 0, "hPa")} />
|
||||
<Row label="Pokój według pompy" value={celsius(t.indoor)} />
|
||||
<Row label="Pokój, cel teraz" value={celsius(targets.room)} />
|
||||
<Row label="Pokój, strefa" value={periodText(hp.periods.room, hp.periods.programText) ?? "—"} />
|
||||
<Row label="Na zewnątrz, średnia" value={celsius(t.outdoorAvg)} />
|
||||
</Group>
|
||||
<Group title="Woda i ogrzewanie">
|
||||
|
||||
@@ -109,6 +109,17 @@ describe("Energy details", () => {
|
||||
expect(screen.getByRole("button", { name: /Falownik Fronius/ })).not.toHaveTextContent("PV");
|
||||
});
|
||||
|
||||
it("shows the house consumption balance in the stat, the flow and the day donut", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
const stat = (await screen.findByText("Zużycie domu")).closest("section")!;
|
||||
expect(stat).toHaveTextContent("365 W");
|
||||
expect(screen.getAllByText("365 W").length).toBeGreaterThan(1);
|
||||
expect(screen.getAllByText("28,4 kWh").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("dims the values of a source that has gone quiet", async () => {
|
||||
justAfterFixture();
|
||||
const e = example<EnergyData>("energy");
|
||||
@@ -120,7 +131,7 @@ describe("Energy details", () => {
|
||||
const stats = (await screen.findByText("Zużycie domu")).closest("section")!;
|
||||
expect(screen.getByText("Produkcja PV", { selector: "p" }).closest("section")).toHaveClass("opacity-60");
|
||||
expect(screen.getAllByText("Bateria", { selector: "p" })[0].closest("section")).toHaveClass("opacity-60");
|
||||
expect(stats).not.toHaveClass("opacity-60");
|
||||
expect(stats).toHaveClass("opacity-60");
|
||||
});
|
||||
|
||||
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
|
||||
@@ -225,3 +236,22 @@ describe("Energy flow arrows", () => {
|
||||
expect(screen.getByText("Słońce zasila dom: 2,5 kW")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Deye summary", () => {
|
||||
it("adds only the string powers, not their voltages and currents", async () => {
|
||||
const { deviceSummary } = await import("./Energy");
|
||||
const item = (label: string, value: number, unit: string) => ({ label, value, unit, digits: 0 });
|
||||
const deye = {
|
||||
id: "deye", name: "Falownik Deye", time: null, stale: false,
|
||||
groups: [
|
||||
{ title: "Bateria", items: [item("Naładowanie", 99, "%")] },
|
||||
{ title: "Panele PV", items: [
|
||||
item("PV1 moc", 2870, "W"), item("PV1 napięcie", 497.7, "V"), item("PV1 prąd", 5.6, "A"),
|
||||
item("PV2 moc", 3011, "W"), item("PV2 napięcie", 548.5, "V"), item("PV2 prąd", 5.3, "A"),
|
||||
] },
|
||||
],
|
||||
};
|
||||
|
||||
expect(deviceSummary(deye, undefined)).toBe("Bateria 99 % · PV 5,9 kW");
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,7 @@ export function flowSummary(e: EnergyData) {
|
||||
// A source without a fresh reading keeps its last value on the screen, so the value is dimmed.
|
||||
function quietNodes(e: EnergyData): string[] {
|
||||
if (isStale(e.time)) return Object.keys(NODES);
|
||||
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery"] : ["pv"]));
|
||||
return silentInverters(e).flatMap(({ id }) => (id === "deye" ? ["pv", "battery", "house"] : ["pv", "house"]));
|
||||
}
|
||||
|
||||
function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void }) {
|
||||
@@ -123,7 +123,7 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
<Node id="pv" dim={dim("pv")} value={power(e.pvPowerW)} />
|
||||
<Node id="grid" dim={dim("grid")} value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" dim={dim("house")} value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="house" dim={dim("house")} value={power(e.housePowerW)} note="zużycie" />
|
||||
<Node id="battery" dim={dim("battery")} onOpen={onBattery} value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
</>
|
||||
@@ -133,9 +133,9 @@ function FlowPicture({ e, onBattery }: { e: EnergyData; onBattery: () => void })
|
||||
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
|
||||
export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; link?: boolean }) {
|
||||
const today = e?.today;
|
||||
const load = today?.loadKwh ?? null;
|
||||
const house = today?.houseKwh ?? null;
|
||||
const bought = today?.gridBoughtKwh ?? null;
|
||||
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
|
||||
const own = house != null && bought != null ? Math.max(0, house - bought) : null;
|
||||
const parts = [
|
||||
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
|
||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||
@@ -159,7 +159,7 @@ export function EnergyToday({ e, link = false }: { e: EnergyData | undefined; li
|
||||
</Pie>
|
||||
</PieChart>
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div><p className="text-lg font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
<div><p className="text-lg font-bold tabular-nums">{kwh(house)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-44 flex-1 text-sm">
|
||||
@@ -200,7 +200,8 @@ const unbreakable = (text: string) => text.replaceAll(" ", "\u00a0");
|
||||
/** The main current values of a device, from the details and, for the house meter, from the flow. */
|
||||
export function deviceSummary(d: EnergyDetailsDevice, e: EnergyData | undefined): string {
|
||||
if (d.id === "deye") {
|
||||
const pv = reading(d, "Panele PV").reduce((sum, item) => sum + item.value, 0);
|
||||
// The PV group also lists each string's voltage and current: only the powers add up.
|
||||
const pv = reading(d, "Panele PV").filter((item) => item.unit === "W").reduce((sum, item) => sum + item.value, 0);
|
||||
return [`Bateria ${percent(reading(d, "Bateria", "Naładowanie")[0]?.value)}`, `PV ${power(pv)}`].map(unbreakable).join(" · ");
|
||||
}
|
||||
if (d.id === "fronius") return `PV ${power(reading(d, "Teraz", "Moc PV")[0]?.value)}`;
|
||||
@@ -240,7 +241,7 @@ function Now({ onBattery }: { onBattery: () => void }) {
|
||||
<EnergyToday e={e} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
||||
<Stat dim={quiet.includes("pv")} icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat dim={quiet.includes("house")} icon="home" label="Zużycie domu" value={power(e.housePowerW)} />
|
||||
<Stat dim={quiet.includes("grid")} icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat dim={quiet.includes("battery")} icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Stats } from "./types";
|
||||
import type { Program, Stats } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const historyScreen = async () => (await import("./History")).History;
|
||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function serveStats(stats: Stats = example("stats")) {
|
||||
return serve({ "api/stats": stats, "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
|
||||
return serve({ "api/stats": stats, "api/program": example("program"), "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
|
||||
}
|
||||
|
||||
async function open(stats?: Stats) {
|
||||
@@ -30,7 +30,7 @@ describe("Historia views", () => {
|
||||
serveStats();
|
||||
const History = await historyScreen();
|
||||
render(<History />);
|
||||
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki"]);
|
||||
expect(screen.getAllByRole("tab").map((t) => t.textContent)).toEqual(["Wykresy", "Statystyki", "Program"]);
|
||||
expect(tab("Wykresy")).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await userEvent.click(tab("Statystyki"));
|
||||
@@ -50,25 +50,14 @@ describe("Historia views", () => {
|
||||
});
|
||||
|
||||
describe("Statystyki", () => {
|
||||
it("shows the totals, the starts as a minimum and the heater highlighted when it ran", 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);
|
||||
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "true");
|
||||
expect(screen.getByText("Prąd z grzałki jest drogi")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not highlight a heater that stayed off", async () => {
|
||||
const stats = example<Stats>("stats");
|
||||
stats.totals.heaterHours = 0;
|
||||
await open(stats);
|
||||
|
||||
await screen.findByText("Grzałka");
|
||||
expect(screen.getByText("Grzałka").closest("[data-alert]")).toHaveAttribute("data-alert", "false");
|
||||
});
|
||||
|
||||
it("asks for 30 days when chosen", async () => {
|
||||
const fetch = await open();
|
||||
await screen.findByText("Sprężarka");
|
||||
@@ -94,4 +83,37 @@ describe("Statystyki", () => {
|
||||
|
||||
expect(await screen.findByText("Za mało danych w tym okresie.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the weekly program read from history, with how sure each day is", async () => {
|
||||
serveStats();
|
||||
location.hash = "history/program";
|
||||
const History = await historyScreen();
|
||||
render(<History />);
|
||||
|
||||
expect(tab("Program")).toHaveAttribute("aria-selected", "true");
|
||||
const friday = (await screen.findByRole("rowheader", { name: "Pt" })).closest("tr")!;
|
||||
expect(friday).toHaveTextContent("11:25–12:05");
|
||||
expect(friday).toHaveTextContent("widziane raz");
|
||||
expect(screen.getByRole("rowheader", { name: "Pn" }).closest("tr")).toHaveTextContent("brak danych");
|
||||
expect(screen.getAllByRole("rowheader").map((h) => h.textContent)).toEqual(["Pn", "Wt", "Śr", "Cz", "Pt", "So", "Nd"]);
|
||||
expect(screen.getByText(/Odczytane z historii pracy – sterownika nie odpytujemy o program \(wymagałoby to zapisów\)/))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks agreeing days as confirmed and an observed day without comfort", async () => {
|
||||
const program = example<Program>("program");
|
||||
const circuit = { observedDays: 3, agreeingDays: 2 };
|
||||
program.weekdays[0].cwu = { ...circuit, confidence: "confirmed", comfort: [{ from: "11:25", to: "12:05" }, { from: "17:55", to: "19:10" }] };
|
||||
program.weekdays[1].cwu = { ...circuit, confidence: "seen_once", comfort: [] };
|
||||
serve({ "api/program": program, "api/stats": example("stats"), "api/history": { series: "cwu_temp", unit: "°C", points: [] } });
|
||||
location.hash = "history/program";
|
||||
const History = await historyScreen();
|
||||
render(<History />);
|
||||
|
||||
const monday = (await screen.findByRole("rowheader", { name: "Pn" })).closest("tr")!;
|
||||
expect(monday).toHaveTextContent("11:25–12:05");
|
||||
expect(monday).toHaveTextContent("17:55–19:10");
|
||||
expect(monday).toHaveTextContent("potwierdzone");
|
||||
expect(screen.getByRole("rowheader", { name: "Wt" }).closest("tr")).toHaveTextContent("bez komfortu");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { Program } from "./Program";
|
||||
import { Stats } from "./Stats";
|
||||
import { RangeToggle, SubViewPanel, SubViewSwitch, useSubView } from "./SubView";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
@@ -18,7 +19,7 @@ const SERIES = {
|
||||
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
||||
pv_power: ["Produkcja PV", "#f59e0b"],
|
||||
load_power: ["Zużycie domu", "#10b981"],
|
||||
load_power: ["Wyjście zapasowe falownika", "#10b981"],
|
||||
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
||||
} as const;
|
||||
type SeriesId = keyof typeof SERIES;
|
||||
@@ -66,7 +67,7 @@ function Charts() {
|
||||
);
|
||||
}
|
||||
|
||||
const VIEWS = { charts: "Wykresy", stats: "Statystyki" } as const;
|
||||
const VIEWS = { charts: "Wykresy", stats: "Statystyki", program: "Program" } as const;
|
||||
|
||||
export function History() {
|
||||
const [view, show] = useSubView("history", VIEWS);
|
||||
@@ -75,7 +76,7 @@ export function History() {
|
||||
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
||||
<SubViewSwitch screen="history" label="Widok historii" views={VIEWS} view={view} onSelect={show} />
|
||||
<SubViewPanel screen="history" view={view}>
|
||||
{view === "charts" ? <Charts /> : <Stats />}
|
||||
{view === "charts" ? <Charts /> : view === "stats" ? <Stats /> : <Program />}
|
||||
</SubViewPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -143,4 +143,35 @@ describe("Overview", () => {
|
||||
|
||||
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the period under the water target and in the room details", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, targets: { ...hp.targets, cwu: 43 }, periods: { ...hp.periods, cwu: "comfort" } });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const water = (await screen.findByRole("region", { name: "Teraz" })).children[0];
|
||||
expect(water).toHaveTextContent("cel 43,0 °Cstrefa komfortu");
|
||||
const card = screen.getByRole("heading", { name: "Szczegóły temperatur" }).closest("section")!;
|
||||
expect(card).toHaveTextContent("Pokój, strefastrefa oszczędna");
|
||||
});
|
||||
|
||||
it("names the controller program instead of a period when it overrides the weekly one", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
const periods = { cwu: "override", room: "override", program: 2, programText: "Party" } as const;
|
||||
serveHeatPump({ ...hp, periods });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect((await screen.findByRole("region", { name: "Teraz" })).children[0]).toHaveTextContent("program: Party");
|
||||
});
|
||||
|
||||
it("says nothing about the period when it is unknown", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, periods: { cwu: "unknown", room: "unknown", program: 1, programText: null } });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect((await screen.findByRole("region", { name: "Teraz" })).children[0]).toHaveTextContent(/^Ciepła woda13,6 °Ccel 8,0 °C$/);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, hour, longDate, number, percent, 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";
|
||||
@@ -58,13 +58,23 @@ const gridNote = (watts: number | null | undefined) =>
|
||||
watts == null ? "brak danych o sieci"
|
||||
: flowing(watts) ? `${gridText(watts)} ${power(Math.abs(watts))}` : gridText(watts);
|
||||
|
||||
function WaterTarget({ hp }: { hp: HeatPump }) {
|
||||
const period = periodText(hp.periods.cwu, hp.periods.programText);
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
<span>cel {celsius(hp.targets.cwu)}</span>
|
||||
{period && <span>{period}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The four figures the family checks first; each value is shown here and nowhere else on the screen.
|
||||
function Now({ hp, e, pi, weather }: { hp: HeatPump; e: Energy | undefined; pi: PiSensor | undefined; weather: Weather | undefined }) {
|
||||
const home = pi?.temperature ?? hp.temperatures.indoor;
|
||||
const humidity = pi?.temperature == null ? null : pi.humidity;
|
||||
return (
|
||||
<section aria-label="Teraz" className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 lg:grid-cols-4">
|
||||
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={`cel ${celsius(hp.targets.cwu)}`} />
|
||||
<Figure icon="drop" label="Ciepła woda" value={celsius(hp.temperatures.cwu)} note={<WaterTarget hp={hp} />} />
|
||||
<Figure icon="home" label="W domu" value={celsius(home)}
|
||||
note={humidity == null ? "czujnik pompy" : `wilgotność ${number(humidity)} %`} />
|
||||
<Figure icon="thermometer" label="Na zewnątrz" value={celsius(hp.temperatures.outdoor)}
|
||||
@@ -98,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={`sprężarka, grzałka ${onOff(hp.heaterOn, "włączona", "wyłączona")}`}
|
||||
<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")}`}
|
||||
@@ -115,7 +125,6 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
const items: [string, boolean | null, string][] = [
|
||||
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
|
||||
["Grzałka", hp.heaterOn, onOff(hp.heaterOn, "włączona", "wyłączona")],
|
||||
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
|
||||
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
|
||||
return [name, on, onOff(on, "pracuje", "stoi")];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { usePoll } from "./poll";
|
||||
import type { Confidence, Program as ProgramData, ProgramCircuit } from "./types";
|
||||
import { Card, Freshness, Loading } from "./ui";
|
||||
|
||||
const PROGRAM_MS = 5 * 60000; // the backend reads every raw heat pump row of the period for each request
|
||||
const DAYS = ["Pn", "Wt", "Śr", "Cz", "Pt", "So", "Nd"]; // Monday is 1
|
||||
const MARKER: Record<Confidence, string> = {
|
||||
confirmed: "potwierdzone", seen_once: "widziane raz", no_data: "brak danych",
|
||||
};
|
||||
|
||||
function Cell({ circuit }: { circuit: ProgramCircuit }) {
|
||||
const { confidence, comfort, observedDays, agreeingDays } = circuit;
|
||||
return (
|
||||
<td className="px-2 py-2 align-top">
|
||||
{confidence === "no_data" ? <span className="text-soft">—</span>
|
||||
: comfort.length === 0 ? <span className="text-soft">bez komfortu</span>
|
||||
: comfort.map((i) => <span key={i.from} className="block font-semibold tabular-nums">{i.from}–{i.to}</span>)}
|
||||
<span className={`block text-xs ${confidence === "confirmed" ? "text-brand" : "text-soft"}`}
|
||||
title={confidence === "no_data" ? undefined : `zgodne dni: ${agreeingDays} z ${observedDays}`}>
|
||||
{MARKER[confidence]}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function Program() {
|
||||
const { data, offline } = usePoll<ProgramData>("api/program?days=14", PROGRAM_MS);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<Card title={`Strefy komfortu – ostatnie ${data.days} dni`}>
|
||||
{offline && <Freshness offline />}
|
||||
<table className="w-full text-left text-[0.95rem]">
|
||||
<thead className="text-sm text-soft">
|
||||
<tr>
|
||||
<th className="px-2 py-1 font-semibold">Dzień</th>
|
||||
<th className="px-2 py-1 font-semibold">Ciepła woda</th>
|
||||
<th className="px-2 py-1 font-semibold">Pokój</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.weekdays.map((w) => (
|
||||
<tr key={w.day} className="border-t border-edge">
|
||||
<th scope="row" className="px-2 py-2 align-top font-semibold">{DAYS[w.day - 1]}</th>
|
||||
<Cell circuit={w.cwu} />
|
||||
<Cell circuit={w.room} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-3 text-sm text-soft">
|
||||
Odczytane z historii pracy – sterownika nie odpytujemy o program (wymagałoby to zapisów).
|
||||
Czas lokalny; po zmianie czasu sterownik może przesunąć strefy o godzinę.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,6 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
|
||||
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
|
||||
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
|
||||
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
|
||||
|
||||
{/* Warmth rising from the floor while the CO pump runs */}
|
||||
{co && [960, 1080, 1200, 1300].map((x, i) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
import { localInput } from "./format";
|
||||
import type { Setpoints } from "./types";
|
||||
import type { HeatPump, Setpoints } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const settings = async () => (await import("./Settings")).Settings;
|
||||
@@ -23,6 +23,38 @@ describe("Settings", () => {
|
||||
expect(screen.getByText("Obniża temperatury na czas nieobecności, do dnia powrotu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("note under Ciepła woda teraz", () => {
|
||||
const hp = (periods: Partial<HeatPump["periods"]>, cwuComfort: number) => {
|
||||
const base = example<HeatPump>("heat-pump");
|
||||
return { ...base, setpoints: { ...base.setpoints, cwuComfort }, periods: { ...base.periods, ...periods } };
|
||||
};
|
||||
const show = async (pump: HeatPump) => {
|
||||
serve({ "api/setpoints": example("setpoints"), "api/heat-pump": pump });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
await screen.findByText("Ciepła woda – oszczędnie: 45 °C na 2 godz.");
|
||||
};
|
||||
|
||||
it("says the water heats to the comfort value anyway when comfort is not below the preset", async () => {
|
||||
await show(hp({ cwu: "comfort" }, 45));
|
||||
|
||||
expect(await screen.findByText("Teraz strefa komfortu – woda i tak grzeje się do 45 °C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the preset still raises economy for later when comfort is below it", async () => {
|
||||
await show(hp({ cwu: "comfort" }, 43));
|
||||
|
||||
expect(await screen.findByText(/Teraz strefa komfortu \(43 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 45 °C/))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays silent outside the comfort period", async () => {
|
||||
await show(hp({ cwu: "economy" }, 45));
|
||||
|
||||
expect(screen.queryByText(/Teraz strefa komfortu/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("writes only after the summary is confirmed", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
|
||||
const Settings = await settings();
|
||||
@@ -178,8 +210,10 @@ describe("Settings", () => {
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the plain confirmation for a preset without a chosen end", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
it("keeps the plain confirmation for a preset that is neither held nor timed", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.presets[0].changes[0].hours = null;
|
||||
const fetch = serve({ "api/setpoints": data }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
@@ -191,6 +225,94 @@ describe("Settings", () => {
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
|
||||
});
|
||||
|
||||
describe("form of Ciepła woda teraz", () => {
|
||||
const open = async (data = example<Setpoints>("setpoints"), pump?: HeatPump) => {
|
||||
const fetch = serve({ "api/setpoints": data, ...(pump && { "api/heat-pump": pump }) },
|
||||
() => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
|
||||
return fetch;
|
||||
};
|
||||
const more = (times: number) => Array.from({ length: times }).reduce<Promise<void>>(
|
||||
(done) => done.then(() => userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie"))), Promise.resolve());
|
||||
|
||||
it("starts at the preset's temperature and hours and sends them", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
expect(screen.getByRole("status").textContent).toBe("45 °C");
|
||||
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText(paragraph("Ciepła woda do 45 °C przez 2 godz., potem wróci 8 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
|
||||
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 2, values: { cwu_economy: 45 } });
|
||||
});
|
||||
|
||||
it("sends the temperature and hours the user chose", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "4 h" }));
|
||||
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByText(paragraph("Ciepła woda do 44 °C przez 4 godz., potem wróci 8 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
|
||||
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 4, values: { cwu_economy: 44 } });
|
||||
});
|
||||
|
||||
it("stays between just above the current value and the gateway's maximum", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 44;
|
||||
data.presets[0].changes[0].value = 40;
|
||||
await open(data);
|
||||
|
||||
expect(screen.getByRole("status").textContent).toBe("45 °C");
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
await more(5);
|
||||
expect(screen.getByRole("status").textContent).toBe("48 °C");
|
||||
expect(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("cannot be confirmed when the water is already at the maximum", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 48;
|
||||
await open(data);
|
||||
|
||||
expect(screen.getByText("Już na maksimum (48 °C), nie da się podnieść.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Podgrzej wodę" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("warns above 47 °C that the pump alone does not get there", async () => {
|
||||
await open();
|
||||
const hint = "Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)";
|
||||
|
||||
await more(2);
|
||||
expect(screen.queryByText(hint)).toBeNull();
|
||||
await more(1);
|
||||
expect(screen.getByText(hint)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the comfort-period note, for the chosen temperature", async () => {
|
||||
const pump = example<HeatPump>("heat-pump");
|
||||
await open(undefined, { ...pump, setpoints: { ...pump.setpoints, cwuComfort: 46 }, periods: { ...pump.periods, cwu: "comfort" } });
|
||||
|
||||
expect(await screen.findAllByText("Teraz strefa komfortu – woda i tak grzeje się do 46 °C")).toHaveLength(1);
|
||||
await more(2);
|
||||
expect(screen.getByText(/Teraz strefa komfortu \(46 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 47 °C/))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on cancel without writing", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz" })).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a held change with its end date and the value that comes back", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
|
||||
|
||||
+109
-26
@@ -4,7 +4,7 @@ import { Accounts } from "./Accounts";
|
||||
import { ChangeHistory } from "./ChangeHistory";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import type { Change, CommandResult, HeatPump, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import { Button, Card, Freshness, Loading, PageTitle, Stepper } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
@@ -85,6 +85,46 @@ function ResultBox({ result }: { result: Result }) {
|
||||
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
|
||||
}
|
||||
|
||||
type Limits = Record<string, { min: number; max: number; step: number }>;
|
||||
|
||||
const limitsOf = (data: Setpoints, preset: Preset, range: (s: Setpoint) => { min: number; max: number }): Limits =>
|
||||
Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { ...range(s), step: s.step }];
|
||||
}));
|
||||
|
||||
// The preset's own values, moved into the limits; the user steps them from there.
|
||||
function usePresetValues(preset: Preset, limits: Limits) {
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
return { values, stepValue, stuck };
|
||||
}
|
||||
|
||||
function ValueSteppers({ data, preset, limits, values, busy, stuckNote, onStep }: {
|
||||
data: Setpoints; preset: Preset; limits: Limits; values: Record<string, number>; busy: boolean;
|
||||
stuckNote: (current: string) => string; onStep: (id: string, direction: number) => void;
|
||||
}) {
|
||||
return preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
|
||||
onStep={(direction) => onStep(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">{stuckNote(degrees(find(data, c.setpoint).value))}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
|
||||
@@ -95,19 +135,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (until: string, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
|
||||
}));
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const limits = limitsOf(data, preset, (s) => ({ min: s.min, max: lowestAllowed(s) }));
|
||||
const { values, stepValue, stuck } = usePresetValues(preset, limits);
|
||||
const [now] = useState(() => Date.now());
|
||||
const min = localInput(new Date(now + 3600_000));
|
||||
const max = localInput(new Date(now + 30 * DAY_MS));
|
||||
@@ -125,18 +154,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
|
||||
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
|
||||
</label>
|
||||
{preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
|
||||
onStep={(direction) => stepValue(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
|
||||
stuckNote={(current) => `Już na minimum (${current}), nie da się obniżyć.`} />
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
|
||||
@@ -145,11 +164,69 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
);
|
||||
}
|
||||
|
||||
// What the pump reaches on its own in the tank; above it only the CWU output (off at 52 °C) helps.
|
||||
const PUMP_ALONE_MAX = 47;
|
||||
const PRESET_HOURS = [1, 2, 3, 4];
|
||||
|
||||
// A timed preset raises: from just above the current value up to the gateway's maximum (none left: min > max).
|
||||
const raisable = (s: Setpoint) => ({ min: Math.max(s.min, s.value! + s.step), max: s.max });
|
||||
|
||||
const isCwu = (setpoint: string) => setpoint.startsWith("cwu_");
|
||||
|
||||
function TimedForm({ data, preset, hp, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; hp: HeatPump | undefined; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (hours: number, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = limitsOf(data, preset, raisable);
|
||||
const { values, stepValue, stuck } = usePresetValues(preset, limits);
|
||||
const presetHours = preset.changes[0].hours!;
|
||||
const [hours, setHours] = useState(Math.min(4, Math.max(1, Math.round(presetHours))));
|
||||
const chosen = preset.changes.map((c) => ({ ...c, value: values[c.setpoint] }));
|
||||
const above = chosen.some((c) => isCwu(c.setpoint) && c.value > PUMP_ALONE_MAX);
|
||||
return (
|
||||
<>
|
||||
{chosen.map((c) => (
|
||||
<p key={c.setpoint} className="my-2 text-lg font-semibold">
|
||||
{isCwu(c.setpoint) ? "Ciepła woda" : name(c.setpoint)} do <b>{degrees(c.value)}</b> przez {duration(hours)}, potem wróci {degrees(returnValue(data, c.setpoint))}.
|
||||
</p>
|
||||
))}
|
||||
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
|
||||
stuckNote={(current) => `Już na maksimum (${current}), nie da się podnieść.`} />
|
||||
{above && <p className="text-sm text-soft">Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)</p>}
|
||||
<ComfortNote changes={chosen} hp={hp} />
|
||||
<p className="mt-4 mb-2 font-semibold">Na jak długo</p>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{PRESET_HOURS.map((h) => (
|
||||
<Button key={h} aria-pressed={h === hours} disabled={busy} onClick={() => setHours(h)}>{h} h</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(hours, values)} disabled={busy || stuck}>Podgrzej wodę</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
|
||||
const presetHint = (p: Preset) => p.until
|
||||
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
|
||||
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
|
||||
|
||||
// The controller works to the comfort value in a comfort period, whatever economy is set to.
|
||||
function ComfortNote({ changes, hp }: { changes: Change[]; hp: HeatPump | undefined }) {
|
||||
const raised = changes.find((c) => c.setpoint === "cwu_economy")?.value;
|
||||
const comfort = hp?.setpoints.cwuComfort;
|
||||
if (raised == null || comfort == null || hp?.stale || hp?.periods.cwu !== "comfort") return null;
|
||||
return (
|
||||
<p className="text-center text-sm font-semibold text-brand">
|
||||
{comfort >= raised
|
||||
? `Teraz strefa komfortu – woda i tak grzeje się do ${degrees(comfort)}`
|
||||
: `Teraz strefa komfortu (${degrees(comfort)}). Ustawienie podnosi też temperaturę oszczędną do ${degrees(raised)}, więc woda będzie grzana do tej wartości także po zakończeniu strefy.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
@@ -159,6 +236,7 @@ function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
|
||||
export function Settings() {
|
||||
const { data, offline } = usePoll<Setpoints>(URL);
|
||||
const { data: hp } = usePoll<HeatPump>("api/heat-pump");
|
||||
// The one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
|
||||
const [draft, setDraft] = useState<{ name: string; value: number } | null>(null);
|
||||
const [hours, setHours] = useState<number | null>(2);
|
||||
@@ -229,6 +307,7 @@ export function Settings() {
|
||||
|
||||
const preset = data.presets.find((p) => p.id === presetId);
|
||||
if (preset) {
|
||||
const timed = preset.changes.every((c) => c.hours != null);
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
@@ -237,6 +316,9 @@ export function Settings() {
|
||||
{preset.until ? (
|
||||
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
|
||||
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
|
||||
) : timed ? (
|
||||
<TimedForm data={data} preset={preset} hp={hp} busy={busy} onCancel={cancel}
|
||||
onConfirm={(hours, values) => send(`api/presets/${preset.id}`, { hours, values }, preset.changes)} />
|
||||
) : (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</p>)}
|
||||
@@ -295,6 +377,7 @@ export function Settings() {
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
|
||||
<ComfortNote changes={p.changes} hp={hp} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -28,14 +28,11 @@ export function takting(days: StatsDay[]) {
|
||||
return days.some((d) => (d.compressorStarts ?? 0) > TAKTING_STARTS_PER_DAY);
|
||||
}
|
||||
|
||||
function Tile({ label, value, hint, alert = false, wide = false }: {
|
||||
label: string; value: string; hint?: string; alert?: boolean; wide?: boolean;
|
||||
}) {
|
||||
function Tile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div data-alert={alert} className={`rounded-xl border p-3 ${wide ? "col-span-2 lg:col-span-1" : ""} ${alert ? "border-alarm bg-run-soft" : "border-edge bg-page"}`}>
|
||||
<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>
|
||||
{hint && <p className="text-sm text-soft">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,13 +40,11 @@ function Tile({ label, value, hint, alert = false, wide = false }: {
|
||||
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="Grzałka" value={hours(t.heaterHours)} wide alert={t.heaterHours > 0}
|
||||
hint={t.heaterHours > 0 ? "Prąd z grzałki jest drogi" : undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Polish number and time formats; every function accepts null and shows a dash.
|
||||
import type { Period } from "./types";
|
||||
|
||||
export const DASH = "—";
|
||||
|
||||
type Value = number | null | undefined;
|
||||
@@ -94,3 +96,11 @@ export function withOffset(local: string): string {
|
||||
export function duration(hours: number): string {
|
||||
return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`;
|
||||
}
|
||||
|
||||
/** The period a circuit is in, in words; null when it is not known. */
|
||||
export function periodText(period: Period, programText: string | null): string | null {
|
||||
if (period === "comfort") return "strefa komfortu";
|
||||
if (period === "economy") return "strefa oszczędna";
|
||||
if (period === "override") return programText ? `program: ${programText}` : "program inny niż tygodniowy";
|
||||
return null;
|
||||
}
|
||||
+27
-5
@@ -2,12 +2,20 @@
|
||||
|
||||
export type PumpState = "IDLE" | "HEATING_CWU" | "HEATING_CO" | "HEATING_BOTH";
|
||||
|
||||
export type Period = "comfort" | "economy" | "unknown" | "override";
|
||||
|
||||
export interface Periods {
|
||||
cwu: Period;
|
||||
room: Period;
|
||||
program: number | null;
|
||||
programText: string | null; // set when the controller runs a program other than the weekly one
|
||||
}
|
||||
|
||||
export interface HeatPump {
|
||||
time: string | null;
|
||||
stale: boolean;
|
||||
state: PumpState | null;
|
||||
compressorPercent: number | null;
|
||||
heaterOn: boolean | null;
|
||||
alarm: boolean;
|
||||
alarmCode: number | null;
|
||||
alarmText: string | null;
|
||||
@@ -18,6 +26,7 @@ export interface HeatPump {
|
||||
>;
|
||||
targets: Record<"room" | "co" | "cwu", number | null>;
|
||||
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
|
||||
periods: Periods;
|
||||
}
|
||||
|
||||
export interface PiSensor {
|
||||
@@ -41,8 +50,9 @@ export interface Energy {
|
||||
gridPowerW: number | null;
|
||||
batteryPowerW: number | null;
|
||||
batterySocPct: number | null;
|
||||
loadPowerW: number | null;
|
||||
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
|
||||
housePowerW: number | null;
|
||||
backupOutputW: number | null;
|
||||
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; houseKwh: number | null };
|
||||
}
|
||||
|
||||
export interface BatteryEnergy {
|
||||
@@ -250,7 +260,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;
|
||||
@@ -261,7 +270,6 @@ export interface StatsTotals {
|
||||
compressorStarts: number;
|
||||
cwuHours: number;
|
||||
coHours: number;
|
||||
heaterHours: number;
|
||||
avgOutdoorTemp: number | null;
|
||||
minCwuTemp: number | null;
|
||||
maxCwuTemp: number | null;
|
||||
@@ -272,3 +280,17 @@ export interface Stats {
|
||||
days: StatsDay[];
|
||||
totals: StatsTotals;
|
||||
}
|
||||
|
||||
export type Confidence = "confirmed" | "seen_once" | "no_data";
|
||||
|
||||
export interface ProgramCircuit {
|
||||
confidence: Confidence;
|
||||
observedDays: number;
|
||||
agreeingDays: number;
|
||||
comfort: { from: string; to: string }[];
|
||||
}
|
||||
|
||||
export interface Program {
|
||||
days: number;
|
||||
weekdays: { day: number; cwu: ProgramCircuit; room: ProgramCircuit }[]; // Monday is 1
|
||||
}
|
||||
@@ -40,5 +40,7 @@ export default defineConfig({
|
||||
},
|
||||
// `npm run dev` asks a running backend for /api: BACKEND, by default the backend's own default port.
|
||||
server: { proxy: { "/api": process.env.BACKEND ?? "http://localhost:8080" } },
|
||||
test: { environment: "jsdom", setupFiles: ["src/test-setup.ts"] },
|
||||
// CI builds the gateway and backend at the same time (scripts/check.sh), so a test's first render can
|
||||
// take several seconds there; the default 5 s timed out and left its DOM for the next test.
|
||||
test: { environment: "jsdom", setupFiles: ["src/test-setup.ts"], testTimeout: 20000 },
|
||||
});
|
||||
Reference in new issue
Block a user