Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6997560d2 | ||
|
|
09042c6338 | ||
|
|
f9fbff4eea | ||
|
|
7e50d1203b | ||
|
|
934330e157 | ||
|
|
2e273f05c2 | ||
|
|
9a48442220 | ||
|
|
b3038aeef7 | ||
|
|
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 | ||
|
|
a8f6384e04 | ||
|
|
3c68dc3621 |
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
|
||||
|
||||
+16
-1
@@ -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.
|
||||
@@ -16,7 +17,7 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
@@ -25,6 +26,20 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
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) {
|
||||
|
||||
@@ -3,10 +3,12 @@ package home.backend.adapters.gateway;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -15,6 +17,7 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
||||
@@ -23,15 +26,27 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
|
||||
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes) {
|
||||
private record Request(String setpoint, int value, Long minutes, Instant until) {
|
||||
}
|
||||
|
||||
private record RestoreRequest(String setpoint) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
@@ -39,6 +54,8 @@ 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.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
@@ -47,23 +64,41 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
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;
|
||||
@@ -16,8 +17,11 @@ import home.backend.application.HistoryService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -40,9 +44,14 @@ public class ApiServer {
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
private record PresetRequest(String until) {
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
@@ -58,7 +67,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,13 +80,25 @@ 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(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(SETPOINTS_PATH, exchange -> {
|
||||
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||
if (restore.matches()) {
|
||||
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
@@ -175,15 +197,32 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var result = setpoints.restore(setpoint, user(exchange));
|
||||
send(exchange, restoreHttpStatus(result.outcome()), result);
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var result = setpoints.applyPreset(id, user(exchange));
|
||||
var request = body.length == 0 ? null : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request == null || request.until() == null ? null
|
||||
: OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
@@ -197,6 +236,16 @@ public class ApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -12,5 +13,11 @@ public interface GatewayCommands {
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||
RestoreResult restore(String setpoint, String requestedBy) 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();
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -38,48 +40,62 @@ public class SetpointService {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||
return gateway.restore(setpoint, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, String user) throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (preset.get().isHeld() != (until != null)) {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, user)));
|
||||
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
||||
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, String user) {
|
||||
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||
try {
|
||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
||||
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||
return ChangeResult.of(gateway.apply(command));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
||||
String problem = problem(change, limits);
|
||||
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||
String problem = problem(change, held, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, List<Setpoint> limits) {
|
||||
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
@@ -88,6 +104,11 @@ public class SetpointService {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||
+ known.get().value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
/**
|
||||
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||
* while the backend renews the lease (ADR-011).
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
||||
public record Preset(String id, String name, List<Change> changes) {
|
||||
/**
|
||||
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||
* then without renewals (ADR-012).
|
||||
*/
|
||||
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||
List<Change> changes) {
|
||||
|
||||
public static final String CHOSEN = "chosen";
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isHeld() {
|
||||
return CHOSEN.equals(until);
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
if (until != null && !until.equals(CHOSEN)) {
|
||||
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||
}
|
||||
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
|
||||
public record RestoreResult(Outcome outcome, String message) {
|
||||
|
||||
/**
|
||||
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
|
||||
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
|
||||
*/
|
||||
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -28,8 +28,8 @@ class PresetFileTest {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PresetFileTest {
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,6 +51,8 @@ class PresetFileTest {
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -32,7 +33,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 +61,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)));
|
||||
@@ -95,6 +96,15 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||
|
||||
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
@@ -136,6 +146,76 @@ 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 restorePostsTheSetpointWithTokenAndUser() throws Exception {
|
||||
serve(200, contract("response-restored.json"));
|
||||
|
||||
var result = commands("s3cret-token").restore("cwu_economy", "anna");
|
||||
|
||||
assertEquals(Outcome.RESTORED, result.outcome());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/restore", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
|
||||
serve(409, contract("response-restore-conflict.json"));
|
||||
|
||||
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreFailsWithoutAVerdictOrToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@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;
|
||||
@@ -10,6 +11,8 @@ import home.backend.application.SetpointService;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
@@ -57,6 +60,8 @@ class ApiServerTest {
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private Outcome restoreOutcome;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean commandsDown;
|
||||
private int answersBeforeDrop;
|
||||
private ApiServer server;
|
||||
@@ -82,6 +87,7 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -98,6 +104,15 @@ class ApiServerTest {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
@@ -109,10 +124,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 +158,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 {
|
||||
@@ -153,6 +178,10 @@ class ApiServerTest {
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static String awayRequest() throws IOException {
|
||||
return example("preset-away-request").toString();
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
@@ -178,16 +207,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 +232,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 +250,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());
|
||||
@@ -301,6 +335,52 @@ class ApiServerTest {
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
restoreOutcome = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/setpoints/cwu_economy/restore";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
|
||||
assertEquals(404, post(path, "{}").statusCode(), path);
|
||||
}
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
@@ -343,7 +423,7 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", "");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
@@ -356,12 +436,37 @@ class ApiServerTest {
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||
post("/api/presets/away", awayRequest());
|
||||
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||
}
|
||||
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
@@ -372,7 +477,7 @@ class ApiServerTest {
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -381,7 +486,7 @@ class ApiServerTest {
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
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 RestoreResult restore(String setpoint, String requestedBy) {
|
||||
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. */
|
||||
|
||||
@@ -3,6 +3,8 @@ package home.backend.application;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
@@ -16,6 +18,7 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -25,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
|
||||
private static final SetpointState STATE = new SetpointState(List.of(
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
@@ -33,6 +37,7 @@ class SetpointServiceTest {
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
|
||||
private final List<Object> script = new ArrayList<>();
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean down;
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -48,6 +53,15 @@ class SetpointServiceTest {
|
||||
return new CommandResult((Status) next, command.setpoint(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(Outcome.RESTORED, "restored");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
@@ -55,10 +69,29 @@ 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);
|
||||
|
||||
@Test
|
||||
void restoreAsksTheGatewayInTheUsersName() throws Exception {
|
||||
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
|
||||
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIsAnIoError() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewJoinsGatewayStateAndPresets() throws Exception {
|
||||
var view = service.current();
|
||||
@@ -93,33 +126,53 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
|
||||
sent);
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("not below the current 19"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -129,9 +182,9 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -145,7 +198,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -156,7 +209,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -166,7 +219,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -176,7 +229,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -186,7 +239,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -198,7 +251,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -17,6 +17,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-012: Away is held until a chosen date, without renewals
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
Amends [ADR-011](011-setpoint-leases.md) for held lowering changes; everything else there stands.
|
||||
|
||||
## Context
|
||||
|
||||
With ADR-011 the preset "Wyjazd" (CWU 10, CO 15) lasts only while the backend and the tunnel
|
||||
renew its lease. A server or tunnel outage during a trip would end the away mode and heat an
|
||||
empty house. The away values only lower temperatures, so keeping them is the safe direction.
|
||||
|
||||
## Decision
|
||||
|
||||
- The user picks a return date and time on a calendar. "Wyjazd" is held until then and the
|
||||
gateway restores the previous values by itself, with no renewals.
|
||||
- `POST /commands` takes an optional `until` (ISO-8601 with offset), exclusive with `minutes`.
|
||||
The change is stored as a restore with `leaseUntil = due = until`: no new file field, `runAt`
|
||||
is the end, and a restart keeps it. Backend: preset field `"until": "chosen"` and
|
||||
`POST /api/presets/{id}` with `{"until": …}`; 400 when it is missing, invalid or not wanted.
|
||||
- The gateway enforces the safety, not the backend. A held change is rejected, with no write,
|
||||
unless the end is in the future and at most 30 days ahead (`Command.MAX_HOLD`), and the new
|
||||
value is lower than the register's current value and lower than the original that will come
|
||||
back. Raising stays on renewed leases.
|
||||
- `renewLeases` only extends: `leaseUntil = max(leaseUntil, now + 15 min)`. A held change keeps
|
||||
its end, and a restore that is due, lapsed or failing is still never revived.
|
||||
- A pending boost: a held change may replace it, keeping the first original. Because it must be
|
||||
below that original, nothing above the original outlives the boost. A later boost on a held
|
||||
change is an ordinary lease again, which can only shorten the hold.
|
||||
- The restore at the end uses the usual path: conflict check, read-back, backoff.
|
||||
|
||||
## Consequences
|
||||
|
||||
- During a trip the house stays on away values even if the backend, tunnel or server are lost.
|
||||
If the gateway or the Pi is lost, the heat pump keeps the away values until someone
|
||||
intervenes; the 30 days and the lowering-only rule bound the cost.
|
||||
- A held away cannot be cancelled by lapsing; the user sets the values back, which replaces it.
|
||||
- Wyjazd is refused when a value is already at or below the away value (nothing to lower).
|
||||
+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), away held until a chosen date (ADR-012); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| 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
|
||||
|
||||
|
||||
+9
-5
@@ -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,19 @@ 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.
|
||||
- **Away**: lower the comfort temperature (CWU 10 °C, CO 15 °C) until a return date and time the
|
||||
user picks (at most 30 days). Held by the gateway without renewals (ADR-012), lowering only.
|
||||
- **Lease** (ADR-011): every other change also needs the backend to renew it every 5 minutes; after
|
||||
15 minutes without, the gateway restores the previous value.
|
||||
- **Restore now**: the user can end any pending change at once; the previous value comes back
|
||||
through the same checks.
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
import { localInput } from "./format";
|
||||
import type { Setpoints } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
@@ -60,10 +61,10 @@ describe("Settings", () => {
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania."))).toBeInTheDocument();
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
@@ -71,6 +72,94 @@ describe("Settings", () => {
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("asks for the return time of a trip and sends the chosen time with the local offset", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
const input = screen.getByLabelText("Powrót") as HTMLInputElement;
|
||||
expect(input.type).toBe("datetime-local");
|
||||
const hours = (value: string) => (Date.parse(value) - Date.now()) / 3600_000;
|
||||
expect(hours(input.min)).toBeGreaterThan(0.9);
|
||||
expect(hours(input.min)).toBeLessThan(1.1);
|
||||
expect(hours(input.max) / 24).toBeGreaterThan(29.9);
|
||||
expect(hours(input.max) / 24).toBeLessThan(30.1);
|
||||
expect(input.value.endsWith("T18:00")).toBe(true);
|
||||
expect(hours(input.value) / 24).toBeGreaterThan(6);
|
||||
expect(hours(input.value) / 24).toBeLessThan(8);
|
||||
|
||||
const chosen = localInput(new Date(Date.now() + 3 * 24 * 3600_000));
|
||||
fireEvent.change(input, { target: { value: chosen } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const sent = JSON.parse(String(posts(fetch)[0][1]!.body)).until as string;
|
||||
expect(sent).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:00[+-]\d\d:\d\d$/);
|
||||
expect(Date.parse(sent)).toBe(new Date(chosen).getTime());
|
||||
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not send a return time that is too early or too late", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
for (const days of [0.01, 31]) {
|
||||
fireEvent.change(screen.getByLabelText("Powrót"), { target: { value: localInput(new Date(Date.now() + days * 24 * 3600_000)) } });
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
}
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closes the return form on cancel without sending anything", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Wyjazd" })).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the plain confirmation for a preset without a chosen end", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
|
||||
expect(screen.queryByLabelText("Powrót")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
|
||||
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
|
||||
});
|
||||
|
||||
it("shows a held change with its end date and the value that comes back", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByText(paragraph("Ciepła woda, komfort: 10 °C do 2 lis, 18:00, potem 43 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -82,4 +171,73 @@ describe("Settings", () => {
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe("restore now", () => {
|
||||
const restoreButton = () => screen.findByRole("button", { name: "Przywróć teraz: Ciepła woda, oszczędnie" });
|
||||
const restoreAnswer = (outcome: string, status: number) => () => answer({ outcome, message: "x" }, status);
|
||||
|
||||
it("asks first, then restores and reloads the state", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
expect(screen.getByText("Przywrócić 8 °C teraz?")).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
const loads = fetch.mock.calls.length;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
expect(posts(fetch)[0][0]).toBe("api/setpoints/cwu_economy/restore");
|
||||
expect(await screen.findByText("Przywrócono poprzednią wartość.")).toBeInTheDocument();
|
||||
expect(fetch.mock.calls.length).toBeGreaterThan(loads + 1);
|
||||
});
|
||||
|
||||
it("sends nothing when cancelled", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, restoreAnswer("RESTORED", 200));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(await restoreButton()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has a button large enough to tap", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect((await restoreButton()).className).toContain("min-h-12");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CONFLICT", 409, "zostawiliśmy ją bez zmian"],
|
||||
["TOO_SOON", 429, "Za wcześnie"],
|
||||
["FAILED", 502, "Zmiana nadal czeka"],
|
||||
["NOT_PENDING", 404, "już się zakończyła"],
|
||||
])("explains %s in Polish", async (outcome, status, text) => {
|
||||
serve({ "api/setpoints": example("setpoints") }, restoreAnswer(outcome, status));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(text);
|
||||
});
|
||||
|
||||
it("never claims nothing changed when the answer is lost", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await restoreButton());
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, przywróć" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
|
||||
});
|
||||
});
|
||||
});
|
||||
+96
-16
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { degrees, duration, when } from "./format";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
|
||||
import type { Change, CommandResult, LineStatus, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
@@ -27,6 +27,13 @@ const RESULT_TEXT: Record<string, string> = {
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
const RESTORE_TEXT: Record<RestoreOutcome, string> = {
|
||||
RESTORED: "Przywrócono poprzednią wartość.",
|
||||
NOT_PENDING: "Ta zmiana już się zakończyła. Nic nie zostało zmienione.",
|
||||
CONFLICT: "Wartość została w międzyczasie zmieniona (np. na panelu pompy), więc zostawiliśmy ją bez zmian.",
|
||||
TOO_SOON: "Za wcześnie po poprzednim zapisie. Spróbuj za minutę.",
|
||||
FAILED: "Nie udało się przywrócić. Zmiana nadal czeka, spróbujemy ponownie sami. Sprawdź wartości poniżej.",
|
||||
};
|
||||
const LINE_TEXT: Record<LineStatus, string> = {
|
||||
APPLIED: "zapisano",
|
||||
REJECTED: "pompa nie przyjęła zmiany",
|
||||
@@ -75,23 +82,54 @@ function ResultBox({ result }: { result: Result }) {
|
||||
{result.results.map((x, i) => (
|
||||
<li key={i}>
|
||||
{result.changes?.[i] ? name(result.changes[i].setpoint) : `Zmiana ${i + 1}`}: {LINE_TEXT[x.status]}
|
||||
{x.status === "APPLIED" && x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""}
|
||||
{x.status === "APPLIED" && x.restoreAt ? `, wróci ${dateTime(x.restoreAt)}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
} else if (ok && result.text) {
|
||||
body = result.text;
|
||||
} else if (ok) {
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(result.restoreAt)}.` : ""}</>;
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości ${dateTime(result.restoreAt)}.` : ""}</>;
|
||||
} else {
|
||||
body = result.text ?? RESULT_TEXT[result.status];
|
||||
}
|
||||
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change) {
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
|
||||
function ReturnForm({ busy, onCancel, onConfirm }: { busy: boolean; onCancel: () => void; onConfirm: (until: string) => void }) {
|
||||
const [now] = useState(() => Date.now());
|
||||
const min = localInput(new Date(now + 3600_000));
|
||||
const max = localInput(new Date(now + 30 * DAY_MS));
|
||||
const [until, setUntil] = useState(() => {
|
||||
const week = new Date(now + 7 * DAY_MS);
|
||||
week.setHours(18, 0, 0, 0);
|
||||
return localInput(week);
|
||||
});
|
||||
const valid = until >= min && until <= max;
|
||||
return (
|
||||
<>
|
||||
<label className="my-3 block font-semibold">
|
||||
Powrót
|
||||
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
|
||||
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(withOffset(until))} disabled={busy || !valid}>Ustaw wyjazd</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change, held: boolean) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
|
||||
const tail = held ? `do powrotu, potem wróci ${back}`
|
||||
: c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
|
||||
return <>{name(c.setpoint)}: <b>{degrees(c.value)}</b> {tail}.</>;
|
||||
}
|
||||
|
||||
@@ -103,6 +141,8 @@ export function Settings() {
|
||||
const [presetId, setPresetId] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The setpoint whose pending change is about to be restored, waiting for "Tak, przywróć".
|
||||
const [restoring, setRestoring] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Result>(null);
|
||||
|
||||
// A refresh can lose the reading while a change is half-made; drop what was built on it.
|
||||
@@ -116,7 +156,7 @@ export function Settings() {
|
||||
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
|
||||
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setRestoring(null); setResult(null); };
|
||||
|
||||
async function send(url: string, payload: unknown, changes?: Change[]) {
|
||||
setBusy(true);
|
||||
@@ -137,6 +177,23 @@ export function Settings() {
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
setBusy(true);
|
||||
let answer: Result;
|
||||
try {
|
||||
const { outcome } = await postJson<RestoreResult>(`${URL}/${id}/restore`);
|
||||
answer = { status: outcome === "RESTORED" ? "APPLIED" : "FAILED", text: RESTORE_TEXT[outcome] };
|
||||
} catch (error) {
|
||||
const status = error instanceof WriteError ? error.status : 0;
|
||||
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
|
||||
}
|
||||
setResult(answer);
|
||||
setBusy(false);
|
||||
setRestoring(null);
|
||||
window.scrollTo(0, 0);
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
function step(s: Setpoint, direction: number) {
|
||||
if (unread(s)) return;
|
||||
setResult(null);
|
||||
@@ -153,12 +210,19 @@ export function Settings() {
|
||||
{title}
|
||||
<ResultBox result={result} />
|
||||
<Card title={preset.name} className="max-w-2xl">
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c)}</p>)}
|
||||
<p className="my-3">Potwierdzasz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
|
||||
</div>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, !!preset.until)}</p>)}
|
||||
{preset.until ? (
|
||||
<ReturnForm busy={busy} onCancel={cancel}
|
||||
onConfirm={(until) => send(`api/presets/${preset.id}`, { until }, preset.changes)} />
|
||||
) : (
|
||||
<>
|
||||
<p className="my-3">Potwierdzasz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
@@ -175,9 +239,25 @@ export function Settings() {
|
||||
{data.restores.length > 0 && (
|
||||
<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>.
|
||||
</p>
|
||||
<div key={r.setpoint} className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<p className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> {r.due ? `do ${dateTime(r.due)}` : "do odwołania (dopóki serwer działa)"}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
{restoring === r.setpoint ? (
|
||||
<>
|
||||
<p className="my-2 text-lg font-semibold">Przywrócić {degrees(r.originalValue)} teraz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={() => setRestoring(null)} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => restore(r.setpoint)} disabled={busy}>Tak, przywróć</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 flex">
|
||||
<Button aria-label={`Przywróć teraz: ${name(r.setpoint)}`} disabled={busy}
|
||||
onClick={() => { setResult(null); setRestoring(r.setpoint); }}>Przywróć teraz</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-4
@@ -16,10 +16,12 @@ export class WriteError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
|
||||
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} (or {outcome, ...}) answer, also for a
|
||||
// refusal (422), a conflict (409) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// and with status 0 when the network fails or the answer does not come in time.
|
||||
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
|
||||
export async function postJson<T extends { status?: string; outcome?: string } = CommandResult>(
|
||||
url: string, body?: unknown,
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
@@ -30,6 +32,6 @@ export async function postJson(url: string, body?: unknown): Promise<CommandResu
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new WriteError(response.status);
|
||||
if (!answer?.status && !answer?.outcome) throw new WriteError(response.status);
|
||||
return answer;
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -64,6 +68,26 @@ export function when(iso: string): string {
|
||||
return sameDay(date, new Date()) ? hour(date) : dayAndHour(date);
|
||||
}
|
||||
|
||||
// "14:05" for today, "2 lis, 18:00" for another day: the end of a trip is a date, not a weekday.
|
||||
export function dateTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (sameDay(date, new Date())) return hour(date);
|
||||
return `${date.toLocaleDateString("pl-PL", { day: "numeric", month: "short", timeZone: ZONE })}, ${hour(date)}`;
|
||||
}
|
||||
|
||||
const two = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// The value of <input type="datetime-local"> for a moment on the phone's own clock.
|
||||
export const localInput = (date: Date) =>
|
||||
`${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}T${two(date.getHours())}:${two(date.getMinutes())}`;
|
||||
|
||||
// "2026-11-02T18:00" on the phone's clock -> "2026-11-02T18:00:00+01:00", an exact moment for the backend.
|
||||
export function withOffset(local: string): string {
|
||||
const offset = -new Date(local).getTimezoneOffset();
|
||||
const sign = offset < 0 ? "-" : "+";
|
||||
return `${local}:00${sign}${two(Math.floor(Math.abs(offset) / 60))}:${two(Math.abs(offset) % 60)}`;
|
||||
}
|
||||
|
||||
export function duration(hours: number): string {
|
||||
return hours < 1 ? `${Math.round(hours * 60)} min` : `${number(hours, 1).replace(/,0$/, "")} godz.`;
|
||||
}
|
||||
+32
-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 {
|
||||
@@ -80,6 +102,8 @@ export interface Change {
|
||||
export interface Preset {
|
||||
id: string;
|
||||
name: string;
|
||||
// "chosen": the user picks the end on a calendar and the gateway holds the changes until then (ADR-012)
|
||||
until?: "chosen";
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
@@ -91,6 +115,13 @@ export interface Setpoints {
|
||||
|
||||
export type LineStatus = "APPLIED" | "REJECTED" | "FAILED" | "UNKNOWN" | "NOT_ATTEMPTED";
|
||||
|
||||
export type RestoreOutcome = "RESTORED" | "NOT_PENDING" | "CONFLICT" | "TOO_SOON" | "FAILED";
|
||||
|
||||
export interface RestoreResult {
|
||||
outcome: RestoreOutcome;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CommandResult {
|
||||
status: "APPLIED" | "REJECTED" | "FAILED" | "PARTIAL";
|
||||
message?: string;
|
||||
|
||||
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"]
|
||||
|
||||
+34
-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,40 @@ 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`.
|
||||
|
||||
**Held changes (ADR-012).** `POST /commands` with `until` (ISO-8601 with offset, not together with `minutes`)
|
||||
holds the value until then without renewals: the restore has `leaseUntil = due = until`, survives a restart
|
||||
and runs at its end. The gateway accepts it only if the end is in the future and at most 30 days ahead and the
|
||||
new value is lower than both the current value and the original that will come back; otherwise 422 and no
|
||||
write. A renewal only ever extends a lease, so it never shortens a held change. A held change may replace a
|
||||
pending boost (the original stays the first one); a later ordinary change on a held one is a lease again.
|
||||
|
||||
**Restore now.** `POST /commands/restore` with `{"setpoint": "cwu_economy"}` (same token and throttle; the
|
||||
requester comes from `X-Requested-By`) runs that setpoint's pending restore at once through the usual path.
|
||||
200 restored; 404 nothing pending; 409 the register no longer holds our value, so it is left alone and the
|
||||
restore dropped (as a due one); 429 too soon after the last write to it (S4); 502 failed, the restore stays
|
||||
pending with its backoff.
|
||||
|
||||
**Shown value.** `GET /commands` shows, per setpoint, the value read back after the gateway's last write or
|
||||
restore or the last polled value, whichever is newer: a poll takes minutes, a panel change shows in a later one.
|
||||
|
||||
## 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));
|
||||
}
|
||||
|
||||
@@ -9,23 +9,32 @@ import home.gateway.application.Snapshot;
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
|
||||
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.
|
||||
* {@code POST /commands} applies one setpoint change (body: setpoint, value, optional minutes or
|
||||
* until, the end of a held change, ADR-012), {@code GET /commands} lists the setpoints
|
||||
* and pending restores, {@code POST /commands/renew} keeps the live restores alive (ADR-011)
|
||||
* and answers with how many it extended, {@code POST /commands/restore} (body: setpoint) restores one
|
||||
* setpoint now: 200 restored, 404 nothing pending, 409 the register no longer holds our value,
|
||||
* 429 too soon after the last write, 502 failed (the restore stays pending). 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 +48,16 @@ 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 static final String RESTORE_PATH = "/commands/restore";
|
||||
|
||||
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 +81,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 (!List.of(COMMANDS_PATH, RENEW_PATH, RESTORE_PATH).contains(path)) {
|
||||
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
|
||||
} else if (throttle.isBlocked()) {
|
||||
exchange.getResponseHeaders().set("Retry-After",
|
||||
@@ -77,14 +93,18 @@ 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 (path.equals(RESTORE_PATH)) {
|
||||
restoreNow(exchange, method);
|
||||
} else if (method.equals("GET")) {
|
||||
respond(exchange, 200, listing());
|
||||
} else if (method.equals("POST")) {
|
||||
post(exchange);
|
||||
@@ -97,6 +117,34 @@ 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 void restoreNow(HttpExchange exchange, String method) throws IOException {
|
||||
if (!method.equals("POST")) {
|
||||
exchange.getResponseHeaders().set("Allow", "POST");
|
||||
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
|
||||
return;
|
||||
}
|
||||
var body = readJsonBody(exchange);
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
var setpoint = body.path("setpoint");
|
||||
if (!setpoint.isTextual()) {
|
||||
respond(exchange, 400, new GatewayHttpServer.Error("bad request: need a text \"setpoint\""));
|
||||
return;
|
||||
}
|
||||
var result = commands.restoreNow(setpoint.textValue(), requester(exchange));
|
||||
respond(exchange, httpStatus(result.outcome()), result);
|
||||
}
|
||||
|
||||
private boolean authorized(HttpExchange exchange) {
|
||||
var header = exchange.getRequestHeaders().getFirst("Authorization");
|
||||
if (header == null || !header.startsWith(BEARER)) {
|
||||
@@ -107,22 +155,14 @@ class CommandsEndpoint implements HttpHandler {
|
||||
}
|
||||
|
||||
private void post(HttpExchange exchange) throws IOException {
|
||||
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
|
||||
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
|
||||
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
|
||||
return;
|
||||
}
|
||||
var body = readLimited(exchange);
|
||||
var body = readJsonBody(exchange);
|
||||
if (body == null) {
|
||||
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
|
||||
return;
|
||||
}
|
||||
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
|
||||
Command command;
|
||||
try {
|
||||
command = parse(JSON.readTree(body),
|
||||
isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
command = parse(body, requester(exchange));
|
||||
} catch (IllegalArgumentException e) {
|
||||
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
|
||||
return;
|
||||
}
|
||||
@@ -130,6 +170,31 @@ class CommandsEndpoint implements HttpHandler {
|
||||
respond(exchange, httpStatus(result.status()), result);
|
||||
}
|
||||
|
||||
/** The parsed body, or null after answering 415, 413 or 400. */
|
||||
private static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
|
||||
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
|
||||
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
|
||||
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
|
||||
return null;
|
||||
}
|
||||
var body = readLimited(exchange);
|
||||
if (body == null) {
|
||||
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.readTree(body);
|
||||
} catch (IOException e) {
|
||||
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String requester(HttpExchange exchange) {
|
||||
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
|
||||
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
|
||||
}
|
||||
|
||||
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
|
||||
private static byte[] readLimited(HttpExchange exchange) throws IOException {
|
||||
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
|
||||
@@ -157,7 +222,19 @@ class CommandsEndpoint implements HttpHandler {
|
||||
throw new IllegalArgumentException("\"minutes\" must be an integer");
|
||||
}
|
||||
var duration = minutes.isInt() ? Duration.ofMinutes(minutes.intValue()) : null;
|
||||
return new Command(setpoint.textValue(), value.intValue(), duration, requestedBy);
|
||||
return new Command(setpoint.textValue(), value.intValue(), duration, heldUntil(body.path("until")),
|
||||
requestedBy);
|
||||
}
|
||||
|
||||
private static Instant heldUntil(JsonNode until) {
|
||||
if (until.isMissingNode() || until.isNull()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OffsetDateTime.parse(until.asText()).toInstant();
|
||||
} catch (DateTimeParseException e) {
|
||||
throw new IllegalArgumentException("\"until\" must be an ISO-8601 date and time with offset");
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(CommandResult.Status status) {
|
||||
@@ -168,24 +245,40 @@ class CommandsEndpoint implements HttpHandler {
|
||||
};
|
||||
}
|
||||
|
||||
private static int httpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private Listing listing() {
|
||||
var views = commands.setpoints().stream().map(this::view).toList();
|
||||
var verified = commands.verifiedValues();
|
||||
var views = commands.setpoints().stream().map(setpoint -> view(setpoint, verified)).toList();
|
||||
return new Listing(views, commands.pendingRestores());
|
||||
}
|
||||
|
||||
private SetpointView view(Setpoint setpoint) {
|
||||
private SetpointView view(Setpoint setpoint, Map<String, VerifiedValue> verified) {
|
||||
return new SetpointView(setpoint.name(), setpoint.register(), setpoint.min(),
|
||||
setpoint.max(), setpoint.step(), currentValue(setpoint.name()));
|
||||
setpoint.max(), setpoint.step(), currentValue(setpoint.name(), verified.get(setpoint.name())));
|
||||
}
|
||||
|
||||
/** The last polled value, or null while the heat pump has not been read yet. */
|
||||
private Integer currentValue(String setpoint) {
|
||||
return snapshot.states().stream()
|
||||
/**
|
||||
* Whichever is newer: the last poll or the value we read back after our own write (a poll takes
|
||||
* minutes, and a panel change after our write shows up in a later poll). Null while neither exists.
|
||||
*/
|
||||
private Integer currentValue(String setpoint, VerifiedValue verified) {
|
||||
var polled = snapshot.states().stream()
|
||||
.flatMap(state -> state.readings().stream())
|
||||
.filter(reading -> reading.measurement().equals(HEAT_PUMP))
|
||||
.map(reading -> reading.fields().get("setpoint_" + setpoint))
|
||||
.filter(Objects::nonNull)
|
||||
.map(Double::intValue)
|
||||
.findFirst().orElse(null);
|
||||
.filter(reading -> reading.fields().get("setpoint_" + setpoint) != null)
|
||||
.findFirst();
|
||||
if (verified != null && (polled.isEmpty() || verified.at().isAfter(polled.get().time()))) {
|
||||
return verified.value();
|
||||
}
|
||||
return polled.map(reading -> reading.fields().get("setpoint_" + setpoint).intValue()).orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -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,13 +5,18 @@ 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;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Modbus TCP (MBAP) over one long-lived socket. The MK01 gateway serves a single client and a
|
||||
* second connection drops the first, so the socket is reused and requests never overlap.
|
||||
* 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 {
|
||||
|
||||
@@ -19,18 +24,33 @@ 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, new MbapFraming(), IDLE_CLOSE);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
@@ -48,10 +68,12 @@ 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);
|
||||
}
|
||||
return response;
|
||||
} catch (IOException e) {
|
||||
closeSocket();
|
||||
@@ -63,12 +85,25 @@ public final class TcpTransport implements ModbusTransport {
|
||||
public void close() {
|
||||
lock.lock();
|
||||
try {
|
||||
idleCloser.shutdownNow();
|
||||
closeSocket();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** A later request in the same poll moved lastResponseNanos on, and the socket stays. */
|
||||
private void closeIfIdle() {
|
||||
lock.lock();
|
||||
try {
|
||||
if (System.nanoTime() - lastResponseNanos >= idleClose.toNanos()) {
|
||||
closeSocket();
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitPause() throws InterruptedIOException {
|
||||
long remaining = settings.minimumPause().toNanos() - (System.nanoTime() - lastResponseNanos);
|
||||
if (remaining <= 0) {
|
||||
@@ -100,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.CommandResult.Status;
|
||||
import home.gateway.domain.Reading;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.RestoreResult.Outcome;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
import home.gateway.domain.SafetyLimits;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import org.slf4j.Logger;
|
||||
@@ -53,6 +56,7 @@ public class CommandService implements Commands {
|
||||
private final Map<Integer, Restore> restores = new LinkedHashMap<>();
|
||||
private final Map<Integer, Long> lastWrite = new HashMap<>();
|
||||
private final Map<Integer, Backoff> backoffs = new HashMap<>();
|
||||
private final Map<String, VerifiedValue> verified = new HashMap<>();
|
||||
private boolean unsaved;
|
||||
|
||||
private record Backoff(int failures, long retryAtNanos) {
|
||||
@@ -104,12 +108,20 @@ public class CommandService implements Commands {
|
||||
if (now.isBefore(earliestPlausible)) {
|
||||
return rejected(command, setpoint, "clock not set");
|
||||
}
|
||||
if (command.heldUntil() != null && !isHoldable(command.heldUntil(), now)) {
|
||||
return rejected(command, setpoint, "a held change must end in the future, at most "
|
||||
+ Command.MAX_HOLD.toDays() + " days ahead");
|
||||
}
|
||||
if (tooSoon(setpoint.register())) {
|
||||
return rejected(command, setpoint, "too soon after the last write to " + setpoint.name());
|
||||
}
|
||||
return write(command, setpoint, now);
|
||||
}
|
||||
|
||||
private static boolean isHoldable(Instant end, Instant now) {
|
||||
return end.isAfter(now) && !end.isAfter(now.plus(Command.MAX_HOLD));
|
||||
}
|
||||
|
||||
private CommandResult write(Command command, Setpoint setpoint, Instant now) {
|
||||
int register = setpoint.register();
|
||||
var previous = restores.get(register);
|
||||
@@ -117,13 +129,20 @@ 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.heldUntil() != null ? command.heldUntil()
|
||||
: 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();
|
||||
// ADR-012: a held change is never renewed, so it may only lower. Below the original too:
|
||||
// it replaces a pending boost's restore, and nothing above the original may outlive the boost.
|
||||
if (command.heldUntil() != null && (command.value() >= old || command.value() >= original)) {
|
||||
return rejected(command, setpoint, "a held change must lower " + setpoint.name()
|
||||
+ " below " + Math.min(old, original));
|
||||
}
|
||||
var lease = command.heldUntil() != null ? command.heldUntil() : now.plus(Restore.LEASE);
|
||||
// Saved before the write, so a write never happens without its restore on disk.
|
||||
remember(new Restore(setpoint.name(), register, original, command.value(), due,
|
||||
lease), previous);
|
||||
lastWrite.put(register, nanoTime.getAsLong());
|
||||
attempted = true;
|
||||
registers.write(register, command.value());
|
||||
@@ -133,11 +152,9 @@ 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);
|
||||
}
|
||||
verified.put(setpoint.name(), new VerifiedValue(command.value(), clock.instant()));
|
||||
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 +165,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 +183,31 @@ 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, and a held change keeps its end (ADR-012).
|
||||
* 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))
|
||||
.filter(restore -> restore.leaseUntil().isBefore(now.plus(Restore.LEASE)))
|
||||
.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,11 +218,11 @@ 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);
|
||||
.forEach(restore -> tryRestore(restore, RESTORE_USER));
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
@@ -194,13 +235,34 @@ public class CommandService implements Commands {
|
||||
}
|
||||
|
||||
/** One restore that is bad or blows up must not keep the others from running. */
|
||||
private void tryRestore(Restore restore) {
|
||||
private RestoreResult tryRestore(Restore restore, String requestedBy) {
|
||||
try {
|
||||
if (isValid(restore)) {
|
||||
restore(restore);
|
||||
}
|
||||
return isValid(restore) ? restore(restore, requestedBy)
|
||||
: new RestoreResult(Outcome.NOT_PENDING, "the restore was invalid and is dropped");
|
||||
} catch (RuntimeException e) {
|
||||
restoreFailed(restore, null, e.toString());
|
||||
return restoreFailed(restore, null, requestedBy, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the pending restore of one setpoint now, on the user's request. It takes the normal path:
|
||||
* conflict check, read-back, audit, rate limit (S4); a failure leaves it pending with its backoff.
|
||||
*/
|
||||
@Override
|
||||
public RestoreResult restoreNow(String setpoint, String requestedBy) {
|
||||
lock.lock();
|
||||
try {
|
||||
var pending = restores.values().stream()
|
||||
.filter(restore -> restore.setpoint().equals(setpoint)).findFirst();
|
||||
if (pending.isEmpty()) {
|
||||
return new RestoreResult(Outcome.NOT_PENDING, "no pending restore for " + setpoint);
|
||||
}
|
||||
if (tooSoon(pending.get().register())) {
|
||||
return new RestoreResult(Outcome.TOO_SOON, "too soon after the last write to " + setpoint);
|
||||
}
|
||||
return tryRestore(pending.get(), requestedBy);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +273,7 @@ public class CommandService implements Commands {
|
||||
return true;
|
||||
}
|
||||
forget(restore.register());
|
||||
auditRestore(restore, "INVALID", null);
|
||||
auditRestore(restore, "INVALID", null, RESTORE_USER);
|
||||
LOGGER.error("restore dropped, never written: {} ({})", problem, restore);
|
||||
return false;
|
||||
}
|
||||
@@ -231,44 +293,48 @@ public class CommandService implements Commands {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void restore(Restore restore) {
|
||||
private RestoreResult restore(Restore restore, String requestedBy) {
|
||||
Integer current = null;
|
||||
try {
|
||||
current = registers.read(restore.register());
|
||||
if (!restore.isOurs(current)) {
|
||||
forget(restore.register());
|
||||
auditRestore(restore, "CONFLICT", current);
|
||||
auditRestore(restore, "CONFLICT", current, requestedBy);
|
||||
LOGGER.warn("{} holds {}, not our {}: left alone, restore dropped", restore.setpoint(),
|
||||
current, restore.appliedValue());
|
||||
return;
|
||||
return new RestoreResult(Outcome.CONFLICT, restore.setpoint() + " holds " + current
|
||||
+ ", not our " + restore.appliedValue() + ": left alone");
|
||||
}
|
||||
lastWrite.put(restore.register(), nanoTime.getAsLong());
|
||||
registers.write(restore.register(), restore.originalValue());
|
||||
pause.await();
|
||||
var actual = registers.read(restore.register());
|
||||
if (actual != restore.originalValue()) {
|
||||
restoreFailed(restore, current, "read back " + actual + ", expected " + restore.originalValue());
|
||||
return;
|
||||
return restoreFailed(restore, current, requestedBy,
|
||||
"read back " + actual + ", expected " + restore.originalValue());
|
||||
}
|
||||
forget(restore.register());
|
||||
auditRestore(restore, "APPLIED", current);
|
||||
verified.put(restore.setpoint(), new VerifiedValue(restore.originalValue(), clock.instant()));
|
||||
auditRestore(restore, "APPLIED", current, requestedBy);
|
||||
LOGGER.info("{} restored to {}", restore.setpoint(), restore.originalValue());
|
||||
return new RestoreResult(Outcome.RESTORED, "restored to " + restore.originalValue());
|
||||
} catch (IOException e) {
|
||||
restoreFailed(restore, current, e.getMessage());
|
||||
return restoreFailed(restore, current, requestedBy, e.getMessage());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
restoreFailed(restore, current, "interrupted");
|
||||
return restoreFailed(restore, current, requestedBy, "interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
private void restoreFailed(Restore restore, Integer old, String problem) {
|
||||
auditRestore(restore, "FAILED", old);
|
||||
private RestoreResult restoreFailed(Restore restore, Integer old, String requestedBy, String problem) {
|
||||
auditRestore(restore, "FAILED", old, requestedBy);
|
||||
var earlier = backoffs.get(restore.register());
|
||||
var failures = earlier == null ? 1 : earlier.failures() + 1;
|
||||
var wait = RETRY_WAITS.get(Math.min(failures, RETRY_WAITS.size()) - 1);
|
||||
backoffs.put(restore.register(), new Backoff(failures, nanoTime.getAsLong() + wait.toNanos()));
|
||||
LOGGER.error("restore of {} failed (attempt {}), next try in {}: {}", restore.setpoint(), failures,
|
||||
wait, problem);
|
||||
return new RestoreResult(Outcome.FAILED, problem);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -276,6 +342,16 @@ public class CommandService implements Commands {
|
||||
return List.copyOf(allowlist.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, VerifiedValue> verifiedValues() {
|
||||
lock.lock();
|
||||
try {
|
||||
return Map.copyOf(verified);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Restore> pendingRestores() {
|
||||
lock.lock();
|
||||
@@ -330,8 +406,8 @@ public class CommandService implements Commands {
|
||||
return new CommandResult(Status.FAILED, message, null);
|
||||
}
|
||||
|
||||
private void auditRestore(Restore restore, String status, Integer old) {
|
||||
audit(restore.setpoint(), restore.register(), RESTORE_USER, status, old, restore.originalValue());
|
||||
private void auditRestore(Restore restore, String status, Integer old, String requestedBy) {
|
||||
audit(restore.setpoint(), restore.register(), requestedBy, status, old, restore.originalValue());
|
||||
}
|
||||
|
||||
private void audit(String setpoint, int register, String requestedBy, String status, Integer old,
|
||||
|
||||
@@ -3,16 +3,28 @@ package home.gateway.application;
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the HTTP adapter may ask of the setpoint service (F3); the service does every check. */
|
||||
public interface Commands {
|
||||
|
||||
CommandResult apply(Command command);
|
||||
|
||||
/** Keeps the live restores alive (ADR-011); returns how many leases were extended. */
|
||||
int renewLeases();
|
||||
|
||||
/** Runs the pending restore of a setpoint now; the same checks as a due one. */
|
||||
RestoreResult restoreNow(String setpoint, String requestedBy);
|
||||
|
||||
List<Setpoint> setpoints();
|
||||
|
||||
/** The value last read back after our own write or restore, by setpoint name. */
|
||||
Map<String, VerifiedValue> verifiedValues();
|
||||
|
||||
List<Restore> pendingRestores();
|
||||
}
|
||||
@@ -3,9 +3,12 @@ package home.gateway.application;
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Stands in for the write path when it cannot be trusted: it never touches the controller. */
|
||||
public record DisabledCommands(String reason) implements Commands {
|
||||
@@ -15,6 +18,21 @@ public record DisabledCommands(String reason) implements Commands {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restoreNow(String setpoint, String requestedBy) {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, VerifiedValue> verifiedValues() {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Setpoint> setpoints() {
|
||||
throw new CommandsUnavailableException(reason);
|
||||
|
||||
@@ -6,10 +6,13 @@ import org.slf4j.LoggerFactory;
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -43,13 +46,26 @@ public class RestoreScheduler implements AutoCloseable {
|
||||
return new Commands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) {
|
||||
if (firstTick.getCount() > 0) {
|
||||
throw new CommandsUnavailableException("starting: restores that fell due while we were "
|
||||
+ "down are still being checked, retry shortly");
|
||||
}
|
||||
requireFirstTick();
|
||||
return inner.apply(command);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restoreNow(String setpoint, String requestedBy) {
|
||||
requireFirstTick();
|
||||
return inner.restoreNow(setpoint, requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, VerifiedValue> verifiedValues() {
|
||||
return inner.verifiedValues();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return inner.renewLeases();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Setpoint> setpoints() {
|
||||
return inner.setpoints();
|
||||
@@ -62,6 +78,13 @@ public class RestoreScheduler implements AutoCloseable {
|
||||
};
|
||||
}
|
||||
|
||||
private void requireFirstTick() {
|
||||
if (firstTick.getCount() > 0) {
|
||||
throw new CommandsUnavailableException("starting: restores that fell due while we were "
|
||||
+ "down are still being checked, retry shortly");
|
||||
}
|
||||
}
|
||||
|
||||
boolean awaitFirstTick(Duration timeout) throws InterruptedException {
|
||||
return firstTick.await(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
package home.gateway.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A request to set one setpoint (F3). With a duration the previous value comes back when it
|
||||
* ends (boost); without one the value stays until changed (away).
|
||||
* ends (boost); without one the value stays until changed, on a lease the backend renews.
|
||||
* With {@code heldUntil} the value is held until that instant without any renewal (ADR-012);
|
||||
* the service accepts it only for lowering changes.
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
/** A restore this far away would be forgotten by everyone, not a boost. */
|
||||
public static final Duration MAX_BOOST = Duration.ofHours(72);
|
||||
|
||||
/** A held change this far away is a trip, not a forgotten setting. */
|
||||
public static final Duration MAX_HOLD = Duration.ofDays(30);
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
public Command {
|
||||
Objects.requireNonNull(setpoint, "setpoint");
|
||||
Objects.requireNonNull(requestedBy, "requestedBy");
|
||||
if (duration != null && (duration.isNegative() || duration.isZero())) {
|
||||
throw new IllegalArgumentException("duration must be positive");
|
||||
}
|
||||
if (duration != null && heldUntil != null) {
|
||||
throw new IllegalArgumentException("a command has a duration or a held end, not both");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,32 @@ 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. A held change (ADR-012) has {@code leaseUntil == due}: it needs no renewal
|
||||
* and runs at its end. {@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 +35,12 @@ 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);
|
||||
}
|
||||
|
||||
/** The lease only ever grows: a renewal must not shorten a held change. */
|
||||
public Restore leasedUntil(Instant newLeaseUntil) {
|
||||
var longest = newLeaseUntil.isAfter(leaseUntil) ? newLeaseUntil : leaseUntil;
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, longest, otherApplied);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.gateway.domain;
|
||||
|
||||
/** The outcome of restoring one setpoint on request. */
|
||||
public record RestoreResult(Outcome outcome, String message) {
|
||||
|
||||
/**
|
||||
* NOT_PENDING: nothing to restore. CONFLICT: the register no longer holds our value, so it was left
|
||||
* alone and the restore dropped. TOO_SOON: the rate limit of S4. FAILED: write or read-back failed,
|
||||
* the restore stays pending.
|
||||
*/
|
||||
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.gateway.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A setpoint value the gateway read back after its own write, and when. */
|
||||
public record VerifiedValue(int value, Instant at) {
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,9 @@ import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.CommandResult.Status;
|
||||
import home.gateway.domain.Reading;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult;
|
||||
import home.gateway.domain.RestoreResult.Outcome;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -32,6 +35,7 @@ import java.time.ZoneOffset;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -68,6 +72,10 @@ class CommandsEndpointTest {
|
||||
private final Snapshot snapshot = new Snapshot();
|
||||
private final List<Command> received = new ArrayList<>();
|
||||
private CommandResult nextResult;
|
||||
private int renewals;
|
||||
private RestoreResult nextRestore;
|
||||
private final List<String> restoreRequests = new ArrayList<>();
|
||||
private final Map<String, VerifiedValue> verified = new HashMap<>();
|
||||
private GatewayHttpServer server;
|
||||
|
||||
private final Commands commands = new Commands() {
|
||||
@@ -77,6 +85,23 @@ class CommandsEndpointTest {
|
||||
return nextResult;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restoreNow(String setpoint, String requestedBy) {
|
||||
restoreRequests.add(setpoint + " by " + requestedBy);
|
||||
return nextRestore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, VerifiedValue> verifiedValues() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
@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 +112,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 +130,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) {
|
||||
@@ -151,6 +182,16 @@ class CommandsEndpointTest {
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "mariusz")), received);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postWithUntilAsksForAHeldChange() throws Exception {
|
||||
nextResult = new CommandResult(Status.APPLIED, "ok", null);
|
||||
|
||||
authorized("POST", example("request-held.json"));
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"),
|
||||
"backend")), received);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusualRequesterNamesFallBackToBackend() throws Exception {
|
||||
nextResult = new CommandResult(Status.APPLIED, "ok", null);
|
||||
@@ -193,7 +234,10 @@ class CommandsEndpointTest {
|
||||
"{\"setpoint\":5,\"value\":40}", "{\"setpoint\":\"cwu_economy\",\"value\":\"40\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":40.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":0}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":\"x\"}")) {
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":40,\"minutes\":\"x\"}",
|
||||
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"until\":\"tomorrow\"}",
|
||||
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"until\":5}",
|
||||
"{\"setpoint\":\"cwu_comfort\",\"value\":10,\"minutes\":60,\"until\":\"2026-11-02T18:00:00Z\"}")) {
|
||||
assertEquals(400, authorized("POST", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(received.isEmpty());
|
||||
@@ -256,6 +300,148 @@ 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());
|
||||
}
|
||||
|
||||
private Integer shownValue(String setpoint) throws Exception {
|
||||
for (var view : json.readTree(authorized("GET", null).body()).get("setpoints")) {
|
||||
if (view.get("name").asText().equals(setpoint)) {
|
||||
return view.get("value").isNull() ? null : view.get("value").asInt();
|
||||
}
|
||||
}
|
||||
throw new AssertionError(setpoint);
|
||||
}
|
||||
|
||||
private void polled(Instant time, double cwuEconomy) {
|
||||
snapshot.register("pump", Duration.ofSeconds(10), T0);
|
||||
snapshot.recordSuccess("pump", time, List.of(new Reading("db", "heat_pump", Map.of(),
|
||||
Map.of("setpoint_cwu_economy", cwuEconomy), time)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aVerifiedValueNewerThanThePollIsShown() throws Exception {
|
||||
polled(T0, 8);
|
||||
verified.put("cwu_economy", new VerifiedValue(45, T0.plusSeconds(30)));
|
||||
|
||||
assertEquals(45, shownValue("cwu_economy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aVerifiedValueIsShownBeforeTheFirstPoll() throws Exception {
|
||||
verified.put("cwu_economy", new VerifiedValue(45, T0));
|
||||
|
||||
assertEquals(45, shownValue("cwu_economy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNewerPollOverridesTheVerifiedValue() throws Exception {
|
||||
polled(T0.plusSeconds(300), 12);
|
||||
verified.put("cwu_economy", new VerifiedValue(45, T0));
|
||||
|
||||
assertEquals(12, shownValue("cwu_economy"));
|
||||
}
|
||||
|
||||
private HttpResponse<String> restoreNow(String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of("Authorization", "Bearer " + BEARER));
|
||||
all.addAll(List.of(headers));
|
||||
return sendTo("/commands/restore", "POST", body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowAsksForTheSetpointAndTakesTheRequesterFromTheHeader() throws Exception {
|
||||
nextRestore = new RestoreResult(Outcome.RESTORED, "restored to 8");
|
||||
|
||||
var response = restoreNow(example("request-restore.json"), "X-Requested-By", "mariusz");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(exampleJson("response-restored.json"), json.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by mariusz"), restoreRequests);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowStatusFollowsTheOutcome() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
nextRestore = new RestoreResult(entry.getKey(), "x");
|
||||
assertEquals(entry.getValue(), restoreNow("{\"setpoint\":\"cwu_economy\"}").statusCode(),
|
||||
entry.getKey().name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreConflictMatchesExample() throws Exception {
|
||||
nextRestore = new RestoreResult(Outcome.CONFLICT, "cwu_economy holds 12, not our 45: left alone");
|
||||
|
||||
var response = restoreNow(example("request-restore.json"));
|
||||
|
||||
assertEquals(409, response.statusCode());
|
||||
assertEquals(exampleJson("response-restore-conflict.json"), json.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowRejectsBadInputAndOtherMethods() throws Exception {
|
||||
for (var body : List.of("not json", "{}", "{\"setpoint\":5}")) {
|
||||
assertEquals(400, restoreNow(body).statusCode(), body);
|
||||
}
|
||||
assertEquals(405, sendTo("/commands/restore", "GET", null, "Authorization", "Bearer " + BEARER).statusCode());
|
||||
assertTrue(restoreRequests.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowNeedsTheTokenAndSharesTheThrottle() throws Exception {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(401, sendTo("/commands/restore", "POST", "{\"setpoint\":\"cwu_economy\"}").statusCode());
|
||||
}
|
||||
|
||||
assertEquals(429, restoreNow("{\"setpoint\":\"cwu_economy\"}").statusCode());
|
||||
assertTrue(restoreRequests.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowIs503WhenTheWritePathIsUnavailable() 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, restoreNow("{\"setpoint\":\"cwu_economy\"}").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,32 +8,52 @@ 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();
|
||||
private final AtomicInteger open = new AtomicInteger();
|
||||
private final List<int[]> writes = new CopyOnWriteArrayList<>();
|
||||
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();
|
||||
}
|
||||
@@ -42,6 +62,11 @@ public final class MockModbusServer implements Closeable {
|
||||
return connections.get();
|
||||
}
|
||||
|
||||
/** Connections the client has not closed yet. */
|
||||
public int openConnections() {
|
||||
return open.get();
|
||||
}
|
||||
|
||||
public void holding(int address, int value) {
|
||||
holding.put(address, value);
|
||||
}
|
||||
@@ -84,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();
|
||||
@@ -94,6 +129,7 @@ public final class MockModbusServer implements Closeable {
|
||||
try {
|
||||
Socket client = server.accept();
|
||||
connections.incrementAndGet();
|
||||
open.incrementAndGet();
|
||||
Thread.ofVirtual().start(() -> serve(client));
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
@@ -105,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;
|
||||
@@ -124,13 +158,57 @@ 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
|
||||
} finally {
|
||||
open.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,28 @@ class TcpTransportTest {
|
||||
assertEquals(1, server.connections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesTheConnectionWhenIdleAndReconnectsForTheNextPoll() throws Exception {
|
||||
transport.close();
|
||||
transport = new TcpTransport(new TcpTransport.Settings(
|
||||
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO),
|
||||
new MbapFraming(), Duration.ofMillis(100));
|
||||
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
assertEquals(1, server.openConnections());
|
||||
|
||||
// Idle: the controller is free for the vendor's app, and no socket goes stale.
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
while (server.openConnections() > 0 && System.nanoTime() < deadline) {
|
||||
Thread.sleep(20);
|
||||
}
|
||||
assertEquals(0, server.openConnections());
|
||||
|
||||
assertArrayEquals(HOLDING_10_RESPONSE, transport.exchange(1, READ_HOLDING_10));
|
||||
assertEquals(2, server.connections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconnectsAfterServerDropsConnection() throws IOException {
|
||||
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");
|
||||
|
||||
@@ -4,6 +4,8 @@ import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult.Status;
|
||||
import home.gateway.domain.Reading;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.RestoreResult.Outcome;
|
||||
import home.gateway.domain.VerifiedValue;
|
||||
import home.gateway.domain.Setpoint;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -194,7 +196,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 +208,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 +283,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 +386,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)) {
|
||||
@@ -410,6 +414,7 @@ class CommandServiceTest {
|
||||
assertTrue(inPause.await(5, TimeUnit.SECONDS), "the overdue restore is in its pause");
|
||||
|
||||
assertThrows(CommandsUnavailableException.class, () -> guarded.apply(command("co_comfort", 25, null)));
|
||||
assertThrows(CommandsUnavailableException.class, () -> guarded.restoreNow("cwu_comfort", "app"));
|
||||
assertEquals(20, registers.values.get(4132));
|
||||
pauseGate.countDown();
|
||||
assertTrue(scheduler.awaitFirstTick(Duration.ofSeconds(5)));
|
||||
@@ -461,7 +466,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 +530,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);
|
||||
|
||||
@@ -614,6 +619,119 @@ class CommandServiceTest {
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void remembersTheVerifiedValueOfASuccessfulWrite() throws IOException {
|
||||
var service = service();
|
||||
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
|
||||
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
|
||||
assertNull(service.verifiedValues().get("co_comfort"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedWriteDoesNotChangeTheVerifiedValue() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
clock.advance(Duration.ofSeconds(60));
|
||||
registers.ignoreWrites = true;
|
||||
|
||||
service.apply(command("cwu_comfort", 40, null));
|
||||
|
||||
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSuccessfulRestoreSetsTheVerifiedValueToTheOriginal() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(HOUR);
|
||||
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(new VerifiedValue(45, T0.plus(HOUR)), service.verifiedValues().get("cwu_comfort"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowPutsTheOriginalBackAndAuditsTheRequester() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(Duration.ofSeconds(60));
|
||||
|
||||
var result = service.restoreNow("cwu_comfort", "mariusz");
|
||||
|
||||
assertEquals(Outcome.RESTORED, result.outcome());
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
assertTrue(service.pendingRestores().isEmpty());
|
||||
assertTrue(stored.isEmpty());
|
||||
assertEquals("APPLIED", lastStatus());
|
||||
assertEquals("mariusz", audit.getLast().tags().get("requested_by"));
|
||||
assertEquals(45, service.verifiedValues().get("cwu_comfort").value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowWithoutAPendingRestoreDoesNothing() throws IOException {
|
||||
var service = service();
|
||||
|
||||
var result = service.restoreNow("cwu_comfort", "mariusz");
|
||||
|
||||
assertEquals(Outcome.NOT_PENDING, result.outcome());
|
||||
assertEquals(0, registers.reads);
|
||||
assertEquals(0, registers.writes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowLeavesAChangedValueAndDropsTheRestore() throws IOException {
|
||||
var service = boosted();
|
||||
registers.values.put(4144, 38);
|
||||
clock.advance(Duration.ofSeconds(60));
|
||||
var writes = registers.writes;
|
||||
|
||||
var result = service.restoreNow("cwu_comfort", "mariusz");
|
||||
|
||||
assertEquals(Outcome.CONFLICT, result.outcome());
|
||||
assertEquals(38, registers.values.get(4144));
|
||||
assertEquals(writes, registers.writes);
|
||||
assertTrue(service.pendingRestores().isEmpty());
|
||||
assertEquals("CONFLICT", lastStatus());
|
||||
assertEquals("mariusz", audit.getLast().tags().get("requested_by"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowObeysTheRateLimit() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(Duration.ofSeconds(59));
|
||||
var writes = registers.writes;
|
||||
|
||||
var result = service.restoreNow("cwu_comfort", "mariusz");
|
||||
|
||||
assertEquals(Outcome.TOO_SOON, result.outcome());
|
||||
assertEquals(writes, registers.writes);
|
||||
assertEquals(1, service.pendingRestores().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRestoreNowStaysPendingWithItsBackoff() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, Duration.ofSeconds(90)));
|
||||
clock.advance(Duration.ofSeconds(60));
|
||||
registers.failWrites = true;
|
||||
|
||||
var result = service.restoreNow("cwu_comfort", "mariusz");
|
||||
|
||||
assertEquals(Outcome.FAILED, result.outcome());
|
||||
assertEquals(1, service.pendingRestores().size());
|
||||
assertEquals(1, stored.size());
|
||||
assertEquals("FAILED", lastStatus());
|
||||
assertEquals(new VerifiedValue(50, T0), service.verifiedValues().get("cwu_comfort"));
|
||||
|
||||
registers.failWrites = false;
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
service.runDueRestores();
|
||||
assertEquals(1, service.pendingRestores().size());
|
||||
clock.advance(Duration.ofSeconds(30));
|
||||
service.runDueRestores();
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRestoreCountsAsALastWrite() throws IOException {
|
||||
var service = boosted();
|
||||
@@ -690,19 +808,304 @@ 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 Command held(String setpoint, int value, Duration fromNow) {
|
||||
return new Command(setpoint, value, null, T0.plus(fromNow), "app");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldLoweringStaysWithoutRenewalsAndIsRestoredAtItsEnd() throws IOException {
|
||||
var service = service();
|
||||
var result = service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
|
||||
|
||||
assertEquals(Status.APPLIED, result.status());
|
||||
assertEquals(T0.plus(Duration.ofDays(7)), result.restoreAt());
|
||||
assertEquals(T0.plus(Duration.ofDays(7)), stored.getFirst().leaseUntil());
|
||||
clock.advance(Duration.ofDays(7).minusSeconds(1));
|
||||
service.runDueRestores();
|
||||
assertEquals(35, registers.values.get(4144));
|
||||
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
assertTrue(stored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldRaiseIsRejectedWithoutAWrite() throws IOException {
|
||||
var service = service();
|
||||
|
||||
for (var value : List.of(45, 46, 50)) {
|
||||
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", value, Duration.ofDays(1))).status());
|
||||
}
|
||||
|
||||
assertEquals(0, registers.writes);
|
||||
assertTrue(stored.isEmpty());
|
||||
assertEquals("REJECTED", lastStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldEndInThePastOrBeyondThirtyDaysIsRejectedWithoutIo() throws IOException {
|
||||
var service = service();
|
||||
|
||||
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 35, Duration.ZERO)).status());
|
||||
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 35, Duration.ofSeconds(-5))).status());
|
||||
assertEquals(Status.REJECTED,
|
||||
service.apply(held("cwu_comfort", 35, Command.MAX_HOLD.plusSeconds(1))).status());
|
||||
|
||||
assertEquals(0, registers.reads);
|
||||
assertEquals(0, registers.writes);
|
||||
assertEquals(Status.APPLIED, service.apply(held("cwu_comfort", 35, Command.MAX_HOLD)).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingNeverShortensAHeldChange() throws IOException {
|
||||
var service = service();
|
||||
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
|
||||
|
||||
clock.advance(Duration.ofDays(3));
|
||||
assertEquals(0, service.renewLeases());
|
||||
|
||||
assertEquals(T0.plus(Duration.ofDays(7)), service.pendingRestores().getFirst().leaseUntil());
|
||||
clock.advance(Duration.ofDays(4).minusMinutes(5));
|
||||
service.renewLeases();
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
service.runDueRestores();
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeKeepsItsEndWhenRenewedInItsLastMinutes() throws IOException {
|
||||
var service = service();
|
||||
service.apply(held("cwu_comfort", 35, Duration.ofMinutes(20)));
|
||||
clock.advance(Duration.ofMinutes(10));
|
||||
|
||||
service.renewLeases();
|
||||
clock.advance(Duration.ofMinutes(10));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSurvivesARestartAndRunsAtItsEnd() throws IOException {
|
||||
service().apply(held("cwu_comfort", 35, Duration.ofDays(7)));
|
||||
clock.advance(Duration.ofDays(3));
|
||||
|
||||
var restarted = service();
|
||||
restarted.runDueRestores();
|
||||
assertEquals(35, registers.values.get(4144));
|
||||
clock.advance(Duration.ofDays(4));
|
||||
restarted.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRestoreAtTheEndOfAHeldChangeGoesThroughTheBackoff() throws IOException {
|
||||
var service = service();
|
||||
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
|
||||
clock.advance(Duration.ofDays(7));
|
||||
registers.failWrites = true;
|
||||
|
||||
service.runDueRestores();
|
||||
var writes = registers.writes;
|
||||
clock.advance(Duration.ofSeconds(59));
|
||||
service.runDueRestores();
|
||||
assertEquals(writes, registers.writes);
|
||||
registers.failWrites = false;
|
||||
clock.advance(Duration.ofSeconds(1));
|
||||
service.runDueRestores();
|
||||
|
||||
assertEquals(45, registers.values.get(4144));
|
||||
assertEquals("FAILED", audit.get(audit.size() - 2).tags().get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeReplacesABoostButKeepsItsOriginalAndMustLowerBelowIt() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 50, HOUR));
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
|
||||
assertEquals(Status.REJECTED, service.apply(held("cwu_comfort", 47, Duration.ofDays(7))).status());
|
||||
assertEquals(50, registers.values.get(4144));
|
||||
assertEquals(Status.APPLIED, service.apply(held("cwu_comfort", 40, Duration.ofDays(7))).status());
|
||||
|
||||
var restore = service.pendingRestores().getFirst();
|
||||
assertEquals(45, restore.originalValue());
|
||||
assertEquals(T0.plus(Duration.ofDays(7)), restore.runAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBoostOnAHeldChangeIsAnOrdinaryLeaseAgain() throws IOException {
|
||||
var service = service();
|
||||
service.apply(held("cwu_comfort", 35, Duration.ofDays(7)));
|
||||
clock.advance(Duration.ofMinutes(5));
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
|
||||
assertEquals(clock.instant().plus(Restore.LEASE), service.pendingRestores().getFirst().runAt());
|
||||
assertEquals(45, service.pendingRestores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewingStillExtendsANormalLease() 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), service.pendingRestores().getFirst().leaseUntil());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant now = T0;
|
||||
private long nanos;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "until": "2026-11-02T17:00:00Z" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "setpoint": "cwu_economy" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "CONFLICT", "message": "cwu_economy holds 12, not our 45: left alone" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -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