Compare commits

...
Author SHA1 Message Date
rootandClaude Opus 5.5 3c4386cee7 docs(roadmap): plan M2 and match what runs in production
CI / Check (gateway, backend, frontend) (push) Failing after 1m13s
The roadmap still said commands and battery commands were off, while both
have been enabled on the Pi since 2026-10-24 and the web UI is live. M2 gets
a checklist from the open questions in docs/frisko/; the F3 limits and the
4226 numbering are already settled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015x6FWeKCY59JfFJmzAm4TL
2026-10-25 14:29:31 +01:00
root 326db85114 Merge PR #106: fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m9s
Release / Image (backend) (push) Successful in 1m8s
Release / Image (frontend) (push) Successful in 50s
Release / Image (gateway) (push) Successful in 29s
2026-10-25 09:46:30 +01:00
rootandClaude Opus 5.5 ead74ebdec fix(frontend): Deye PV in the inverter list adds only the string powers
CI / Check (gateway, backend, frontend) (push) Successful in 1m12s
The summary summed every item of the Panele PV group, including each
string's voltage and current, so 5.9 kW of PV showed as about 7 kW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:44:43 +01:00
root f99ae01626 Merge PR #105: fix: house consumption from the energy balance, Deye load is only its backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m14s
2026-10-25 09:40:13 +01:00
rootandClaude Opus 5.5 a804923290 fix(energy): house consumption as a balance, not the Deye backup output
CI / Check (gateway, backend, frontend) (push) Successful in 1m11s
The Deye load registers cover only the inverter's backup output, so the
house showed 0 W while it drew 300 W. Now power is PV + battery + grid
(house meter, Deye grid as fallback) and today's energy is the day
counters balance. The backup output is exposed as backupOutputW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:35:30 +01:00
16 changed files with 203 additions and 41 deletions

No files matched your search

+1
View File
@@ -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)
@@ -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);
}
@@ -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) {
}
}
@@ -280,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,
@@ -428,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
@@ -441,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();
}
@@ -532,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
@@ -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 }
}
+3 -2
View File
@@ -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 }
}
-5
View File
@@ -44,10 +44,6 @@ below therefore come from the PDF, the XML and the MR208 manual, not from code.
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
## Modes and programs
| Reg | Values | Source |
@@ -115,6 +111,5 @@ The vendor documents no limit. The app's 200 ms pause is the only known pacing;
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
- Does 4011 exist and move on this firmware? Its relation to 4221.
- Is 4226 numbered as the PDF says (see above)?
- What does 4119 read when it is implemented, and is the register used at all?
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
+1 -1
View File
@@ -23,7 +23,7 @@ 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 | |
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | 4226 numbered as the PDF (1 = weekly), verified 2026-10-24 |
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater signal (heater not connected), 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater signal delay = 20 | read 2026-10-23 |
+21 -5
View File
@@ -33,14 +33,30 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
meter's converter speaks RTU over TCP); `rpi-measurements` stopped and disabled
- [ ] After the week: remove `rpi-measurements` and the old `home-pump.service` files
## Next
## Next: M2 Understand the controller
Done when: `docs/frisko/` reviewed, each open question answered or marked out of reach. Reads only;
a probe against the controller runs on the Pi, with the owner's approval, while the Frisko app is closed.
- [x] F3 limits agreed: CWU 5–48, CO 5–25 (gateway/README.md); 4226 numbering holds (history)
- [ ] DST, 2026-10-26: CWU comfort at ~10:25 / 16:55 local means the clock is still on CEST; then
the owner sets it and we note the date
- [ ] Timing probe: block response times (p50, p95) and errors at 1, 2 and 5 s cycles, ~10 min
each; what our connection does to a running Frisko app. Gives the poll rate and Odśwież limit
- [ ] Which pumps run in which state: outputs 4112, 4113, 4116–4118 and stage 4114 against 4227
over a week of `heat_pump`, including the first CO cycles
- [ ] Over the same week: 4222–4225 and 4228 values; the CWU start condition (40.8–42.8 °C vs none
at 38–39 °C)
- [ ] Read 4011 beside 4221 and 4073 (a new read-only field): does it exist on SE21 (M5 needs it)
- [ ] Weekly program: read 4167–4177 through the shared selector, or keep inferring it (F6); decide
## Later
| 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, 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 |
| 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); live at `pompa.mariuszpacek.pl` behind the sign-in (2026-10-25) | 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 enabled on the Pi since 2026-10-24 (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 master and enabled on the Pi since 2026-10-24 (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 |
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
+31 -1
View File
@@ -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");
});
});
+8 -7
View File
@@ -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>
+1 -1
View File
@@ -19,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;
+3 -2
View File
@@ -50,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 {