Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e997e43f3 | ||
|
|
8ae6cea536 | ||
|
|
604ef4e6d9 | ||
|
|
b0735471b4 | ||
|
|
6f50f5b7b9 | ||
|
|
bad67b9538 | ||
|
|
ce00d0e81b | ||
|
|
0eebba072e | ||
|
|
ea83255b91 | ||
|
|
ba8631b3c2 | ||
|
|
6cae491b1c | ||
|
|
83333a19bf | ||
|
|
0072017966 | ||
|
|
35b3f8e1e0 | ||
|
|
7a17bbbcdb | ||
|
|
f0c974c82a | ||
|
|
d557f98df6 | ||
|
|
9f33f5a94c | ||
|
|
798f98b69d | ||
|
|
e65cb5837f | ||
|
|
0b3197ac55 | ||
|
|
5513c09c51 | ||
|
|
697b68ec39 | ||
|
|
645bbc4b88 | ||
|
|
2d7899ebc8 | ||
|
|
c34a9da0d7 |
No files matched your search
@@ -37,6 +37,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
@@ -44,6 +45,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
@@ -51,6 +53,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
@@ -8,6 +8,9 @@ COPY src src
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
|
||||
@@ -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/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.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
@@ -28,3 +29,6 @@ docker compose -f backend/compose.yaml up --build # with backend/.e
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -6,14 +6,17 @@ import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.SetpointService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
@@ -29,13 +32,21 @@ final class Backend {
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
|
||||
settings.gatewayTimeout());
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
var setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
static LeaseRenewer leaseRenewer(Settings settings) {
|
||||
return new LeaseRenewer(commands(settings));
|
||||
}
|
||||
|
||||
private static GatewayHttpCommands commands(Settings settings) {
|
||||
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} else {
|
||||
var renewer = Backend.leaseRenewer(settings);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||
renewer.start();
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(List<DeviceHealth> devices) {
|
||||
private record Health(String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
|
||||
@@ -27,11 +27,15 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
private record Request(String setpoint, int value, Long minutes) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
@@ -39,6 +43,7 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
@@ -59,11 +64,21 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
return request(endpoint);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
@@ -58,7 +59,8 @@ public class ApiServer {
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
@@ -70,6 +72,7 @@ public class ApiServer {
|
||||
});
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
|
||||
* catalog below is the only place that knows a field's label, unit and place; showing a new field
|
||||
* is one line here.
|
||||
*/
|
||||
public class EnergyDetailsService {
|
||||
|
||||
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
|
||||
private record Field(String key, String label, String unit, int digits, double scale) {
|
||||
}
|
||||
|
||||
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
|
||||
}
|
||||
|
||||
/** {@code measurement} is null for the device's own one. */
|
||||
private record GroupSpec(String title, String measurement, List<Field> fields) {
|
||||
}
|
||||
|
||||
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
|
||||
private static Field[] field(String key, String label, String unit, int digits) {
|
||||
return new Field[] {new Field(key, label, unit, digits, 1)};
|
||||
}
|
||||
|
||||
private static Field[] kwhFromWh(String key, String label, int digits) {
|
||||
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
|
||||
}
|
||||
|
||||
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
|
||||
private static Field[] phases(String key, String label, String unit, int digits) {
|
||||
return IntStream.rangeClosed(1, 3)
|
||||
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
|
||||
}
|
||||
|
||||
private static GroupSpec group(String title, Field[]... parts) {
|
||||
return groupOf(null, title, parts);
|
||||
}
|
||||
|
||||
/** A group whose fields come from another measurement of the same device. */
|
||||
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
|
||||
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
|
||||
}
|
||||
|
||||
private static final List<Spec> CATALOG = List.of(
|
||||
new Spec("deye", "Falownik Deye", "deye", List.of(
|
||||
group("Bateria",
|
||||
field("battery_soc_pct", "Naładowanie", "%", 0),
|
||||
field("battery_power_w", "Moc", "W", 0),
|
||||
field("battery_voltage_v", "Napięcie", "V", 1),
|
||||
field("battery_current_a", "Prąd", "A", 1),
|
||||
field("battery_temperature_c", "Temperatura", "°C", 1)),
|
||||
group("Sieć",
|
||||
field("grid_power_w", "Moc razem", "W", 0),
|
||||
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",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
field("pv1_power_w", "PV1 moc", "W", 0),
|
||||
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
|
||||
field("pv1_current_a", "PV1 prąd", "A", 1),
|
||||
field("pv2_power_w", "PV2 moc", "W", 0),
|
||||
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
|
||||
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("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("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),
|
||||
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
|
||||
group("Falownik",
|
||||
field("inverter_power_w", "Moc wyjściowa", "W", 0),
|
||||
field("inverter_temperature_c", "Temperatura", "°C", 1),
|
||||
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
|
||||
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
|
||||
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
|
||||
group("Produkcja",
|
||||
kwhFromWh("energy_day_wh", "Dziś", 1),
|
||||
kwhFromWh("energy_year_wh", "Ten rok", 0),
|
||||
kwhFromWh("energy_total_wh", "Od początku", 0)))),
|
||||
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
|
||||
group("Moc czynna",
|
||||
field("total_power_active", "Razem", "W", 0),
|
||||
phases("p%d_power_active", "Moc", "W", 0)),
|
||||
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
|
||||
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
|
||||
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
|
||||
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
|
||||
group("Energia od początku",
|
||||
field("import_energy_active", "Pobrana", "kWh", 0),
|
||||
field("export_energy_active", "Oddana", "kWh", 0)),
|
||||
groupOf("house_meter_phases", "Energia na fazę",
|
||||
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
|
||||
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyDetails current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
|
||||
}
|
||||
|
||||
private Device device(Spec spec, GatewaySnapshot snapshot) {
|
||||
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
|
||||
List<Group> groups = spec.groups().stream()
|
||||
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
|
||||
.filter(g -> !g.items().isEmpty()).toList();
|
||||
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
|
||||
}
|
||||
|
||||
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
|
||||
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
|
||||
}
|
||||
|
||||
private static Group group(GroupSpec spec, Map<String, Double> fields) {
|
||||
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
|
||||
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
|
||||
return new Group(spec.title(), items);
|
||||
}
|
||||
|
||||
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
|
||||
private static double scaled(double value, Field f) {
|
||||
double factor = Math.pow(10, f.digits());
|
||||
return Math.round(value * f.scale() * factor) / factor;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
private record Inverter(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -31,8 +26,8 @@ public class EnergyService {
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Inverter deye = inverter(snapshot, "deye");
|
||||
Inverter fronius = inverter(snapshot, "fronius");
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
@@ -46,13 +41,6 @@ public class EnergyService {
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private Inverter inverter(GatewaySnapshot snapshot, String name) {
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
|
||||
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Inverter(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,7 @@ public interface GatewayCommands {
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
|
||||
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||
int renewLeases() throws IOException;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
@@ -13,19 +14,21 @@ public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
private final String version;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock) {
|
||||
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of());
|
||||
return new HealthView(clock.instant(), false, List.of(), version, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package home.backend.application;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
|
||||
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
|
||||
*/
|
||||
public class LeaseRenewer implements AutoCloseable {
|
||||
|
||||
/** A third of the gateway's lease, so two renewals in a row may fail. */
|
||||
public static final Duration PERIOD = Duration.ofMinutes(5);
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("lease-renewal").factory());
|
||||
|
||||
public LeaseRenewer(GatewayCommands gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
start(PERIOD);
|
||||
}
|
||||
|
||||
void start(Duration period) {
|
||||
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
|
||||
void renew() {
|
||||
try {
|
||||
int renewed = gateway.renewLeases();
|
||||
LOGGER.debug("renewed {} lease(s)", renewed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -29,6 +30,17 @@ final class SnapshotFields {
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
}
|
||||
|
||||
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
record Live(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
|
||||
Optional<Measurement> reading = find(snapshot, device, measurement);
|
||||
boolean stale = stale(device(snapshot, device), reading, now);
|
||||
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Every value the energy devices report, grouped for a details panel (F7). A device without a
|
||||
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
|
||||
*/
|
||||
public record EnergyDetails(List<Device> devices) {
|
||||
|
||||
/** One device; {@code id} is the gateway's device name. */
|
||||
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
|
||||
}
|
||||
|
||||
public record Group(String title, List<Item> items) {
|
||||
}
|
||||
|
||||
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
|
||||
public record Item(String label, double value, String unit, int digits) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
/** The gateway's latest state of every device, as the backend sees it.
|
||||
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
|
||||
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package home.backend.domain;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||
* The gateway's version is null when it is unreachable or too old to report it. */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
|
||||
String backendVersion, String gatewayVersion) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
||||
/**
|
||||
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
|
||||
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
|
||||
*/
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheGatewayVersion() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
assertEquals("v1.2.1", client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
assertNull(client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
@@ -32,7 +32,7 @@ class GatewayHttpCommandsTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String authorization, String requestedBy, String body) {
|
||||
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
@@ -60,7 +60,7 @@ class GatewayHttpCommandsTest {
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(),
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
@@ -136,6 +136,35 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDue() throws Exception {
|
||||
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
|
||||
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
|
||||
serve(200, "{\"renewed\":2}");
|
||||
|
||||
assertEquals(2, commands("s3cret-token").renewLeases());
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/renew", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).renewLeases());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
|
||||
@@ -2,6 +2,7 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
@@ -109,10 +110,16 @@ class ApiServerTest {
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock), new HistoryService(history, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)));
|
||||
server.start();
|
||||
}
|
||||
@@ -137,14 +144,18 @@ class ApiServerTest {
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
return new GatewaySnapshot(List.of(
|
||||
return new GatewaySnapshot("v1.2.1", List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
|
||||
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)))));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
@@ -178,16 +189,21 @@ class ApiServerTest {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyDetailsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/energy/details", "energy-details");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of());
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@@ -198,7 +214,7 @@ class ApiServerTest {
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot(devices);
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@@ -216,7 +232,7 @@ class ApiServerTest {
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeaseRenewerTest {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final CountDownLatch threeCalls = new CountDownLatch(3);
|
||||
private Exception failure;
|
||||
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
calls.incrementAndGet();
|
||||
threeCalls.countDown();
|
||||
if (failure instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
if (failure instanceof RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void renewsAtOnceAndThenEveryPeriod() throws Exception {
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
|
||||
failure = new IllegalStateException("bad answer");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewDoesNotThrowOutOfTheScheduler() {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.renew();
|
||||
}
|
||||
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
@@ -40,7 +44,7 @@ class ServicesTest {
|
||||
}
|
||||
|
||||
private static GatewayClient gateway(DeviceSnapshot... devices) {
|
||||
return () -> new GatewaySnapshot(List.of(devices));
|
||||
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
|
||||
}
|
||||
|
||||
private static GatewayClient unreachable() {
|
||||
@@ -215,6 +219,80 @@ class ServicesTest {
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
|
||||
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
|
||||
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
|
||||
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
|
||||
|
||||
assertFalse(deye.stale());
|
||||
assertEquals(READ, deye.time());
|
||||
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
|
||||
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
|
||||
|
||||
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
|
||||
|
||||
assertTrue(deye.stale());
|
||||
assertEquals(old, deye.time());
|
||||
assertTrue(deye.groups().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
|
||||
EnergyDetails all = details();
|
||||
|
||||
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
|
||||
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
|
||||
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
|
||||
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
|
||||
|
||||
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
|
||||
fronius.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsReadTheHouseMeterPerPhase() throws IOException {
|
||||
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
|
||||
|
||||
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
|
||||
meter.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
|
||||
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
|
||||
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
|
||||
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
|
||||
|
||||
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
|
||||
|
||||
assertEquals("Energia na fazę", phases.title());
|
||||
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsFailWhenTheGatewayIsUnreachable() {
|
||||
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
|
||||
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
|
||||
}
|
||||
@@ -269,7 +347,7 @@ class ServicesTest {
|
||||
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
|
||||
|
||||
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
|
||||
down), CLOCK).current();
|
||||
down), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertTrue(view.gatewayReachable());
|
||||
assertEquals(NOW, view.time());
|
||||
@@ -279,12 +357,22 @@ class ServicesTest {
|
||||
assertEquals(READ, view.devices().get(0).lastSuccess());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthReportsBothVersions() {
|
||||
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertEquals("v1.2.1", view.gatewayVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK).current();
|
||||
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertNull(view.gatewayVersion());
|
||||
}
|
||||
|
||||
/** Remembers the request and answers with one point. */
|
||||
|
||||
@@ -55,6 +55,11 @@ class SetpointServiceTest {
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Bateria", "items": [
|
||||
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "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": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Teraz", "items": [
|
||||
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Produkcja", "items": [
|
||||
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Prąd", "items": [
|
||||
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Współczynnik mocy", "items": [
|
||||
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Energia od początku", "items": [
|
||||
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"backendVersion": "v1.3.0",
|
||||
"gatewayVersion": "v1.2.1",
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
{"status":"UP","version":"v1.2.1","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR-011: Setpoint changes are leases that the backend renews
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
ADR-006 restores a timed boost by itself, but a change "until changed" (away: CWU 10, CO 15)
|
||||
stays forever. If the backend, the tunnel or the Kraków server is lost for good, the house stays
|
||||
on those settings until someone notices. The requirement: a missing command at the gateway
|
||||
reverses the change.
|
||||
|
||||
## Decision
|
||||
|
||||
- Every applied change, timed or not, keeps a restore with a **lease** of 15 minutes
|
||||
(`leaseUntil`). The restore runs at the earlier of its end time (`due`, null for "until
|
||||
changed") and the end of the lease, through the existing path: conflict check, read-back,
|
||||
backoff.
|
||||
- `POST /commands/renew` (token, same throttle as `/commands`) extends the lease to 15 minutes
|
||||
from now for every restore that has not reached its run time. A lapsed, due or failing restore
|
||||
is never revived.
|
||||
- The backend calls it every 5 minutes from its start while `GATEWAY_TOKEN` is set. A failed call
|
||||
is logged and retried; two in a row may fail without effect.
|
||||
- A restore file entry without `leaseUntil` (older file) counts as lapsed and is restored on
|
||||
start. `leaseUntil` is saved atomically like the rest.
|
||||
- Limits and presets stay as agreed: CWU at most 48 °C (code ceiling 50), CO 5–25, restores at
|
||||
most 72 h.
|
||||
|
||||
## Consequences
|
||||
|
||||
- "Until changed" means "while the backend runs and reaches the gateway". The UI says so.
|
||||
- After a backend or tunnel outage longer than 15 minutes the house returns to the original
|
||||
values; the user sets them again. That is the intended safe direction.
|
||||
- One extra small file write on the Pi every 5 minutes while a change is active.
|
||||
- A backend that is up but wrong about the world keeps changes alive; the gateway cannot tell.
|
||||
+8
-9
@@ -27,9 +27,11 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
baseline classes (D4, D5)
|
||||
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
|
||||
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
|
||||
`rpi-measurements` and the old `home-pump.service`
|
||||
- [x] On the Pi (2026-10-24): D7, Docker (no iptables, no forwarding), `gateway.json`, ufw 8080
|
||||
on `wg0` only, `v1.1.1` running with the heat pump, Deye and Fronius; old service stopped
|
||||
- [x] On the Pi (2026-10-24): IIO overlay, BME280 and the meter in the gateway (`v1.2.1`: the
|
||||
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
|
||||
|
||||
@@ -37,7 +39,7 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); 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 |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006) and 15 min leases the backend renews (ADR-011); 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -51,11 +53,8 @@ their names and fields; new data goes into new measurements (ADR-005). The heat
|
||||
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
|
||||
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
|
||||
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
|
||||
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
|
||||
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
|
||||
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
|
||||
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
|
||||
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
|
||||
- D7 fixed 2026-10-24: Pi `AllowedIPs` only `10.0.0.0/24, 10.2.0.0/24`, and OPNsense NATs
|
||||
`192.168.0.0/24 → 10.0.0.2` to its tunnel address, as both sites use `192.168.0.0/24`.
|
||||
|
||||
## Knowledge
|
||||
|
||||
|
||||
+5
-4
@@ -41,7 +41,7 @@ Out, until decided otherwise:
|
||||
| Solar thermal collector | roof | Also heats the CWU tank; not connected to anything we read |
|
||||
| Hybrid inverter Deye SUN-10K-SG04LP3-EU | logger `192.168.0.232:8899` | 10 kW, 3 phases, 48 V battery (backup only); most of the PV |
|
||||
| PV inverter Fronius Symo 3.7-3-M | `192.168.0.143` | 4.2 kWp; local Solar API (JSON) |
|
||||
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2`, moving into our gateway |
|
||||
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2` (unchanged), moving into our gateway; per-phase energy counters go to `electricity`/`house_meter_phases` |
|
||||
| Solar-Log | `192.168.0.210` | Old monitoring, no data since April 2026 |
|
||||
| Raspberry Pi (`raspberrypi`) | `192.168.0.158` | Debian 11 (kept as is); runs the gateway (and `rpi-measurements` until the move); BME280 sensor; WireGuard `10.0.0.2` |
|
||||
| My server | Kraków | Proxmox and Docker: backend, Traefik, InfluxDB 1.x, Grafana, Gitea. Same subnet `192.168.0.0/24` as Krosno; WireGuard on OPNsense |
|
||||
@@ -78,15 +78,16 @@ Functional:
|
||||
- F2 History for the last 7 days.
|
||||
- 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
|
||||
the backend config: CWU 5–50 °C (pump maximum per my father; 48 °C proposed for margin, to
|
||||
decide in M2), CO 5–25 °C.
|
||||
the backend config: CWU 5–48 °C (decided; the gateway's ceiling stays 50), CO 5–25 °C.
|
||||
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
|
||||
is restored automatically.
|
||||
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
|
||||
- **Lease** (ADR-011): every change also needs the backend to renew it every 5 minutes; after
|
||||
15 minutes without, the gateway restores the previous value.
|
||||
- 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).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals.
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter).
|
||||
|
||||
Safety, for every write:
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
# The release tag, shown in the Stan screen; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -13,3 +13,6 @@ docker compose -f frontend/compose.yaml up --build # after the backend's; .env
|
||||
|
||||
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
|
||||
the sizes and the pipe masks in `src/assets/` from it.
|
||||
|
||||
The release tag is baked in at build (`VERSION` build arg, `dev` by default) and shown with the backend's
|
||||
and gateway's versions in the "Wersje" card of the Stan screen.
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0e2a21">
|
||||
<title>Dom Krosno</title>
|
||||
<title>Dom Szczepańcowa</title>
|
||||
<link rel="icon" href="data:,">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -60,7 +60,7 @@ function SideFooter() {
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Krosno</p>
|
||||
<p className="text-sm text-white/60">Szczepańcowa</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
@@ -97,7 +97,7 @@ export function App() {
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
|
||||
<p className="text-lg font-bold leading-tight">Dom Szczepańcowa</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { quantity, when } from "./format";
|
||||
import type { EnergyDetailsDevice } from "./types";
|
||||
import { Row } from "./ui";
|
||||
|
||||
/** The tappable row that says in words and with a chevron that a device has more to show. */
|
||||
export function DetailsButton({ device, onOpen }: { device: EnergyDetailsDevice; onOpen: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onOpen}
|
||||
className="flex min-h-14 w-full items-center justify-between gap-3 rounded-xl border border-edge px-4 py-2 text-left hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
<span>
|
||||
<span className="block font-semibold">{device.name}</span>
|
||||
{device.stale && <span className="block text-xs text-soft">brak odczytu</span>}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 font-semibold text-brand">
|
||||
Szczegóły <span aria-hidden="true" className="text-xl leading-none">›</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A bottom sheet on a phone, a dialog on a wide screen; closes by button, backdrop or Escape. */
|
||||
export function DeviceDetails({ device, onClose }: { device: EnergyDetailsDevice; onClose: () => void }) {
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
const overflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
close.current?.focus();
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
// The close button is the only control inside, so Tab stays on it.
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
close.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = overflow;
|
||||
opener?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div data-testid="backdrop" className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 sm:items-center"
|
||||
onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="details-title" onClick={(event) => event.stopPropagation()}
|
||||
className="flex max-h-[85dvh] w-full flex-col rounded-t-2xl border border-edge bg-panel shadow-xl sm:max-w-lg sm:rounded-2xl">
|
||||
<header className="flex items-start justify-between gap-3 border-b border-edge p-4">
|
||||
<div>
|
||||
<h2 id="details-title" className="text-lg font-semibold">{device.name}</h2>
|
||||
{device.time && <p className="text-sm text-soft">Odczyt z {when(device.time)}</p>}
|
||||
</div>
|
||||
<button ref={close} type="button" onClick={onClose}
|
||||
className="min-h-11 min-w-11 rounded-xl px-3 font-semibold text-brand hover:bg-brand-soft focus-visible:outline-2 focus-visible:outline-brand">
|
||||
Zamknij
|
||||
</button>
|
||||
</header>
|
||||
<div className="overflow-y-auto p-4">
|
||||
{device.groups.length === 0 && (
|
||||
<p className="text-soft">
|
||||
Brak aktualnych danych{device.time ? ` (ostatni odczyt ${when(device.time)})` : ""}.
|
||||
</p>
|
||||
)}
|
||||
{device.groups.map((group) => (
|
||||
<section key={group.title} className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wide text-soft">{group.title}</h3>
|
||||
{group.items.map((item) => (
|
||||
<Row key={item.label} label={item.label} value={quantity(item.value, item.unit, item.digits)} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { EnergyDetails } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const energy = async () => (await import("./Energy")).Energy;
|
||||
|
||||
function serveEnergy(details: EnergyDetails = example("energy-details")) {
|
||||
serve({ "api/energy/details": details, "api/energy": example("energy") });
|
||||
}
|
||||
|
||||
describe("Energy details", () => {
|
||||
it("offers every device with a visible way to open it", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
for (const name of ["Falownik Deye", "Falownik Fronius", "Licznik domu"]) {
|
||||
expect(await screen.findByRole("button", { name: new RegExp(`${name}.*Szczegóły`) })).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the groups of a device and closes by button, backdrop and Escape", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
const opener = await screen.findByRole("button", { name: /Falownik Deye/ });
|
||||
await userEvent.click(opener);
|
||||
const dialog = screen.getByRole("dialog", { name: "Falownik Deye" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
expect(within(dialog).getByRole("button", { name: "Zamknij" })).toHaveFocus();
|
||||
expect(within(dialog).getByRole("heading", { name: "Bateria" })).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Naładowanie").parentElement).toHaveTextContent("99 %");
|
||||
expect(within(dialog).getByText("Pobrano z sieci").parentElement).toHaveTextContent("16,8 kWh");
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Zamknij" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(opener).toHaveFocus();
|
||||
|
||||
await userEvent.click(opener);
|
||||
await userEvent.click(screen.getByTestId("backdrop"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
await userEvent.click(opener);
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("stays open when the panel itself is tapped", async () => {
|
||||
serveEnergy();
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Licznik domu/ }));
|
||||
await userEvent.click(screen.getByRole("heading", { name: "Licznik domu" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says there is no current data for a device without a fresh reading", async () => {
|
||||
const details = example<EnergyDetails>("energy-details");
|
||||
serveEnergy({ devices: details.devices.map((d) => (d.id === "fronius" ? { ...d, stale: true, groups: [] } : d)) });
|
||||
const Energy = await energy();
|
||||
render(<Energy />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Falownik Fronius/ }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Brak aktualnych danych");
|
||||
});
|
||||
});
|
||||
+21
-1
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import { kwh, percent, power, when } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Energy as EnergyData } from "./types";
|
||||
import type { Energy as EnergyData, EnergyDetails } from "./types";
|
||||
import { DetailsButton, DeviceDetails } from "./DeviceDetails";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
@@ -138,6 +140,23 @@ function Stat({ icon, label, value, note }: { icon: IconName; label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
function Devices() {
|
||||
const { data } = usePoll<EnergyDetails>("api/energy/details");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const close = useCallback(() => setOpenId(null), []);
|
||||
const open = data?.devices.find((d) => d.id === openId);
|
||||
return (
|
||||
<Card title="Urządzenia" className="lg:col-span-3">
|
||||
{!data ? <Loading /> : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{data.devices.map((d) => <DetailsButton key={d.id} device={d} onOpen={() => setOpenId(d.id)} />)}
|
||||
</div>
|
||||
)}
|
||||
{open && <DeviceDetails device={open} onClose={close} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Energy() {
|
||||
const { data: e, offline } = usePoll<EnergyData>("api/energy");
|
||||
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
|
||||
@@ -157,6 +176,7 @@ export function Energy() {
|
||||
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
<Devices />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -71,6 +71,18 @@ describe("Settings", () => {
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("labels a change without an end time as lasting while the server runs", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 45, appliedValue: 10, due: null }];
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania (dopóki serwer działa), potem 45 °C.")),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not offer a change built on a missing reading", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints[2].value = null;
|
||||
|
||||
@@ -176,7 +176,7 @@ export function Settings() {
|
||||
<Card title="Zmiany czasowe">
|
||||
{data.restores.map((r) => (
|
||||
<p key={r.setpoint} className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> do {when(r.due)}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${when(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Health } from "./types";
|
||||
|
||||
const status = async () => (await import("./Status")).Status;
|
||||
const versions = () => screen.getByText("Wersje").closest("section")!;
|
||||
|
||||
describe("Status", () => {
|
||||
it("shows the version of each component", async () => {
|
||||
serve({ "api/health": example("health") });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("Wersje")).toBeInTheDocument();
|
||||
expect(versions()).toHaveTextContent(`Aplikacja${__APP_VERSION__}`);
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
expect(versions()).toHaveTextContent("Bramkav1.2.1");
|
||||
});
|
||||
|
||||
it("says unknown for a gateway that does not report its version", async () => {
|
||||
serve({ "api/health": { ...example<Health>("health"), gatewayReachable: false, gatewayVersion: null } });
|
||||
const Status = await status();
|
||||
render(<Status />);
|
||||
|
||||
expect(await screen.findByText("Wersje")).toBeInTheDocument();
|
||||
expect(versions()).toHaveTextContent("Bramkanieznana");
|
||||
expect(versions()).toHaveTextContent("Serwerv1.3.0");
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,11 @@ export function Status() {
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
<Card title="Wersje">
|
||||
<Row label="Aplikacja" value={__APP_VERSION__} />
|
||||
<Row label="Serwer" value={data.backendVersion} />
|
||||
<Row label="Bramka" value={data.gatewayVersion ?? "nieznana"} />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,10 @@ export const kwh = (value: Value) => (value == null ? DASH : `${number(value, 1)
|
||||
// "45 °C" for a whole setpoint, "45,5 °C" otherwise.
|
||||
export const degrees = (value: Value) => (value == null ? DASH : `${number(value, 1).replace(/,0$/, "")} °C`);
|
||||
|
||||
// A value in the unit the backend names, with the precision it asks for.
|
||||
export const quantity = (value: number, unit: string, digits: number) =>
|
||||
unit ? `${number(value, digits)} ${unit}` : number(value, digits);
|
||||
|
||||
export function power(watts: Value): string {
|
||||
if (watts == null) return DASH;
|
||||
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
|
||||
|
||||
+23
-1
@@ -37,6 +37,25 @@ export interface Energy {
|
||||
today: { pvKwh: number | null; gridBoughtKwh: number | null; gridSoldKwh: number | null; loadKwh: number | null };
|
||||
}
|
||||
|
||||
export interface EnergyDetailsItem {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
export interface EnergyDetailsDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
time: string | null;
|
||||
stale: boolean;
|
||||
groups: { title: string; items: EnergyDetailsItem[] }[];
|
||||
}
|
||||
|
||||
export interface EnergyDetails {
|
||||
devices: EnergyDetailsDevice[];
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
t: string;
|
||||
v: number;
|
||||
@@ -51,6 +70,8 @@ export interface History {
|
||||
export interface Health {
|
||||
time: string;
|
||||
gatewayReachable: boolean;
|
||||
backendVersion: string;
|
||||
gatewayVersion: string | null;
|
||||
devices: { name: string; up: boolean; lastSuccess: string | null; lastError: string | null }[];
|
||||
}
|
||||
|
||||
@@ -68,7 +89,8 @@ export interface Restore {
|
||||
register: number;
|
||||
originalValue: number;
|
||||
appliedValue: number;
|
||||
due: string;
|
||||
// null: until changed, which holds only while the backend keeps renewing the lease (ADR-011)
|
||||
due: string | null;
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/** The frontend's own release tag, defined at build time in vite.config.ts. */
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -6,6 +6,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "./",
|
||||
// The release tag from the Dockerfile's VERSION build arg; "dev" for a local build.
|
||||
define: { __APP_VERSION__: JSON.stringify(process.env.VERSION ?? "dev") },
|
||||
build: { outDir: "dist", emptyOutDir: true },
|
||||
// `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" } },
|
||||
|
||||
@@ -8,6 +8,9 @@ COPY src src
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
# The release tag, shown in /health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
COPY --from=build /src/target/home-pump-1.0.0.jar /app/gateway.jar
|
||||
# Config, spool, restore file and token live on the Pi under the same path (compose.yaml).
|
||||
ENTRYPOINT ["java", "-jar", "/app/gateway.jar"]
|
||||
|
||||
+18
-2
@@ -37,6 +37,7 @@ only the heat pump enabled. A mistake stops the start with a message naming the
|
||||
- `devices`: `heatPump`, `houseMeter`, `deye`, `fronius`, `bme280`, each with `enabled`. Modbus links need
|
||||
`minimumPauseMs` above 0. Enable the meter only after stopping `rpi-measurements`, BME280 after the IIO
|
||||
overlay, Deye and Fronius after the D7 network fix.
|
||||
The meter writes `electricity.PRAD2` (legacy, fixed) and `electricity.house_meter_phases` (per-phase kWh and kvarh).
|
||||
- `commands`: the setpoint write path (`POST /commands`), off unless `enabled` is true, and it needs the
|
||||
heat pump enabled. `tokenFile`, `restoreFile`, `minIntervalSeconds` (60), `readBackDelayMillis` (1000),
|
||||
`restoreCheckSeconds` (30) and `setpoints`: the allowlist `{name, register, min, max, step}`, limited to
|
||||
@@ -52,9 +53,24 @@ Do all of this before setting `commands.enabled` to true:
|
||||
3. Only the backend may reach port 8080 (S6; the gateway has no other access control): set `http.bind` to
|
||||
the WireGuard or LAN address the backend uses, or add a firewall rule on the Pi.
|
||||
4. Traefik must pass the `Host` header unchanged: the backend's CSRF check compares it with `Origin`.
|
||||
5. Confirm the CWU maximum (48 degrees C) in M2 against the controller documentation; the allowlist in
|
||||
`gateway.json` must not exceed it.
|
||||
5. The allowlist in `gateway.json` holds the agreed limits: CWU at most 48 degrees C, CO 5 to 25 (the code
|
||||
ceiling for CWU is 50).
|
||||
|
||||
Then set `commands.enabled` and `docker restart home-pump-gateway`. If the restore file is corrupt at start, the gateway
|
||||
keeps polling and serving, moves the file aside, and `/commands` answers 503 until a restart.
|
||||
|
||||
## Leases (ADR-011)
|
||||
|
||||
Every applied change, timed or "until changed", keeps a restore with a 15 minute lease. The restore runs at
|
||||
the earlier of its end time and the end of the lease, through the usual path (conflict check, read-back,
|
||||
backoff). `POST /commands/renew` (same token and throttle as `/commands`, no body) extends the lease of the
|
||||
restores that have not reached their run time and answers `{"renewed": n}`. The backend calls it every
|
||||
5 minutes. A lapsed or failing restore is never revived. Without the backend, or with the tunnel down, a
|
||||
change therefore ends 15 minutes after the last renewal. `restores.json` entries carry `leaseUntil`; one
|
||||
without it (older file) counts as lapsed and is restored on start. `GET /commands` lists both `due`
|
||||
(absent for "until changed") and `leaseUntil`.
|
||||
|
||||
## Version
|
||||
|
||||
`/health` has a top-level `version`: the release tag the image was built with (`VERSION` build arg and
|
||||
environment variable, set by the release workflow), `dev` for a local build.
|
||||
@@ -165,7 +165,9 @@ public final class Gateway implements AutoCloseable {
|
||||
result.add(new HeatPumpDevice(heatPumpModbus, heatPump.settings()));
|
||||
}
|
||||
if (Switchable.isOn(devices.houseMeter())) {
|
||||
result.add(new HouseMeterDevice(modbusOverTcp(devices.houseMeter().connection())));
|
||||
// Its RS485 converter forwards RTU frames as they are, not Modbus TCP.
|
||||
var meterLink = devices.houseMeter().connection();
|
||||
result.add(new HouseMeterDevice(new PduModbusClient(own(TcpTransport.rtuOverTcp(meterLink.settings())))));
|
||||
}
|
||||
if (Switchable.isOn(devices.deye())) {
|
||||
result.add(new DeyeDevice(deyeModbus(devices.deye())));
|
||||
|
||||
@@ -26,9 +26,21 @@ public final class DeyeDevice implements Device {
|
||||
|
||||
/** One request per block, in this order; the serial (3–7) is identity only and is skipped. */
|
||||
private static final List<Block> BLOCKS = List.of(
|
||||
new Block(514, 2), new Block(520, 2), new Block(526, 1), new Block(529, 1),
|
||||
new Block(586, 3), new Block(590, 1), new Block(598, 3), new Block(622, 4),
|
||||
new Block(650, 4), new Block(672, 2));
|
||||
new Block(500, 1), new Block(514, 22), new Block(540, 2), new Block(586, 6),
|
||||
new Block(598, 3), new Block(609, 1), new Block(622, 4), new Block(636, 1),
|
||||
new Block(650, 4), new Block(672, 8));
|
||||
|
||||
/** A 32-bit counter in /10 kWh, low word first. */
|
||||
private record Counter(String name, int lowRegister) {
|
||||
}
|
||||
|
||||
private static final List<Counter> COUNTERS = List.of(
|
||||
new Counter("battery_charge_total_kwh", 516),
|
||||
new Counter("battery_discharge_total_kwh", 518),
|
||||
new Counter("grid_bought_total_kwh", 522),
|
||||
new Counter("grid_sold_total_kwh", 524),
|
||||
new Counter("load_total_kwh", 527),
|
||||
new Counter("pv_total_kwh", 534));
|
||||
|
||||
private static final List<Field> FIELDS = List.of(
|
||||
new Field("battery_charge_today_kwh", 514, scaled(10)),
|
||||
@@ -53,7 +65,17 @@ public final class DeyeDevice implements Device {
|
||||
new Field("load_power_l3_w", 652, scaled(1)),
|
||||
new Field("load_power_w", 653, scaled(1)),
|
||||
new Field("pv1_power_w", 672, scaled(1)),
|
||||
new Field("pv2_power_w", 673, scaled(1)));
|
||||
new Field("pv2_power_w", 673, scaled(1)),
|
||||
new Field("run_state", 500, scaled(1)),
|
||||
new Field("inverter_temperature_c", 540, x -> (x - 1000) / 10),
|
||||
new Field("heatsink_temperature_c", 541, x -> (x - 1000) / 10),
|
||||
new Field("battery_current_a", 591, x -> signed(x) / 100),
|
||||
new Field("grid_frequency_hz", 609, scaled(100)),
|
||||
new Field("inverter_power_w", 636, DeyeDevice::signed),
|
||||
new Field("pv1_voltage_v", 676, scaled(10)),
|
||||
new Field("pv1_current_a", 677, scaled(10)),
|
||||
new Field("pv2_voltage_v", 678, scaled(10)),
|
||||
new Field("pv2_current_a", 679, scaled(10)));
|
||||
|
||||
private final ModbusClient modbus;
|
||||
|
||||
@@ -78,6 +100,11 @@ public final class DeyeDevice implements Device {
|
||||
for (Field field : FIELDS) {
|
||||
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
|
||||
}
|
||||
for (Counter counter : COUNTERS) {
|
||||
int low = words.get(counter.lowRegister());
|
||||
long high = words.get(counter.lowRegister() + 1);
|
||||
fields.put(counter.name(), ((high << 16) | low) / 10.0);
|
||||
}
|
||||
return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
|
||||
|
||||
/**
|
||||
* {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints
|
||||
* and pending restores. Only the backend may call it (S6): every request needs the shared token.
|
||||
* and pending restores, {@code POST /commands/renew} keeps the live restores alive (ADR-011)
|
||||
* and answers with how many it extended. Only the backend may call it (S6): every request needs the shared token.
|
||||
* HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side.
|
||||
* 503 when the write path is unavailable ({@link CommandsUnavailableException}).
|
||||
* Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}).
|
||||
@@ -39,10 +40,15 @@ class CommandsEndpoint implements HttpHandler {
|
||||
private static final Pattern REQUESTER = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
private static final String BEARER = "Bearer ";
|
||||
private static final String HEAT_PUMP = "heat_pump";
|
||||
private static final String COMMANDS_PATH = "/commands";
|
||||
private static final String RENEW_PATH = "/commands/renew";
|
||||
|
||||
private record SetpointView(String name, int register, int min, int max, int step, Integer value) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private record Listing(List<SetpointView> setpoints, List<Restore> restores) {
|
||||
}
|
||||
|
||||
@@ -66,7 +72,8 @@ class CommandsEndpoint implements HttpHandler {
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
var method = exchange.getRequestMethod();
|
||||
if (!exchange.getRequestURI().getPath().equals("/commands")) {
|
||||
var path = exchange.getRequestURI().getPath();
|
||||
if (!path.equals(COMMANDS_PATH) && !path.equals(RENEW_PATH)) {
|
||||
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
|
||||
} else if (throttle.isBlocked()) {
|
||||
exchange.getResponseHeaders().set("Retry-After",
|
||||
@@ -77,14 +84,16 @@ class CommandsEndpoint implements HttpHandler {
|
||||
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
|
||||
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
|
||||
} else {
|
||||
dispatch(exchange, method);
|
||||
dispatch(exchange, method, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatch(HttpExchange exchange, String method) throws IOException {
|
||||
private void dispatch(HttpExchange exchange, String method, String path) throws IOException {
|
||||
try {
|
||||
if (method.equals("GET")) {
|
||||
if (path.equals(RENEW_PATH)) {
|
||||
renew(exchange, method);
|
||||
} else if (method.equals("GET")) {
|
||||
respond(exchange, 200, listing());
|
||||
} else if (method.equals("POST")) {
|
||||
post(exchange);
|
||||
@@ -97,6 +106,15 @@ class CommandsEndpoint implements HttpHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private void renew(HttpExchange exchange, String method) throws IOException {
|
||||
if (method.equals("POST")) {
|
||||
respond(exchange, 200, new Renewal(commands.renewLeases()));
|
||||
} else {
|
||||
exchange.getResponseHeaders().set("Allow", "POST");
|
||||
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean authorized(HttpExchange exchange) {
|
||||
var header = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (header == null || !header.startsWith(BEARER)) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.net.InetSocketAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@@ -28,7 +29,7 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record Health(String status, List<DeviceHealth> devices) {
|
||||
private record Health(String status, String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
record Error(String error) {
|
||||
@@ -39,6 +40,9 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
|
||||
|
||||
/** The release tag, set as VERSION by the image; "dev" for a local build. */
|
||||
private static final String VERSION = Objects.requireNonNullElse(System.getenv("VERSION"), "dev");
|
||||
|
||||
public static final int MIN_TOKEN_LENGTH = CommandsEndpoint.MIN_TOKEN_LENGTH;
|
||||
|
||||
private final Snapshot snapshot;
|
||||
@@ -101,7 +105,7 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
.allMatch(GatewayHttpServer::isUp);
|
||||
boolean allUp = devices.stream().allMatch(GatewayHttpServer::isUp);
|
||||
var status = !criticalUp ? "DOWN" : allUp ? "UP" : "DEGRADED";
|
||||
respond(exchange, criticalUp ? 200 : 503, new Health(status, devices));
|
||||
respond(exchange, criticalUp ? 200 : 503, new Health(status, VERSION, devices));
|
||||
}
|
||||
|
||||
private static boolean isUp(DeviceHealth device) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
@@ -14,11 +17,17 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
|
||||
/**
|
||||
* The Eastron SDM630 house meter: all input registers as the legacy PRAD2 point, plus the
|
||||
* per-phase energy counters as a second point of the same poll.
|
||||
*/
|
||||
public final class HouseMeterDevice implements Device {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HouseMeterDevice.class);
|
||||
private static final Map<String, String> TAGS = Map.of("location", "dom-szczepancowa");
|
||||
private static final int UNIT_ID = 1;
|
||||
private static final int MAX_REGISTERS_PER_REQUEST = 125;
|
||||
private static final Duration INTERVAL = Duration.ofSeconds(60);
|
||||
@@ -29,6 +38,7 @@ public final class HouseMeterDevice implements Device {
|
||||
|
||||
private final ModbusClient modbus;
|
||||
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
|
||||
private final Block phaseEnergyBlock = blocksOf(Sdm630Registers.PHASE_ENERGY).getFirst();
|
||||
|
||||
public HouseMeterDevice(ModbusClient modbus) {
|
||||
this.modbus = modbus;
|
||||
@@ -53,8 +63,25 @@ public final class HouseMeterDevice implements Device {
|
||||
if (fields.isEmpty()) {
|
||||
throw new IOException("house meter returned no finite value");
|
||||
}
|
||||
return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
List<Reading> readings = new ArrayList<>();
|
||||
readings.add(new Reading("electricity", "PRAD2", TAGS, fields, now));
|
||||
readPhaseEnergy(now).ifPresent(readings::add);
|
||||
return readings;
|
||||
}
|
||||
|
||||
/** Extra data must never cost the legacy point: a failed request is logged and skipped. */
|
||||
private Optional<Reading> readPhaseEnergy(Instant now) {
|
||||
Map<String, Double> fields = new LinkedHashMap<>();
|
||||
try {
|
||||
readBlock(phaseEnergyBlock, fields);
|
||||
} catch (IOException e) {
|
||||
log.warn("House meter phase energy not read, PRAD2 is still written", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
if (fields.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new Reading("electricity", "house_meter_phases", TAGS, fields, now));
|
||||
}
|
||||
|
||||
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the
|
||||
* field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled.
|
||||
* The batch number is the library's: registers of one batch are fetched with one request.
|
||||
* The per-phase energy counters below have their own measurement.
|
||||
*/
|
||||
final class Sdm630Registers {
|
||||
|
||||
@@ -88,6 +90,30 @@ final class Sdm630Registers {
|
||||
new Register("total_energy_reactive", 0x0158, 4)
|
||||
);
|
||||
|
||||
private static final int PHASE_ENERGY_FIRST = 0x015a;
|
||||
private static final int PHASES = 3;
|
||||
private static final List<String> PHASE_ENERGY_KINDS = List.of(
|
||||
"import_energy_active_kwh", "export_energy_active_kwh", "total_energy_active_kwh",
|
||||
"import_energy_reactive_kvarh", "export_energy_reactive_kvarh",
|
||||
"total_energy_reactive_kvarh");
|
||||
|
||||
/**
|
||||
* Per-phase energy counters, 0x015A to 0x017D: for each kind in turn the phases L1, L2, L3.
|
||||
* All are fetched with one request.
|
||||
*/
|
||||
static final List<Register> PHASE_ENERGY = phaseEnergy();
|
||||
|
||||
private static List<Register> phaseEnergy() {
|
||||
List<Register> registers = new ArrayList<>();
|
||||
for (String kind : PHASE_ENERGY_KINDS) {
|
||||
for (int phase = 1; phase <= PHASES; phase++) {
|
||||
int address = PHASE_ENERGY_FIRST + WORDS_PER_VALUE * registers.size();
|
||||
registers.add(new Register("l" + phase + "_" + kind, address, 1));
|
||||
}
|
||||
}
|
||||
return List.copyOf(registers);
|
||||
}
|
||||
|
||||
private Sdm630Registers() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/** How one request PDU and its response travel over the socket of a {@link TcpTransport}. */
|
||||
interface Framing {
|
||||
|
||||
byte[] frame(int unitId, byte[] pdu);
|
||||
|
||||
/** The response PDU, function code first, for the request framed last. */
|
||||
byte[] readResponse(DataInputStream in, int unitId) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** Modbus TCP: an MBAP header with a transaction id before the PDU. */
|
||||
final class MbapFraming implements Framing {
|
||||
|
||||
private static final int HEADER_LENGTH = 7;
|
||||
|
||||
private int transactionId;
|
||||
|
||||
@Override
|
||||
public byte[] frame(int unitId, byte[] pdu) {
|
||||
transactionId = (transactionId + 1) & 0xFFFF;
|
||||
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
|
||||
.putShort((short) transactionId)
|
||||
.putShort((short) 0)
|
||||
.putShort((short) (pdu.length + 1))
|
||||
.put((byte) unitId)
|
||||
.put(pdu)
|
||||
.array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readResponse(DataInputStream in, int expectedUnitId) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
|
||||
if (header.remaining() < HEADER_LENGTH) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
int id = header.getShort() & 0xFFFF;
|
||||
int protocol = header.getShort() & 0xFFFF;
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
if (protocol != 0 || id != transactionId || unitId != expectedUnitId || length < 2) {
|
||||
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
|
||||
.formatted(id, protocol, length, unitId));
|
||||
}
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
if (pdu.length < length - 1) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/**
|
||||
* Modbus RTU frames (unit, PDU, CRC-16) sent as they are over TCP, as a plain RS485 converter
|
||||
* forwards them. There is no length header, so the response length follows from its function code;
|
||||
* only reads are framed, as the house meter is never written.
|
||||
*/
|
||||
final class RtuFraming implements Framing {
|
||||
|
||||
private static final int CRC_LENGTH = 2;
|
||||
|
||||
@Override
|
||||
public byte[] frame(int unitId, byte[] pdu) {
|
||||
ByteBuffer frame = ByteBuffer.allocate(1 + pdu.length + CRC_LENGTH).put((byte) unitId).put(pdu);
|
||||
int crc = crc(frame.array(), 1 + pdu.length);
|
||||
return frame.put((byte) crc).put((byte) (crc >>> 8)).array();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readResponse(DataInputStream in, int expectedUnitId) throws IOException {
|
||||
byte[] head = readExactly(in, 2);
|
||||
int unitId = head[0] & 0xFF;
|
||||
int function = head[1] & 0xFF;
|
||||
if (unitId != expectedUnitId) {
|
||||
throw new IOException("RTU response from unit " + unitId + ", expected " + expectedUnitId);
|
||||
}
|
||||
byte[] body = switch (function) {
|
||||
case 3, 4 -> withCount(in);
|
||||
default -> {
|
||||
if ((function & 0x80) == 0) {
|
||||
throw new IOException("RTU response with unsupported function " + function);
|
||||
}
|
||||
yield readExactly(in, 1);
|
||||
}
|
||||
};
|
||||
byte[] crcBytes = readExactly(in, CRC_LENGTH);
|
||||
byte[] frame = ByteBuffer.allocate(2 + body.length).put(head).put(body).array();
|
||||
int expected = crc(frame, frame.length);
|
||||
int received = (crcBytes[0] & 0xFF) | (crcBytes[1] & 0xFF) << 8;
|
||||
if (received != expected) {
|
||||
throw new IOException("RTU response CRC %04x, expected %04x".formatted(received, expected));
|
||||
}
|
||||
return ByteBuffer.allocate(1 + body.length).put((byte) function).put(body).array();
|
||||
}
|
||||
|
||||
private static byte[] withCount(DataInputStream in) throws IOException {
|
||||
int count = readExactly(in, 1)[0] & 0xFF;
|
||||
return ByteBuffer.allocate(1 + count).put((byte) count).put(readExactly(in, count)).array();
|
||||
}
|
||||
|
||||
private static byte[] readExactly(DataInputStream in, int length) throws IOException {
|
||||
byte[] bytes = in.readNBytes(length);
|
||||
if (bytes.length < length) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Modbus CRC-16: polynomial 0xA001, start 0xFFFF; sent low byte first. */
|
||||
static int crc(byte[] bytes, int length) {
|
||||
int crc = 0xFFFF;
|
||||
for (int i = 0; i < length; i++) {
|
||||
crc ^= bytes[i] & 0xFF;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -13,10 +12,11 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Modbus TCP (MBAP). The MK01 gateway serves a single client and a second connection drops the
|
||||
* first, so requests never overlap and the socket is reused for the requests of one poll. It is
|
||||
* closed after a few idle seconds: MK01 resets a connection left idle for minutes, which cost every
|
||||
* other poll, and while one is open the vendor's app cannot connect.
|
||||
* Modbus over a TCP socket: Modbus TCP (MBAP) or RTU frames as an RS485 converter forwards them.
|
||||
* The MK01 gateway serves a single client and a second connection drops the first, so requests
|
||||
* never overlap and the socket is reused for the requests of one poll. It is closed after a few
|
||||
* idle seconds: MK01 resets a connection left idle for minutes, which cost every other poll, and
|
||||
* while one is open the vendor's app cannot connect.
|
||||
*/
|
||||
public final class TcpTransport implements ModbusTransport {
|
||||
|
||||
@@ -24,26 +24,32 @@ public final class TcpTransport implements ModbusTransport {
|
||||
Duration minimumPause) {
|
||||
}
|
||||
|
||||
private static final int HEADER_LENGTH = 7;
|
||||
static final Duration IDLE_CLOSE = Duration.ofSeconds(5);
|
||||
|
||||
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Settings settings;
|
||||
private final Framing framing;
|
||||
private final Duration idleClose;
|
||||
private final ScheduledExecutorService idleCloser = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("modbus-idle-close").factory());
|
||||
private Socket socket;
|
||||
private DataInputStream in;
|
||||
private int transactionId;
|
||||
private long lastResponseNanos;
|
||||
|
||||
/** Modbus TCP, as MK01 speaks it. */
|
||||
public TcpTransport(Settings settings) {
|
||||
this(settings, IDLE_CLOSE);
|
||||
this(settings, new MbapFraming(), IDLE_CLOSE);
|
||||
}
|
||||
|
||||
TcpTransport(Settings settings, Duration idleClose) {
|
||||
/** RTU frames with their CRC and no MBAP header, as a plain RS485 converter forwards them. */
|
||||
public static TcpTransport rtuOverTcp(Settings settings) {
|
||||
return new TcpTransport(settings, new RtuFraming(), IDLE_CLOSE);
|
||||
}
|
||||
|
||||
TcpTransport(Settings settings, Framing framing, Duration idleClose) {
|
||||
this.settings = settings;
|
||||
this.framing = framing;
|
||||
this.idleClose = idleClose;
|
||||
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
|
||||
}
|
||||
@@ -62,9 +68,8 @@ public final class TcpTransport implements ModbusTransport {
|
||||
try {
|
||||
awaitPause();
|
||||
connectIfNeeded();
|
||||
int id = nextTransactionId();
|
||||
socket.getOutputStream().write(frame(id, unitId, requestPdu));
|
||||
byte[] response = readResponse(id, unitId);
|
||||
socket.getOutputStream().write(framing.frame(unitId, requestPdu));
|
||||
byte[] response = framing.readResponse(in, unitId);
|
||||
lastResponseNanos = System.nanoTime();
|
||||
if (!idleCloser.isShutdown()) {
|
||||
idleCloser.schedule(this::closeIfIdle, idleClose.toNanos(), TimeUnit.NANOSECONDS);
|
||||
@@ -130,41 +135,6 @@ public final class TcpTransport implements ModbusTransport {
|
||||
in = new DataInputStream(candidate.getInputStream());
|
||||
}
|
||||
|
||||
private int nextTransactionId() {
|
||||
transactionId = (transactionId + 1) & 0xFFFF;
|
||||
return transactionId;
|
||||
}
|
||||
|
||||
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
|
||||
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
|
||||
.putShort((short) transactionId)
|
||||
.putShort((short) 0)
|
||||
.putShort((short) (pdu.length + 1))
|
||||
.put((byte) unitId)
|
||||
.put(pdu)
|
||||
.array();
|
||||
}
|
||||
|
||||
private byte[] readResponse(int expectedTransactionId, int expectedUnitId) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
|
||||
if (header.remaining() < HEADER_LENGTH) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
int id = header.getShort() & 0xFFFF;
|
||||
int protocol = header.getShort() & 0xFFFF;
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
if (protocol != 0 || id != expectedTransactionId || unitId != expectedUnitId || length < 2) {
|
||||
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
|
||||
.formatted(id, protocol, length, unitId));
|
||||
}
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
if (pdu.length < length - 1) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private void closeSocket() {
|
||||
if (socket == null) {
|
||||
return;
|
||||
|
||||
@@ -88,25 +88,31 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** The file format: every field required except otherApplied, which only a lost reply sets. */
|
||||
/**
|
||||
* The file format: setpoint, register and both values are required. Without due the change lasts
|
||||
* until changed; without leaseUntil (a file from before ADR-011) the lease counts as lapsed.
|
||||
* otherApplied only a lost reply sets.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Stored(@JsonProperty(required = true) String setpoint,
|
||||
@JsonProperty(required = true) int register,
|
||||
@JsonProperty(required = true) int originalValue,
|
||||
@JsonProperty(required = true) int appliedValue,
|
||||
@JsonProperty(required = true) Instant due,
|
||||
Instant due,
|
||||
Instant leaseUntil,
|
||||
Integer otherApplied) {
|
||||
|
||||
static Stored of(Restore r) {
|
||||
return new Stored(r.setpoint(), r.register(), r.originalValue(), r.appliedValue(), r.due(),
|
||||
r.otherApplied());
|
||||
r.leaseUntil(), r.otherApplied());
|
||||
}
|
||||
|
||||
Restore restore() throws IOException {
|
||||
if (setpoint == null || due == null) {
|
||||
throw new IOException("restore without setpoint or due time");
|
||||
if (setpoint == null) {
|
||||
throw new IOException("restore without setpoint");
|
||||
}
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, otherApplied);
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due,
|
||||
leaseUntil == null ? Instant.EPOCH : leaseUntil, otherApplied);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,13 +117,12 @@ public class CommandService implements Commands {
|
||||
Integer old = null;
|
||||
try {
|
||||
old = registers.read(register);
|
||||
var restoreAt = command.duration() == null ? null : now.plus(command.duration());
|
||||
if (restoreAt != null) {
|
||||
// The first original stays even if the panel changed the register since (ADR-006).
|
||||
var original = previous == null ? old : previous.originalValue();
|
||||
// Saved before the write, so a write never happens without its restore on disk.
|
||||
remember(new Restore(setpoint.name(), register, original, command.value(), restoreAt), previous);
|
||||
}
|
||||
var due = command.duration() == null ? null : now.plus(command.duration());
|
||||
// The first original stays even if the panel changed the register since (ADR-006).
|
||||
var original = previous == null ? old : previous.originalValue();
|
||||
// Saved before the write, so a write never happens without its restore on disk.
|
||||
remember(new Restore(setpoint.name(), register, original, command.value(), due,
|
||||
now.plus(Restore.LEASE)), previous);
|
||||
lastWrite.put(register, nanoTime.getAsLong());
|
||||
attempted = true;
|
||||
registers.write(register, command.value());
|
||||
@@ -133,11 +132,8 @@ public class CommandService implements Commands {
|
||||
return failedWrite(command, setpoint, old, previous, "read back " + actual + ", expected "
|
||||
+ command.value());
|
||||
}
|
||||
if (restoreAt == null && restores.containsKey(register)) {
|
||||
forget(register);
|
||||
}
|
||||
audit(setpoint.name(), setpoint.register(), command.requestedBy(), "APPLIED", old, command.value());
|
||||
return new CommandResult(Status.APPLIED, "applied", restoreAt);
|
||||
return new CommandResult(Status.APPLIED, "applied", due);
|
||||
} catch (IOException e) {
|
||||
return attempted ? failedWrite(command, setpoint, old, previous, e.getMessage())
|
||||
: failed(command, setpoint, old, e.getMessage());
|
||||
@@ -148,12 +144,12 @@ public class CommandService implements Commands {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first boost's restore must survive a failed second one. The failed write may still have
|
||||
* The first change's restore must survive a failed second one. The failed write may still have
|
||||
* landed (a lost reply), so the register can hold either value and both count as ours.
|
||||
*/
|
||||
private CommandResult failedWrite(Command command, Setpoint setpoint, Integer old, Restore previous,
|
||||
String message) {
|
||||
if (previous != null && command.duration() != null) {
|
||||
if (previous != null) {
|
||||
restores.put(previous.register(), previous.alsoApplied(command.value()));
|
||||
persist();
|
||||
}
|
||||
@@ -166,7 +162,29 @@ public class CommandService implements Commands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the restores whose time has come, oldest first. One that fails stays pending and is
|
||||
* Extends the lease of every restore that has not reached its run time yet (ADR-011); one that
|
||||
* is due, lapsed or failing is never revived. Returns how many leases were extended.
|
||||
*/
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
lock.lock();
|
||||
try {
|
||||
var now = clock.instant();
|
||||
var renewed = restores.values().stream()
|
||||
.filter(restore -> restore.runAt().isAfter(now))
|
||||
.toList();
|
||||
renewed.forEach(restore -> restores.put(restore.register(), restore.leasedUntil(now.plus(Restore.LEASE))));
|
||||
if (!renewed.isEmpty()) {
|
||||
persist();
|
||||
}
|
||||
return renewed.size();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the restores whose time has come (due or lease lapsed), oldest first. One that fails stays pending and is
|
||||
* retried after a growing wait; the rate limit of S4 holds for restores too.
|
||||
*/
|
||||
public void runDueRestores() {
|
||||
@@ -177,9 +195,9 @@ public class CommandService implements Commands {
|
||||
}
|
||||
var now = clock.instant();
|
||||
restores.values().stream()
|
||||
.filter(restore -> !restore.due().isAfter(now))
|
||||
.filter(restore -> !restore.runAt().isAfter(now))
|
||||
.filter(restore -> !isWaiting(restore))
|
||||
.sorted(Comparator.comparing(Restore::due))
|
||||
.sorted(Comparator.comparing(Restore::runAt))
|
||||
.toList()
|
||||
.forEach(this::tryRestore);
|
||||
} finally {
|
||||
|
||||
@@ -12,6 +12,9 @@ public interface Commands {
|
||||
|
||||
CommandResult apply(Command command);
|
||||
|
||||
/** Keeps the live restores alive (ADR-011); returns how many leases were extended. */
|
||||
int renewLeases();
|
||||
|
||||
List<Setpoint> setpoints();
|
||||
|
||||
List<Restore> pendingRestores();
|
||||
|
||||
@@ -15,6 +15,11 @@ public record DisabledCommands(String reason) implements Commands {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Setpoint> setpoints() {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
|
||||
@@ -50,6 +50,11 @@ public class RestoreScheduler implements AutoCloseable {
|
||||
return inner.apply(command);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return inner.renewLeases();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Setpoint> setpoints() {
|
||||
return inner.setpoints();
|
||||
|
||||
@@ -2,19 +2,31 @@ package home.gateway.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A pending timed restore (ADR-006): put {@code originalValue} back at {@code due}, but only if the
|
||||
* register still holds {@code appliedValue}; otherwise someone changed it and we leave it.
|
||||
* {@code otherApplied} is set when a later boost failed after its write may have landed (a lost
|
||||
* reply): the register then holds either value and both are ours.
|
||||
* A pending restore (ADR-006, ADR-011): put {@code originalValue} back, but only if the register
|
||||
* still holds {@code appliedValue}; otherwise someone changed it and we leave it.
|
||||
* It runs at {@link #runAt()}: {@code due}, where a change is timed (null: until changed), or the
|
||||
* end of the lease, whichever comes first. The backend keeps the lease alive; a lost backend or
|
||||
* tunnel lets it lapse. {@code otherApplied} is set when a later boost failed after its write may
|
||||
* have landed (a lost reply): the register then holds either value and both are ours.
|
||||
*/
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due,
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Instant due, Instant leaseUntil,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Integer otherApplied) {
|
||||
|
||||
public Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
this(setpoint, register, originalValue, appliedValue, due, null);
|
||||
/** How long a change lasts without a renewal. */
|
||||
public static final Duration LEASE = Duration.ofMinutes(15);
|
||||
|
||||
public Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due,
|
||||
Instant leaseUntil) {
|
||||
this(setpoint, register, originalValue, appliedValue, due, leaseUntil, null);
|
||||
}
|
||||
|
||||
public Instant runAt() {
|
||||
return due == null || leaseUntil.isBefore(due) ? leaseUntil : due;
|
||||
}
|
||||
|
||||
public boolean isOurs(int current) {
|
||||
@@ -22,6 +34,10 @@ public record Restore(String setpoint, int register, int originalValue, int appl
|
||||
}
|
||||
|
||||
public Restore alsoApplied(int value) {
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, value);
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, leaseUntil, value);
|
||||
}
|
||||
|
||||
public Restore leasedUntil(Instant newLeaseUntil) {
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, newLeaseUntil, otherApplied);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,12 @@ class DeyeDeviceTest {
|
||||
registers.putAll(Map.of(622, 390, 623, 52, 624, 203, 625, 645));
|
||||
registers.putAll(Map.of(650, 364, 651, 26, 652, 180, 653, 570));
|
||||
registers.putAll(Map.of(672, 0, 673, 0));
|
||||
registers.putAll(Map.of(500, 2, 540, 1250, 541, 1406, 591, 65536 - 5, 609, 4995,
|
||||
636, 1388));
|
||||
registers.putAll(Map.of(676, 5493, 677, 13, 678, 5789, 679, 9));
|
||||
registers.putAll(Map.of(516, 4537, 518, 1621, 522, 7150, 523, 2, 524, 44780,
|
||||
525, 2));
|
||||
registers.putAll(Map.of(527, 31750, 528, 2, 534, 48834, 535, 2));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,7 +53,7 @@ class DeyeDeviceTest {
|
||||
}
|
||||
int[] words = new int[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
words[i] = registers.get(address + i);
|
||||
words[i] = registers.getOrDefault(address + i, 0);
|
||||
}
|
||||
return words;
|
||||
}
|
||||
@@ -94,7 +100,35 @@ class DeyeDeviceTest {
|
||||
assertEquals(22.3, fields.get("grid_sold_today_kwh"), 1e-9);
|
||||
assertEquals(24.2, fields.get("load_today_kwh"), 1e-9);
|
||||
assertEquals(29.9, fields.get("pv_today_kwh"), 1e-9);
|
||||
assertEquals(23, fields.size());
|
||||
assertEquals(39, fields.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesTheMoreRegistersVerifiedOnTheLiveInverter() throws IOException {
|
||||
Map<String, Double> fields = onlyReading().fields();
|
||||
|
||||
assertEquals(2.0, fields.get("run_state"));
|
||||
assertEquals(25.0, fields.get("inverter_temperature_c"), 1e-9);
|
||||
assertEquals(40.6, fields.get("heatsink_temperature_c"), 1e-9);
|
||||
assertEquals(-0.05, fields.get("battery_current_a"), 1e-9);
|
||||
assertEquals(49.95, fields.get("grid_frequency_hz"), 1e-9);
|
||||
assertEquals(1388.0, fields.get("inverter_power_w"));
|
||||
assertEquals(549.3, fields.get("pv1_voltage_v"), 1e-9);
|
||||
assertEquals(1.3, fields.get("pv1_current_a"), 1e-9);
|
||||
assertEquals(578.9, fields.get("pv2_voltage_v"), 1e-9);
|
||||
assertEquals(0.9, fields.get("pv2_current_a"), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void joinsThe32BitTotalsLowWordFirst() throws IOException {
|
||||
Map<String, Double> fields = onlyReading().fields();
|
||||
|
||||
assertEquals(453.7, fields.get("battery_charge_total_kwh"), 1e-9);
|
||||
assertEquals(162.1, fields.get("battery_discharge_total_kwh"), 1e-9);
|
||||
assertEquals(13822.2, fields.get("grid_bought_total_kwh"), 1e-9);
|
||||
assertEquals(17585.2, fields.get("grid_sold_total_kwh"), 1e-9);
|
||||
assertEquals(16282.2, fields.get("load_total_kwh"), 1e-9);
|
||||
assertEquals(17990.6, fields.get("pv_total_kwh"), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -137,8 +171,8 @@ class DeyeDeviceTest {
|
||||
void readsSmallBlocksInOrderWithoutTheSerial() throws IOException {
|
||||
onlyReading();
|
||||
|
||||
assertEquals(List.of("1:514+2", "1:520+2", "1:526+1", "1:529+1", "1:586+3", "1:590+1",
|
||||
"1:598+3", "1:622+4", "1:650+4", "1:672+2"), inverter.requests);
|
||||
assertEquals(List.of("1:500+1", "1:514+22", "1:540+2", "1:586+6", "1:598+3", "1:609+1",
|
||||
"1:622+4", "1:636+1", "1:650+4", "1:672+8"), inverter.requests);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -68,6 +68,7 @@ class CommandsEndpointTest {
|
||||
private final Snapshot snapshot = new Snapshot();
|
||||
private final List<Command> received = new ArrayList<>();
|
||||
private CommandResult nextResult;
|
||||
private int renewals;
|
||||
private GatewayHttpServer server;
|
||||
|
||||
private final Commands commands = new Commands() {
|
||||
@@ -77,6 +78,12 @@ class CommandsEndpointTest {
|
||||
return nextResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
renewals++;
|
||||
return 2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Setpoint> setpoints() {
|
||||
return List.of(new Setpoint("co_economy", 4131, 5, 25, 1),
|
||||
@@ -87,7 +94,8 @@ class CommandsEndpointTest {
|
||||
|
||||
@Override
|
||||
public List<Restore> pendingRestores() {
|
||||
return List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z")));
|
||||
return List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"),
|
||||
Instant.parse("2026-10-24T10:15:00Z")));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +112,12 @@ class CommandsEndpointTest {
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(String method, String body, String... headers) throws Exception {
|
||||
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/commands"))
|
||||
return sendTo("/commands", method, body, headers);
|
||||
}
|
||||
|
||||
private HttpResponse<String> sendTo(String path, String method, String body, String... headers)
|
||||
throws Exception {
|
||||
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofString(body));
|
||||
if (body != null) {
|
||||
@@ -256,6 +269,43 @@ class CommandsEndpointTest {
|
||||
assertEquals(exampleJson("setpoints.json"), json.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewExtendsTheLeasesAndReportsHowMany() throws Exception {
|
||||
var response = sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(2, json.readTree(response.body()).get("renewed").asInt());
|
||||
assertEquals(1, renewals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewNeedsTheTokenAndOnlyAcceptsPost() throws Exception {
|
||||
assertEquals(401, sendTo("/commands/renew", "POST", null).statusCode());
|
||||
now.updateAndGet(time -> time.plus(Duration.ofMinutes(2)));
|
||||
assertEquals(405, sendTo("/commands/renew", "GET", null, "Authorization", "Bearer " + BEARER).statusCode());
|
||||
assertEquals(0, renewals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedTokensOnRenewCountForTheSameThrottle() throws Exception {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(401, sendTo("/commands/renew", "POST", null).statusCode());
|
||||
}
|
||||
|
||||
assertEquals(429, sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
|
||||
assertEquals(0, renewals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewIs503WhenTheWritePathIsUnavailable() throws Exception {
|
||||
server.close();
|
||||
server = new GatewayHttpServer(snapshot, clock, new InetSocketAddress(0), Set.of());
|
||||
server.serveCommands(new DisabledCommands("restore file was corrupt"), BEARER);
|
||||
server.start();
|
||||
|
||||
assertEquals(503, sendTo("/commands/renew", "POST", null, "Authorization", "Bearer " + BEARER).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getHasNullValuesBeforeTheFirstPoll() throws Exception {
|
||||
var body = json.readTree(authorized("GET", null).body());
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -118,6 +119,7 @@ class GatewayHttpServerTest {
|
||||
assertEquals(200, response.statusCode());
|
||||
var body = json.readTree(response.body());
|
||||
assertEquals("UP", body.get("status").asText());
|
||||
assertFalse(body.get("version").asText().isBlank());
|
||||
assertEquals("UP", device(body, "pump").get("status").asText());
|
||||
assertEquals("2026-10-24T10:00:00Z", device(body, "pump").get("lastSuccess").asText());
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class HouseMeterDeviceTest {
|
||||
|
||||
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(1, readings.size());
|
||||
assertEquals(2, readings.size());
|
||||
Reading reading = readings.getFirst();
|
||||
assertEquals("electricity", reading.database());
|
||||
assertEquals("PRAD2", reading.measurement());
|
||||
@@ -44,13 +44,58 @@ class HouseMeterDeviceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsFourBlocksOfUnitOneOneAfterAnother() throws Exception {
|
||||
void reportsThePhaseEnergyCountersAsASecondPointOfTheSamePoll() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.setWords(0x015a, 1.5f);
|
||||
meter.setWords(0x0164, 2.5f);
|
||||
meter.setWords(0x017c, 3.5f);
|
||||
meter.setWords(0x016c, Float.NaN);
|
||||
|
||||
Reading reading = new HouseMeterDevice(meter).read(NOW).get(1);
|
||||
|
||||
assertEquals("electricity", reading.database());
|
||||
assertEquals("house_meter_phases", reading.measurement());
|
||||
assertEquals(Map.of("location", "dom-szczepancowa"), reading.tags());
|
||||
assertEquals(NOW, reading.time());
|
||||
assertEquals(1.5, reading.fields().get("l1_import_energy_active_kwh"));
|
||||
assertEquals(2.5, reading.fields().get("l3_export_energy_active_kwh"));
|
||||
assertEquals(3.5, reading.fields().get("l3_total_energy_reactive_kvarh"));
|
||||
assertFalse(reading.fields().containsKey("l1_import_energy_reactive_kvarh"));
|
||||
assertEquals(17, reading.fields().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsPrad2WhenThePhaseEnergyRequestFails() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("frequency", 50f);
|
||||
meter.failAt = 0x015a;
|
||||
|
||||
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(1, readings.size());
|
||||
assertEquals("PRAD2", readings.getFirst().measurement());
|
||||
assertEquals(50.0, readings.getFirst().fields().get("frequency"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsPrad2WhenThePhaseEnergyBlockComesBackShort() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("frequency", 50f);
|
||||
meter.shortAt = 0x015a;
|
||||
|
||||
List<Reading> readings = new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(List.of("PRAD2"), readings.stream().map(Reading::measurement).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsFourBlocksOfPrad2AndThePhaseEnergyBlockOfUnitOneOneAfterAnother() throws Exception {
|
||||
FakeMeter meter = new FakeMeter();
|
||||
meter.set("frequency", 50f);
|
||||
|
||||
new HouseMeterDevice(meter).read(NOW);
|
||||
|
||||
assertEquals(List.of("1:0+80", "1:80+28", "1:200+70", "1:334+12"), meter.requests);
|
||||
assertEquals(List.of("1:0+80", "1:80+28", "1:200+70", "1:334+12", "1:346+36"), meter.requests);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -142,9 +187,13 @@ class HouseMeterDeviceTest {
|
||||
void set(String name, float value) {
|
||||
Sdm630Registers.Register register = Sdm630Registers.INPUT.stream()
|
||||
.filter(r -> r.name().equals(name)).findFirst().orElseThrow();
|
||||
setWords(register.address(), value);
|
||||
}
|
||||
|
||||
void setWords(int address, float value) {
|
||||
int bits = Float.floatToRawIntBits(value);
|
||||
registers[register.address()] = bits >>> 16;
|
||||
registers[register.address() + 1] = bits & 0xFFFF;
|
||||
registers[address] = bits >>> 16;
|
||||
registers[address + 1] = bits & 0xFFFF;
|
||||
}
|
||||
|
||||
void fillWith(float value) {
|
||||
|
||||
@@ -8,16 +8,24 @@ import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Local Modbus TCP fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. */
|
||||
/**
|
||||
* Local Modbus fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. It speaks
|
||||
* Modbus TCP, or RTU frames over TCP when made with {@link #rtuOverTcp()}.
|
||||
*/
|
||||
public final class MockModbusServer implements Closeable {
|
||||
|
||||
private record Request(int transactionId, int unitId, byte[] pdu) {
|
||||
}
|
||||
|
||||
private final ServerSocket server;
|
||||
private final boolean rtu;
|
||||
private final Map<Integer, Integer> holding = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, Integer> input = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger connections = new AtomicInteger();
|
||||
@@ -26,15 +34,26 @@ public final class MockModbusServer implements Closeable {
|
||||
private volatile Duration responseDelay = Duration.ZERO;
|
||||
private volatile boolean dropNextRequest;
|
||||
private volatile boolean wrongTransactionId;
|
||||
private volatile boolean wrongCrc;
|
||||
private volatile boolean wrongUnit;
|
||||
private volatile boolean overlapped;
|
||||
private volatile boolean wrongWriteEcho;
|
||||
private volatile int writeException;
|
||||
|
||||
public MockModbusServer() throws IOException {
|
||||
this(false);
|
||||
}
|
||||
|
||||
private MockModbusServer(boolean rtu) throws IOException {
|
||||
this.rtu = rtu;
|
||||
server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
|
||||
Thread.ofVirtual().start(this::acceptLoop);
|
||||
}
|
||||
|
||||
static MockModbusServer rtuOverTcp() throws IOException {
|
||||
return new MockModbusServer(true);
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getLocalPort();
|
||||
}
|
||||
@@ -90,6 +109,16 @@ public final class MockModbusServer implements Closeable {
|
||||
wrongTransactionId = true;
|
||||
}
|
||||
|
||||
/** RTU only: answers the next request with a CRC that does not match. */
|
||||
void answerWithWrongCrc() {
|
||||
wrongCrc = true;
|
||||
}
|
||||
|
||||
/** Answers the next request as if another unit had sent it. */
|
||||
void answerFromAnotherUnit() {
|
||||
wrongUnit = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
server.close();
|
||||
@@ -112,15 +141,13 @@ public final class MockModbusServer implements Closeable {
|
||||
try (client) {
|
||||
DataInputStream in = new DataInputStream(client.getInputStream());
|
||||
while (true) {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
|
||||
if (header.remaining() < 7) {
|
||||
Request request = rtu ? readRtu(in) : readMbap(in);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
int transactionId = header.getShort() & 0xFFFF;
|
||||
header.getShort();
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
int transactionId = request.transactionId();
|
||||
int unitId = request.unitId();
|
||||
byte[] pdu = request.pdu();
|
||||
if (dropNextRequest) {
|
||||
dropNextRequest = false;
|
||||
return;
|
||||
@@ -131,7 +158,12 @@ public final class MockModbusServer implements Closeable {
|
||||
wrongTransactionId = false;
|
||||
transactionId++;
|
||||
}
|
||||
client.getOutputStream().write(frame(transactionId, unitId, answer(unitId, pdu)));
|
||||
byte[] answer = answer(unitId, pdu);
|
||||
if (wrongUnit) {
|
||||
wrongUnit = false;
|
||||
unitId++;
|
||||
}
|
||||
client.getOutputStream().write(rtu ? rtuFrame(unitId, answer) : frame(transactionId, unitId, answer));
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// the client went away, which is a normal end of a test
|
||||
@@ -140,6 +172,43 @@ public final class MockModbusServer implements Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
private static Request readMbap(DataInputStream in) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
|
||||
if (header.remaining() < 7) {
|
||||
return null;
|
||||
}
|
||||
int transactionId = header.getShort() & 0xFFFF;
|
||||
header.getShort();
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
return new Request(transactionId, unitId, in.readNBytes(length - 1));
|
||||
}
|
||||
|
||||
/** Every read request (FC3, FC4) is unit, function, four bytes and the CRC. */
|
||||
private static Request readRtu(DataInputStream in) throws IOException {
|
||||
byte[] frame = in.readNBytes(8);
|
||||
if (frame.length < 8) {
|
||||
return null;
|
||||
}
|
||||
int crc = RtuFraming.crc(frame, 6);
|
||||
if ((frame[6] & 0xFF) != (crc & 0xFF) || (frame[7] & 0xFF) != crc >>> 8) {
|
||||
throw new IOException("request with a wrong CRC");
|
||||
}
|
||||
return new Request(0, frame[0] & 0xFF, Arrays.copyOfRange(frame, 1, 6));
|
||||
}
|
||||
|
||||
private byte[] rtuFrame(int unitId, byte[] pdu) {
|
||||
byte[] frame = ByteBuffer.allocate(1 + pdu.length + 2).put((byte) unitId).put(pdu).array();
|
||||
int crc = RtuFraming.crc(frame, 1 + pdu.length);
|
||||
if (wrongCrc) {
|
||||
wrongCrc = false;
|
||||
crc ^= 1;
|
||||
}
|
||||
frame[frame.length - 2] = (byte) crc;
|
||||
frame[frame.length - 1] = (byte) (crc >>> 8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private void pause() {
|
||||
try {
|
||||
Thread.sleep(responseDelay);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The house meter's RS485 converter: RTU frames with their CRC over TCP, no MBAP header. */
|
||||
class RtuOverTcpTransportTest {
|
||||
|
||||
private static final byte[] READ_INPUT_0 = {4, 0, 0, 0, 2};
|
||||
|
||||
private MockModbusServer server;
|
||||
private TcpTransport transport;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = MockModbusServer.rtuOverTcp();
|
||||
server.input(0, 0x436B);
|
||||
server.input(1, 0xA8F6);
|
||||
transport = TcpTransport.rtuOverTcp(new TcpTransport.Settings(
|
||||
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() throws IOException {
|
||||
transport.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void framesTheRequestWithTheModbusCrc() {
|
||||
// The specification's example: read 10 holding registers from unit 1 ends in C5 CD.
|
||||
assertArrayEquals(new byte[] {1, 3, 0, 0, 0, 10, (byte) 0xC5, (byte) 0xCD},
|
||||
new RtuFraming().frame(1, new byte[] {3, 0, 0, 0, 10}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsInputRegisters() throws IOException {
|
||||
int[] words = new PduModbusClient(transport).readInputRegisters(1, 0, 2);
|
||||
|
||||
assertEquals(235.66f, Float.intBitsToFloat(words[0] << 16 | words[1]), 0.005f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsExceptionPduAsIs() throws IOException {
|
||||
assertArrayEquals(new byte[] {(byte) 0x84, 2}, transport.exchange(1, new byte[] {4, 0, 99, 0, 1}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAWrongCrcAndReconnectsAfterwards() throws IOException {
|
||||
server.answerWithWrongCrc();
|
||||
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
|
||||
|
||||
assertEquals(6, transport.exchange(1, READ_INPUT_0).length);
|
||||
assertEquals(2, server.connections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAnswerFromAnotherUnit() {
|
||||
server.answerFromAnotherUnit();
|
||||
|
||||
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class TcpTransportTest {
|
||||
transport.close();
|
||||
transport = new TcpTransport(new TcpTransport.Settings(
|
||||
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO),
|
||||
Duration.ofMillis(100));
|
||||
new MbapFraming(), Duration.ofMillis(100));
|
||||
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
|
||||
@@ -21,7 +21,7 @@ class JsonRestoreStoreTest {
|
||||
Path dir;
|
||||
|
||||
private final Restore restore = new Restore("cwu_comfort", 4144, 450, 500,
|
||||
Instant.parse("2026-10-24T11:00:00Z"));
|
||||
Instant.parse("2026-10-24T11:00:00Z"), Instant.parse("2026-10-24T10:15:00Z"));
|
||||
|
||||
@Test
|
||||
void missingFileIsEmpty() throws IOException {
|
||||
@@ -70,8 +70,7 @@ class JsonRestoreStoreTest {
|
||||
var cases = List.of("{not json", "", "garbage \u0000\u0001", "null", "{}", "[null]",
|
||||
"[{\"setpoint\":\"x\"}]", restoreJson("4144", "\"5\""), restoreJson("null", "5"),
|
||||
restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"),
|
||||
restoreJson("4144", "5").replace("\"x\"", "null"),
|
||||
restoreJson("4144", "5").replace("\"2026-10-24T11:00:00Z\"", "null"));
|
||||
restoreJson("4144", "5").replace("\"x\"", "null"));
|
||||
for (var content : cases) {
|
||||
var file = dir.resolve("restores.json");
|
||||
Files.writeString(file, content);
|
||||
@@ -90,7 +89,8 @@ class JsonRestoreStoreTest {
|
||||
@Test
|
||||
void aLostReplyValueSurvivesSaveAndLoad() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z")).alsoApplied(47);
|
||||
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z"),
|
||||
Instant.parse("2026-10-24T10:15:00Z")).alsoApplied(47);
|
||||
|
||||
new JsonRestoreStore(file).save(List.of(restore));
|
||||
|
||||
@@ -105,6 +105,28 @@ class JsonRestoreStoreTest {
|
||||
assertEquals(1, new JsonRestoreStore(file).load().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFileFromBeforeLeasesLoadsWithALapsedLease() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
Files.writeString(file, restoreJson("4144", "450"));
|
||||
|
||||
var loaded = new JsonRestoreStore(file).load().getFirst();
|
||||
|
||||
assertEquals(Instant.EPOCH, loaded.leaseUntil());
|
||||
assertEquals(Instant.parse("2026-10-24T11:00:00Z"), loaded.due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDueInTheFile() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
var untilChanged = new Restore("cwu_comfort", 4144, 450, 100, null, Instant.parse("2026-10-24T10:15:00Z"));
|
||||
|
||||
new JsonRestoreStore(file).save(List.of(untilChanged));
|
||||
|
||||
assertFalse(Files.readString(file).contains("due"));
|
||||
assertEquals(List.of(untilChanged), new JsonRestoreStore(file).load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterTheRenameTheNextLoadIsEmpty() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
|
||||
@@ -194,7 +194,7 @@ class CommandServiceTest {
|
||||
var result = service().apply(command("cwu_comfort", 50, HOUR));
|
||||
|
||||
assertEquals(T0.plus(HOUR), result.restoreAt());
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR));
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), T0.plus(Restore.LEASE));
|
||||
assertEquals(List.of(expected), stored);
|
||||
}
|
||||
|
||||
@@ -206,21 +206,23 @@ class CommandServiceTest {
|
||||
|
||||
service.apply(command("cwu_comfort", 48, Duration.ofHours(2)));
|
||||
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 48, clock.instant().plus(Duration.ofHours(2)));
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 48, clock.instant().plus(Duration.ofHours(2)),
|
||||
clock.instant().plus(Restore.LEASE));
|
||||
assertEquals(List.of(expected), service.pendingRestores());
|
||||
assertEquals(List.of(expected), stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastingCommandCancelsAPendingRestore() throws IOException {
|
||||
void lastingCommandKeepsTheOriginalAndDropsTheEndTime() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, HOUR));
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
|
||||
service.apply(command("cwu_comfort", 40, null));
|
||||
|
||||
assertTrue(service.pendingRestores().isEmpty());
|
||||
assertTrue(stored.isEmpty());
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 40, null, clock.instant().plus(Restore.LEASE));
|
||||
assertEquals(List.of(expected), service.pendingRestores());
|
||||
assertEquals(List.of(expected), stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -279,7 +281,7 @@ class CommandServiceTest {
|
||||
@Test
|
||||
void doesNothingBeforeDue() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(Duration.ofMinutes(59));
|
||||
advanceRenewing(service, Duration.ofMinutes(59));
|
||||
|
||||
service.runDueRestores();
|
||||
|
||||
@@ -382,7 +384,7 @@ class CommandServiceTest {
|
||||
var first = service();
|
||||
first.apply(command("cwu_comfort", 50, HOUR));
|
||||
first.apply(command("co_comfort", 25, Duration.ofHours(3)));
|
||||
clock.advance(Duration.ofHours(2));
|
||||
advanceRenewing(first, Duration.ofHours(2));
|
||||
|
||||
var restarted = service();
|
||||
try (var scheduler = new RestoreScheduler(restarted)) {
|
||||
@@ -461,7 +463,7 @@ class CommandServiceTest {
|
||||
}
|
||||
|
||||
private void assertFirstBoostStillPendingAcceptingSecond(CommandService service) {
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), 48);
|
||||
var expected = new Restore("cwu_comfort", 4144, 45, 50, T0.plus(HOUR), T0.plus(Restore.LEASE), 48);
|
||||
assertEquals(List.of(expected), service.pendingRestores());
|
||||
assertEquals(List.of(expected), stored);
|
||||
}
|
||||
@@ -525,11 +527,11 @@ class CommandServiceTest {
|
||||
|
||||
@Test
|
||||
void badStoredRestoresAreDroppedOnLoadAndNeverWritten() throws IOException {
|
||||
var good = new Restore("co_comfort", 4132, 20, 25, T0.minus(HOUR));
|
||||
var good = new Restore("co_comfort", 4132, 20, 25, T0.minus(HOUR), T0.minus(HOUR));
|
||||
stored.addAll(List.of(
|
||||
new Restore("cwu_comfort", 0, 0, 50, T0.minus(HOUR)),
|
||||
new Restore("gone", 4145, 45, 50, T0.minus(HOUR)),
|
||||
new Restore("cwu_comfort", 4144, 45, 99, T0.minus(HOUR), 20),
|
||||
new Restore("cwu_comfort", 0, 0, 50, T0.minus(HOUR), T0.minus(HOUR)),
|
||||
new Restore("gone", 4145, 45, 50, T0.minus(HOUR), T0.minus(HOUR)),
|
||||
new Restore("cwu_comfort", 4144, 45, 99, T0.minus(HOUR), T0.minus(HOUR), 20),
|
||||
good));
|
||||
registers.values.put(4132, 25);
|
||||
|
||||
@@ -690,19 +692,155 @@ class CommandServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedSaveAfterALastingCommandIsRetriedOnTheNextTick() throws IOException {
|
||||
void aFailedSaveAfterRenewingIsRetriedOnTheNextTick() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
storeFails = true;
|
||||
|
||||
assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 40, null)).status());
|
||||
assertEquals(1, stored.size());
|
||||
assertEquals(1, service.renewLeases());
|
||||
assertEquals(T0.plus(Restore.LEASE), stored.getFirst().leaseUntil());
|
||||
storeFails = false;
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(clock.instant().plus(Restore.LEASE), stored.getFirst().leaseUntil());
|
||||
}
|
||||
|
||||
private void advanceRenewing(CommandService service, Duration total) {
|
||||
for (var left = total; !left.isZero(); ) {
|
||||
var step = left.compareTo(Duration.ofMinutes(5)) < 0 ? left : Duration.ofMinutes(5);
|
||||
clock.advance(step);
|
||||
service.renewLeases();
|
||||
left = left.minus(step);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLapsedLeaseRestoresAnUntilChangedChange() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Restore.LEASE.minusSeconds(1));
|
||||
service.runDueRestores();
|
||||
assertEquals(50, registers.values.get(4144));
|
||||
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
assertTrue(service.pendingRestores().isEmpty());
|
||||
assertTrue(stored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aLapsedLeaseBeatsADistantEndTime() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, Duration.ofHours(10)));
|
||||
clock.advance(Restore.LEASE);
|
||||
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingKeepsAChangeBeyondTheLeaseAndSavesTheNewLease() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Duration.ofMinutes(10));
|
||||
|
||||
assertEquals(1, service.renewLeases());
|
||||
assertEquals(clock.instant().plus(Restore.LEASE), stored.getFirst().leaseUntil());
|
||||
clock.advance(Duration.ofMinutes(10));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(50, registers.values.get(4144));
|
||||
assertEquals(1, service.pendingRestores().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingNeverPushesARestoreBeyondItsEndTime() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, Duration.ofMinutes(20)));
|
||||
advanceRenewing(service, Duration.ofMinutes(19));
|
||||
assertEquals(50, registers.values.get(4144));
|
||||
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingDoesNotReviveALapsedLeaseBeforeItsRestoreRan() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Restore.LEASE);
|
||||
|
||||
assertEquals(0, service.renewLeases());
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingDoesNotReviveARestoreThatKeepsFailing() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Restore.LEASE);
|
||||
registers.failWrites = true;
|
||||
service.runDueRestores();
|
||||
var lease = service.pendingRestores().getFirst().leaseUntil();
|
||||
|
||||
assertEquals(0, service.renewLeases());
|
||||
|
||||
assertEquals(lease, service.pendingRestores().getFirst().leaseUntil());
|
||||
registers.failWrites = false;
|
||||
clock.advance(Duration.ofMinutes(1));
|
||||
service.runDueRestores();
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedWriteOnLapseGoesThroughTheBackoff() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Restore.LEASE);
|
||||
registers.failWrites = true;
|
||||
|
||||
for (var wait : List.of(1, 2, 5)) {
|
||||
var writes = registers.writes;
|
||||
service.runDueRestores();
|
||||
assertEquals(writes + 1, registers.writes);
|
||||
clock.advance(Duration.ofMinutes(wait).minusSeconds(1));
|
||||
service.runDueRestores();
|
||||
assertEquals(writes + 1, registers.writes);
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
}
|
||||
|
||||
assertEquals(3, audit.stream().filter(reading -> "FAILED".equals(reading.tags().get("status"))).count());
|
||||
assertEquals(1, service.pendingRestores().size());
|
||||
assertEquals(50, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRestoreFromBeforeLeasesIsRestoredOnStart() throws IOException {
|
||||
stored.add(new Restore("cwu_comfort", 4144, 45, 50, T0.plus(Duration.ofHours(5)), Instant.EPOCH));
|
||||
registers.values.put(4144, 50);
|
||||
var service = service();
|
||||
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
assertTrue(service.pendingRestores().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingWithNothingPendingChangesNothing() throws IOException {
|
||||
var service = service();
|
||||
|
||||
assertEquals(0, service.renewLeases());
|
||||
assertEquals(0, registers.writes);
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now = T0;
|
||||
private long nanos;
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
|
||||
],
|
||||
"restores": [
|
||||
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
|
||||
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z", "leaseUntil": "2026-10-24T10:15:00Z" }
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,26 @@ SDM630 house meter: 645 W vs 640 W):
|
||||
| 526 | today load | /10 kWh | 24.2 |
|
||||
| 529 | today PV production | /10 kWh | 29.9 |
|
||||
|
||||
More registers verified live 2026-10-24 about 12:00 (PV running, two reads a minute apart;
|
||||
every 32-bit total is low word first, /10 kWh, and the today counters are below the totals):
|
||||
|
||||
| Register | Meaning | Scale | Value at check |
|
||||
|---|---|---|---|
|
||||
| 500 | run state (2 = normal) | – | 2 |
|
||||
| 516–517, 518–519 | total battery charge, discharge | /10 kWh | 453.7, 162.1 |
|
||||
| 522–523, 524–525 | total grid bought, sold | /10 kWh | 13822.2, 17585.2 |
|
||||
| 527–528 | total load | /10 kWh | 16282.2 |
|
||||
| 534–535 | total PV (this inverter only, not the Fronius) | /10 kWh | 17990.6 |
|
||||
| 540, 541 | inverter temperature, heat sink | (x − 1000) / 10 °C | 25.0, 40.6 |
|
||||
| 591 | battery current, signed like 590 | /100 A | −0.05 (590: −3 W at 54.8 V) |
|
||||
| 609 | grid frequency | /100 Hz | 49.95 |
|
||||
| 636 | inverter output power, total (= sum of 633–635) | W, signed | 1388 |
|
||||
| 676–679 | PV1 V, A, PV2 V, A | /10 | 549.3 V 1.3 A, 578.9 V 0.9 A (PV W 763, 553) |
|
||||
|
||||
Not used: 627–632 and 644–646 (inverter and load voltages and currents, current scale
|
||||
unclear: 452 W at 232.8 V does not fit 180 as /100 A), 552–558 (alarm words, 552 = 5 while
|
||||
running normally, meaning unknown), 592 and 604–607 (unknown).
|
||||
|
||||
The battery is configured as **backup only** (power cuts), on purpose: day and night the house
|
||||
runs on PV and the grid, so a full battery with ~0 W flow is the normal state.
|
||||
|
||||
|
||||
Reference in new issue
Block a user