Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4386cee7 | ||
|
|
326db85114 | ||
|
|
ead74ebdec | ||
|
|
f99ae01626 | ||
|
|
a804923290 |
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user