Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ef638fcd | ||
|
|
8396372943 | ||
|
|
25f76ea084 | ||
|
|
8146296061 | ||
|
|
c21094b4f4 | ||
|
|
3b6d073b1f | ||
|
|
63c552dfd7 | ||
|
|
de19424a7b | ||
|
|
03b4ee1a48 | ||
|
|
355dd76555 | ||
|
|
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 |
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
|
||||
|
||||
+22
-1
@@ -3,6 +3,10 @@
|
||||
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.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
@@ -16,7 +20,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 +29,23 @@ 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. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
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,18 @@ 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.PiSensorService;
|
||||
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 +33,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 HistoryService(influx, clock), setpoints);
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock));
|
||||
}
|
||||
|
||||
/** 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,15 +9,20 @@ 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;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.PiSensorService;
|
||||
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 +45,23 @@ public class ApiServer {
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
values = Map.of();
|
||||
} else if (values.containsValue(null)) {
|
||||
throw new IllegalArgumentException("a value must be a number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +77,9 @@ 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,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
@@ -70,13 +90,26 @@ 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/pi-sensor", query -> piSensor.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 +208,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 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
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; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "for setpoints of the preset"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
@@ -197,6 +247,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.PiSensorView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
|
||||
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
|
||||
public class PiSensorService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public PiSensorService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public PiSensorView current() throws IOException {
|
||||
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
|
||||
Map<String, Double> f = live.fields();
|
||||
return new PiSensorView(live.source().time(), live.source().stale(),
|
||||
f.get("temperature"), f.get("humidity"), f.get("pressure"));
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@ 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.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
@@ -38,48 +43,86 @@ 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
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
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 = withValues(preset.get().changes(), values);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
requireWithinLimits(values, limits);
|
||||
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 static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||
if (!own.containsAll(values.keySet())) {
|
||||
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
if (value != null && !limit.allows(value)) {
|
||||
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||
+ " in steps of " + limit.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +131,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) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
|
||||
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
|
||||
* without any reading.
|
||||
*/
|
||||
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -151,6 +151,11 @@ class BackendTest {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
|
||||
@@ -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;
|
||||
@@ -17,6 +20,7 @@ import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -57,6 +61,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 +88,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 +105,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,11 +125,17 @@ 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 SetpointService(commands, PresetFile.load(null)));
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -137,14 +159,21 @@ 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))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
List.of(new Measurement("measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
@@ -153,6 +182,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 +211,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,10 +236,26 @@ 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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement bme = devices.getLast().measurements().get(0);
|
||||
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
@@ -216,7 +270,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 +355,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 +443,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 +456,66 @@ 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 awayWithOwnValuesSendsThem() throws Exception {
|
||||
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||
values);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||
|
||||
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@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 +526,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 +535,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;
|
||||
@@ -9,6 +13,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -40,7 +45,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() {
|
||||
@@ -166,6 +171,33 @@ class ServicesTest {
|
||||
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
|
||||
return new PiSensorService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
|
||||
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertFalse(view.stale());
|
||||
assertEquals(20.17, view.temperature());
|
||||
assertEquals(45.82, view.humidity());
|
||||
assertEquals(985.8, view.pressure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorShowsNoValues() throws IOException {
|
||||
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
|
||||
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.temperature());
|
||||
assertTrue(piSensor().stale());
|
||||
assertNull(piSensor().time());
|
||||
}
|
||||
|
||||
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
@@ -215,6 +247,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 +375,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 +385,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,8 +18,10 @@ 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;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -25,6 +29,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 +38,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 +54,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 +70,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 +127,93 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "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 ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "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, Map.of(), "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, Map.of(), "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, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -129,9 +223,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, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -145,7 +239,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -156,7 +250,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, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -166,7 +260,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -176,7 +270,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, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -186,7 +280,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, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -198,7 +292,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, Map.of(), "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,7 @@
|
||||
{
|
||||
"time": "2026-10-23T21:00:00Z",
|
||||
"stale": true,
|
||||
"temperature": null,
|
||||
"humidity": null,
|
||||
"pressure": null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"temperature": 20.17,
|
||||
"humidity": 45.82,
|
||||
"pressure": 985.8
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
|
||||
@@ -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
|
||||
|
||||
|
||||
+11
-6
@@ -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 |
|
||||
@@ -74,19 +74,24 @@ Layers in both apps, dependencies point inwards only:
|
||||
Functional:
|
||||
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures.
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room.
|
||||
- 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 by default, lower if the user chooses) 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,124 @@
|
||||
import { cleanup, 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 { Energy as EnergyData, 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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Energy flow arrows", () => {
|
||||
async function arrows(patch: Partial<EnergyData>) {
|
||||
serve({ "api/energy/details": example("energy-details"), "api/energy": { ...example<EnergyData>("energy"), ...patch } });
|
||||
const Energy = await energy();
|
||||
const { container } = render(<Energy />);
|
||||
await screen.findByText("Energia teraz");
|
||||
const on = (flow: string) => container.querySelector(`[data-flow="${flow}-house"] [data-arrow]`);
|
||||
return { on, container };
|
||||
}
|
||||
const angle = (el: Element | null) => /rotate\((-?[\d.]+)\)/.exec(el!.getAttribute("transform")!)![1];
|
||||
|
||||
it("points from the grid to the house on import and back on export", async () => {
|
||||
const imported = await arrows({ gridPowerW: 645 });
|
||||
expect(Number(angle(imported.on("grid")))).toBeGreaterThan(90);
|
||||
expect(screen.getByText("Sieć zasila dom: 645 W")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const exported = await arrows({ gridPowerW: -800 });
|
||||
expect(Math.abs(Number(angle(exported.on("grid"))))).toBeLessThan(90);
|
||||
expect(screen.getByText(/Dom oddaje do sieci: 800 W/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("points from the battery to the house on discharge and the other way on charge", async () => {
|
||||
const discharging = await arrows({ batteryPowerW: 300 });
|
||||
expect(Number(angle(discharging.on("battery")))).toBe(-90);
|
||||
expect(screen.getByText(/Bateria zasila dom/)).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const charging = await arrows({ batteryPowerW: -300 });
|
||||
expect(Number(angle(charging.on("battery")))).toBe(90);
|
||||
expect(screen.getByText(/Dom ładuje baterię/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no arrow for about 0 W or a missing value", async () => {
|
||||
const quiet = await arrows({ pvPowerW: 3, gridPowerW: -4, batteryPowerW: 0 });
|
||||
for (const flow of ["pv", "grid", "battery"]) expect(quiet.on(flow)).toBeNull();
|
||||
expect(screen.getByText("Bateria: bez przepływu")).toBeInTheDocument();
|
||||
cleanup();
|
||||
|
||||
const missing = await arrows({ pvPowerW: null, gridPowerW: null, batteryPowerW: null });
|
||||
for (const flow of ["pv", "grid", "battery"]) expect(missing.on(flow)).toBeNull();
|
||||
});
|
||||
|
||||
it("points from the sun to the house", async () => {
|
||||
const sunny = await arrows({ pvPowerW: 2500 });
|
||||
expect(Number(angle(sunny.on("pv")))).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Słońce zasila dom: 2,5 kW")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+45
-2
@@ -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;
|
||||
@@ -41,6 +43,13 @@ const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number;
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
// A triangle halfway along the line, pointing at the sink: the direction is readable without the moving dots.
|
||||
function Arrowhead({ from, to }: { from: { x: number; y: number; colour: string }; to: { x: number; y: number } }) {
|
||||
const angle = (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
|
||||
const x = (from.x + to.x) / 2, y = (from.y + to.y) / 2;
|
||||
return <polygon data-arrow points="-11,-10 11,0 -11,10" fill={from.colour} transform={`translate(${x} ${y}) rotate(${angle})`} />;
|
||||
}
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
const a = NODES[from], b = NODES[to];
|
||||
const on = flowing(watts);
|
||||
@@ -52,6 +61,7 @@ function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof
|
||||
<g data-flow={`${from}-${to}`} data-on={on}>
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
{on && <Arrowhead from={s} to={e} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -69,9 +79,23 @@ function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note
|
||||
);
|
||||
}
|
||||
|
||||
// The picture in words, for screen readers: who gives to whom.
|
||||
export function flowSummary(e: EnergyData) {
|
||||
const w = (watts: number | null) => power(magnitude(watts));
|
||||
return [
|
||||
flowing(e.pvPowerW) ? `Słońce zasila dom: ${w(e.pvPowerW)}` : "Słońce: brak produkcji",
|
||||
!flowing(e.gridPowerW) ? "Sieć: bez wymiany"
|
||||
: e.gridPowerW! > 0 ? `Sieć zasila dom: ${w(e.gridPowerW)}` : `Dom oddaje do sieci: ${w(e.gridPowerW)}`,
|
||||
!flowing(e.batteryPowerW) ? "Bateria: bez przepływu"
|
||||
: e.batteryPowerW! > 0 ? `Bateria zasila dom: ${w(e.batteryPowerW)}` : `Dom ładuje baterię: ${w(e.batteryPowerW)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function FlowPicture({ e }: { e: EnergyData }) {
|
||||
return (
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" role="img" aria-label="Przepływ energii w domu">
|
||||
<>
|
||||
<ul className="sr-only">{flowSummary(e).map((t) => <li key={t}>{t}</li>)}</ul>
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" aria-hidden="true">
|
||||
<Flow from="pv" to="house" watts={e.pvPowerW} />
|
||||
<Flow from="grid" to="house" watts={e.gridPowerW} />
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
@@ -80,6 +104,7 @@ function FlowPicture({ e }: { e: EnergyData }) {
|
||||
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +163,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 +199,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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
import { PI_SENSOR_TITLE } from "./Overview";
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
|
||||
function serveHeatPump(hp: HeatPump) {
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor") });
|
||||
}
|
||||
|
||||
const flowing = (container: HTMLElement) =>
|
||||
@@ -17,6 +19,48 @@ const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("shows the sensor of the Raspberry Pi in the living room", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperatura20,2 °C");
|
||||
expect(card).toHaveTextContent("Wilgotność45,8 %");
|
||||
expect(card).toHaveTextContent("Ciśnienie986 hPa");
|
||||
});
|
||||
|
||||
it("says there is no reading when the sensor is stale", async () => {
|
||||
serve({ "api/heat-pump": fresh(example("heat-pump")), "api/energy": example("energy"), "api/history": history,
|
||||
"api/pi-sensor": example("pi-sensor-stale") });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
const card = (await screen.findByRole("heading", { name: PI_SENSOR_TITLE })).closest("section")!;
|
||||
expect(card).toHaveTextContent("Temperaturabrak odczytu");
|
||||
expect(card).toHaveTextContent("Wilgotnośćbrak odczytu");
|
||||
expect(card).toHaveTextContent("Ciśnieniebrak odczytu");
|
||||
});
|
||||
|
||||
it("shows a running part in the red accent and a stopped one in grey", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true } });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByTitle("Pompa CWU: pracuje")).toHaveClass("bg-run-soft");
|
||||
expect(screen.getByTitle("Pompa CO: stoi")).not.toHaveClass("bg-run-soft");
|
||||
expect(screen.getAllByText("Grzeje wodę (CWU)")[0].closest("span")).toHaveClass("text-run");
|
||||
});
|
||||
|
||||
it("keeps the alarm solid, unlike the red of a running part", async () => {
|
||||
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect((await screen.findAllByText("Alarm"))[0].closest("span")).toHaveClass("bg-alarm", "text-white");
|
||||
});
|
||||
|
||||
it("draws a resting pump with nothing flowing", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, percent, power } from "./format";
|
||||
import { celsius, degrees, hour, longDate, number, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PumpState } from "./types";
|
||||
import type { Energy, HeatPump, History, PiSensor, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
|
||||
@@ -32,9 +32,9 @@ const TEMPERATURE_LINES: Line[] = [
|
||||
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
|
||||
|
||||
function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
|
||||
if (hp.alarm) return <Pill tone="bad" live><Icon name="alert" className="size-3.5" />Alarm</Pill>;
|
||||
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="off">Praca prawidłowa</Pill> : <Pill tone="run" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
@@ -59,7 +59,7 @@ function Tile({ icon, title, value, note, status, children }: {
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="run" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
@@ -80,7 +80,7 @@ function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is green and pulses.
|
||||
// Every moving part as a chip under the picture: what runs now is red and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
const items: [string, boolean | null, string][] = [
|
||||
@@ -95,11 +95,11 @@ function Devices({ hp }: { hp: HeatPump }) {
|
||||
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{items.map(([name, on, text]) => (
|
||||
<li key={name} title={`${name}: ${text}`}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "ok" : "off"} live={!!on} />
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-run/30 bg-run-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "run" : "off"} live={!!on} />
|
||||
<span>{name}</span>
|
||||
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
|
||||
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
|
||||
<b className={`hidden sm:inline ${on ? "text-run" : "text-soft"}`}>{text}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -119,6 +119,24 @@ function Targets({ hp }: { hp: HeatPump }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The Raspberry Pi with the BME280 stands in the living room.
|
||||
export const PI_SENSOR_TITLE = "Salon";
|
||||
|
||||
const NO_READING = "brak odczytu";
|
||||
const reading = (value: number | null | undefined, digits: number, unit: string) =>
|
||||
value == null ? NO_READING : `${number(value, digits)} ${unit}`;
|
||||
|
||||
function PiSensorCard() {
|
||||
const { data } = usePoll<PiSensor>("api/pi-sensor");
|
||||
return (
|
||||
<Card title={PI_SENSOR_TITLE}>
|
||||
<Row label="Temperatura" value={reading(data?.temperature, 1, "°C")} />
|
||||
<Row label="Wilgotność" value={reading(data?.humidity, 1, "%")} />
|
||||
<Row label="Ciśnienie" value={reading(data?.pressure, 0, "hPa")} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
@@ -211,6 +229,7 @@ export function Overview() {
|
||||
</Tile>
|
||||
</div>
|
||||
<TemperatureChart />
|
||||
<PiSensorCard />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -111,10 +111,10 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
|
||||
{/* Photovoltaics: a light sweeps over the panels while they produce */}
|
||||
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
|
||||
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="var(--color-run)" on={compressor} />
|
||||
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
|
||||
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="var(--color-run)" />}
|
||||
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
|
||||
|
||||
{/* Warmth rising from the floor while the CO pump runs */}
|
||||
@@ -124,7 +124,7 @@ export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | n
|
||||
))}
|
||||
|
||||
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-run)" : undefined}
|
||||
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
|
||||
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
|
||||
|
||||
@@ -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,138 @@ 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("shows the preset temperatures in the trip form and sends the ones the user chose", 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" }));
|
||||
expect(screen.getAllByRole("status").map((o) => o.textContent)).toEqual(["10 °C", "15 °C"]);
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 12 °C do powrotu, potem wróci 45 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ustaw wyjazd" }));
|
||||
|
||||
const body = JSON.parse(String(posts(fetch)[0][1]!.body));
|
||||
expect(body.values).toEqual({ cwu_comfort: 12, co_comfort: 14 });
|
||||
});
|
||||
|
||||
it("never lets a trip temperature reach the current value", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "co_comfort")!.value = 15;
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText(paragraph("Ogrzewanie domu, komfort: 14 °C do powrotu, potem wróci 15 °C."))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables a trip temperature that cannot be lowered and the confirmation with it", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "co_comfort")!.value = 5;
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByLabelText("Mniej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Więcej: Ogrzewanie domu, komfort")).toBeDisabled();
|
||||
expect(screen.getByText("Już na minimum (5 °C), nie da się obniżyć.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Ustaw wyjazd" })).toBeDisabled();
|
||||
});
|
||||
|
||||
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 +215,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+144
-23
@@ -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, Preset, 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",
|
||||
@@ -62,6 +69,21 @@ function Button({ primary = false, big = false, children, ...props }: {
|
||||
);
|
||||
}
|
||||
|
||||
// Touch-sized − and + around the value; no value shows "brak odczytu".
|
||||
function Stepper({ label, value, min, max, disabled, onStep }: {
|
||||
label: string; value: number | null | undefined; min: number; max: number; disabled?: boolean; onStep: (direction: number) => void;
|
||||
}) {
|
||||
const none = value == null;
|
||||
const button = "size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40";
|
||||
return (
|
||||
<div className="my-2 flex items-center gap-3">
|
||||
<button aria-label={`Mniej: ${label}`} onClick={() => onStep(-1)} disabled={disabled || none || value <= min} className={button}>−</button>
|
||||
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{none ? "brak odczytu" : degrees(value)}</output>
|
||||
<button aria-label={`Więcej: ${label}`} onClick={() => onStep(1)} disabled={disabled || none || value >= max} className={button}>+</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultBox({ result }: { result: Result }) {
|
||||
if (!result) return null;
|
||||
const ok = result.status === "APPLIED";
|
||||
@@ -75,23 +97,86 @@ 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).
|
||||
// The user may only lower the away temperatures: the gateway refuses a held raise.
|
||||
const lowestAllowed = (s: Setpoint) => Math.min(s.max, s.value! - s.step);
|
||||
|
||||
function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (until: string, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
|
||||
}));
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const [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 && !stuck;
|
||||
return (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, { ...c, value: values[c.setpoint] }, true)}</p>)}
|
||||
<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>
|
||||
{preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} disabled={busy || max < min}
|
||||
onStep={(direction) => stepValue(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
|
||||
</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 +188,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 +203,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 +224,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 +257,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.until ? (
|
||||
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
|
||||
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
|
||||
) : (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</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>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
@@ -175,9 +286,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>
|
||||
)}
|
||||
@@ -201,13 +328,7 @@ export function Settings() {
|
||||
return (
|
||||
<div key={s.name} className={`border-t border-edge py-3 first:border-t-0 ${shown === s.value ? "" : "border-l-4 border-l-brand pl-3"}`}>
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<div className="my-2 flex items-center gap-3">
|
||||
<button aria-label={`Mniej: ${label}`} onClick={() => step(s, -1)} disabled={busy || unread(s) || shown! <= s.min}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">−</button>
|
||||
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{unread(s) ? "brak odczytu" : degrees(shown)}</output>
|
||||
<button aria-label={`Więcej: ${label}`} onClick={() => step(s, 1)} disabled={busy || unread(s) || shown! >= s.max}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">+</button>
|
||||
</div>
|
||||
<Stepper label={label} value={unread(s) ? null : shown} min={s.min} max={s.max} disabled={busy} onStep={(direction) => step(s, direction)} />
|
||||
<span className="text-sm text-soft">od {degrees(s.min)} do {degrees(s.max)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
--color-edge: #e2e9e5;
|
||||
--color-brand: #0f8a5f;
|
||||
--color-brand-soft: #e3f4ec;
|
||||
/* A part that runs; red but never the alarm, which is solid (ui.tsx). */
|
||||
--color-run: #c8283f;
|
||||
--color-run-soft: #fde9ec;
|
||||
--color-side: #0e2a21;
|
||||
--color-side-hi: #1b4d3b;
|
||||
--color-hot: #f26b1d;
|
||||
@@ -32,6 +35,8 @@
|
||||
--color-edge: #24352c;
|
||||
--color-brand: #3fd197;
|
||||
--color-brand-soft: #143628;
|
||||
--color-run: #ff7a8a;
|
||||
--color-run-soft: #3d141b;
|
||||
--color-side: #08170f;
|
||||
--color-side-hi: #17402f;
|
||||
--color-off: #4b5a52;
|
||||
|
||||
+40
-1
@@ -20,6 +20,14 @@ export interface HeatPump {
|
||||
setpoints: Record<"coEconomy" | "coComfort" | "cwuEconomy" | "cwuComfort", number | null>;
|
||||
}
|
||||
|
||||
export interface PiSensor {
|
||||
time: string | null;
|
||||
stale: boolean;
|
||||
temperature: number | null;
|
||||
humidity: number | null;
|
||||
pressure: number | null;
|
||||
}
|
||||
|
||||
export interface EnergySource {
|
||||
name: string;
|
||||
time: string | null;
|
||||
@@ -37,6 +45,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 +78,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 +97,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 +110,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 +123,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;
|
||||
|
||||
+5
-4
@@ -19,18 +19,19 @@ export function Card({ title, aside, className = "", children }: {
|
||||
);
|
||||
}
|
||||
|
||||
type Tone = "ok" | "warn" | "bad" | "off" | "hot";
|
||||
type Tone = "ok" | "run" | "warn" | "bad" | "off";
|
||||
|
||||
const PILL: Record<Tone, string> = {
|
||||
ok: "bg-brand-soft text-brand",
|
||||
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
bad: "bg-red-100 text-alarm dark:bg-red-950 dark:text-red-300",
|
||||
run: "bg-run-soft text-run",
|
||||
// Solid, unlike the soft "run": an alarm must never be mistaken for a part that works.
|
||||
bad: "bg-alarm text-white",
|
||||
off: "bg-edge text-soft",
|
||||
hot: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
|
||||
};
|
||||
|
||||
const DOT: Record<Tone, string> = {
|
||||
ok: "bg-brand", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off", hot: "bg-hot",
|
||||
ok: "bg-brand", run: "bg-run", warn: "bg-amber-500", bad: "bg-alarm", off: "bg-off",
|
||||
};
|
||||
|
||||
export function Pill({ tone, live = false, children }: { tone: Tone; live?: boolean; children: ReactNode }) {
|
||||
|
||||
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"]
|
||||
|
||||
+36
-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,42 @@ 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.
|
||||
|
||||
**Extra poll.** After a write or restore that took, the heat pump is polled once more 3 s later by its own poller thread (so never beside another read, and several writes share one poll), because its derived targets would otherwise show the old value until the next 300 s poll; a failure of it counts like any failed poll.
|
||||
|
||||
**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.
|
||||
@@ -48,6 +48,8 @@ import java.util.concurrent.TimeUnit;
|
||||
public final class Gateway implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Gateway.class);
|
||||
// Long enough for the controller to derive its target values from a new setpoint.
|
||||
private static final Duration REPOLL_DELAY = Duration.ofSeconds(3);
|
||||
private static final Duration WATCHDOG_PERIOD = Duration.ofSeconds(30);
|
||||
// The Pi has no RTC, so before NTP the clock reads an old date; no build can predate this one.
|
||||
// Raise it with a release when convenient; a too-old floor only weakens the guard.
|
||||
@@ -103,13 +105,13 @@ public final class Gateway implements AutoCloseable {
|
||||
InfluxWriter writer = new InfluxWriter(config.influx().settings());
|
||||
scheduleFlush(writer);
|
||||
Snapshot snapshot = new Snapshot();
|
||||
Poller poller = own(new Poller(devices, snapshot, writer, clock));
|
||||
Poller poller = own(new Poller(devices, snapshot, writer, clock, REPOLL_DELAY));
|
||||
poller.start();
|
||||
Set<String> critical = Set.copyOf(config.watchdog().criticalDevices());
|
||||
Watchdog watchdog = own(new Watchdog(snapshot, clock, critical,
|
||||
config.watchdog().allowedIntervals(), exit));
|
||||
watchdog.start(WATCHDOG_PERIOD);
|
||||
Commands commands = config.commands().enabled() ? startCommands(writer) : null;
|
||||
Commands commands = config.commands().enabled() ? startCommands(writer, poller) : null;
|
||||
// Pushed last, so it closes first: no request arrives while the rest shuts down.
|
||||
GatewayHttpServer http = own(new GatewayHttpServer(snapshot, clock, config.http().address(), critical));
|
||||
if (commands != null) {
|
||||
@@ -122,7 +124,7 @@ public final class Gateway implements AutoCloseable {
|
||||
* A restore file that cannot be read must not stop the heating side (polling, health), so the
|
||||
* write path stays off instead, and never writes, until someone has looked at the moved file.
|
||||
*/
|
||||
private Commands startCommands(InfluxWriter audit) throws IOException {
|
||||
private Commands startCommands(InfluxWriter audit, Poller poller) throws IOException {
|
||||
var settings = config.commands();
|
||||
var registers = new ModbusSetpointRegisters(heatPumpModbus, config.devices().heatPump().unitId());
|
||||
CommandService service;
|
||||
@@ -130,7 +132,8 @@ public final class Gateway implements AutoCloseable {
|
||||
service = new CommandService(settings.setpoints(), registers,
|
||||
new JsonRestoreStore(settings.restoreFile()), audit, clock, System::nanoTime,
|
||||
EARLIEST_PLAUSIBLE, settings.minInterval(),
|
||||
() -> Thread.sleep(settings.readBackDelayMillis()));
|
||||
() -> Thread.sleep(settings.readBackDelayMillis()),
|
||||
() -> poller.requestPoll(HeatPumpDevice.NAME));
|
||||
} catch (CorruptRestoreFileException e) {
|
||||
LOGGER.error("Commands disabled until restart, pending restores were NOT applied: "
|
||||
+ "restore file moved to {}", e.quarantined(), e);
|
||||
@@ -165,7 +168,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));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.Map;
|
||||
/** The Frisko heat pump controller: every poll yields the legacy POMPA point and the full heat_pump point. */
|
||||
public final class HeatPumpDevice implements Device {
|
||||
|
||||
public static final String NAME = "heat-pump";
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
|
||||
private final ModbusClient modbus;
|
||||
@@ -26,7 +27,7 @@ public final class HeatPumpDevice implements Device {
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "heat-pump";
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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,7 +5,6 @@ import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -13,10 +12,11 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Modbus TCP (MBAP). The MK01 gateway serves a single client and a second connection drops the
|
||||
* first, so requests never overlap and the socket is reused for the requests of one poll. It is
|
||||
* closed after a few idle seconds: MK01 resets a connection left idle for minutes, which cost every
|
||||
* other poll, and while one is open the vendor's app cannot connect.
|
||||
* Modbus over a TCP socket: Modbus TCP (MBAP) or RTU frames as an RS485 converter forwards them.
|
||||
* The MK01 gateway serves a single client and a second connection drops the first, so requests
|
||||
* never overlap and the socket is reused for the requests of one poll. It is closed after a few
|
||||
* idle seconds: MK01 resets a connection left idle for minutes, which cost every other poll, and
|
||||
* while one is open the vendor's app cannot connect.
|
||||
*/
|
||||
public final class TcpTransport implements ModbusTransport {
|
||||
|
||||
@@ -24,26 +24,32 @@ public final class TcpTransport implements ModbusTransport {
|
||||
Duration minimumPause) {
|
||||
}
|
||||
|
||||
private static final int HEADER_LENGTH = 7;
|
||||
static final Duration IDLE_CLOSE = Duration.ofSeconds(5);
|
||||
|
||||
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Settings settings;
|
||||
private final Framing framing;
|
||||
private final Duration idleClose;
|
||||
private final ScheduledExecutorService idleCloser = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("modbus-idle-close").factory());
|
||||
private Socket socket;
|
||||
private DataInputStream in;
|
||||
private int transactionId;
|
||||
private long lastResponseNanos;
|
||||
|
||||
/** Modbus TCP, as MK01 speaks it. */
|
||||
public TcpTransport(Settings settings) {
|
||||
this(settings, IDLE_CLOSE);
|
||||
this(settings, new MbapFraming(), IDLE_CLOSE);
|
||||
}
|
||||
|
||||
TcpTransport(Settings settings, Duration idleClose) {
|
||||
/** RTU frames with their CRC and no MBAP header, as a plain RS485 converter forwards them. */
|
||||
public static TcpTransport rtuOverTcp(Settings settings) {
|
||||
return new TcpTransport(settings, new RtuFraming(), IDLE_CLOSE);
|
||||
}
|
||||
|
||||
TcpTransport(Settings settings, Framing framing, Duration idleClose) {
|
||||
this.settings = settings;
|
||||
this.framing = framing;
|
||||
this.idleClose = idleClose;
|
||||
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
|
||||
}
|
||||
@@ -62,9 +68,8 @@ public final class TcpTransport implements ModbusTransport {
|
||||
try {
|
||||
awaitPause();
|
||||
connectIfNeeded();
|
||||
int id = nextTransactionId();
|
||||
socket.getOutputStream().write(frame(id, unitId, requestPdu));
|
||||
byte[] response = readResponse(id, unitId);
|
||||
socket.getOutputStream().write(framing.frame(unitId, requestPdu));
|
||||
byte[] response = framing.readResponse(in, unitId);
|
||||
lastResponseNanos = System.nanoTime();
|
||||
if (!idleCloser.isShutdown()) {
|
||||
idleCloser.schedule(this::closeIfIdle, idleClose.toNanos(), TimeUnit.NANOSECONDS);
|
||||
@@ -130,41 +135,6 @@ public final class TcpTransport implements ModbusTransport {
|
||||
in = new DataInputStream(candidate.getInputStream());
|
||||
}
|
||||
|
||||
private int nextTransactionId() {
|
||||
transactionId = (transactionId + 1) & 0xFFFF;
|
||||
return transactionId;
|
||||
}
|
||||
|
||||
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
|
||||
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
|
||||
.putShort((short) transactionId)
|
||||
.putShort((short) 0)
|
||||
.putShort((short) (pdu.length + 1))
|
||||
.put((byte) unitId)
|
||||
.put(pdu)
|
||||
.array();
|
||||
}
|
||||
|
||||
private byte[] readResponse(int expectedTransactionId, int expectedUnitId) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
|
||||
if (header.remaining() < HEADER_LENGTH) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
int id = header.getShort() & 0xFFFF;
|
||||
int protocol = header.getShort() & 0xFFFF;
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
if (protocol != 0 || id != expectedTransactionId || unitId != expectedUnitId || length < 2) {
|
||||
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
|
||||
.formatted(id, protocol, length, unitId));
|
||||
}
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
if (pdu.length < length - 1) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private void closeSocket() {
|
||||
if (socket == null) {
|
||||
return;
|
||||
|
||||
@@ -88,25 +88,31 @@ public class JsonRestoreStore implements RestoreStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** The file format: every field required except otherApplied, which only a lost reply sets. */
|
||||
/**
|
||||
* The file format: setpoint, register and both values are required. Without due the change lasts
|
||||
* until changed; without leaseUntil (a file from before ADR-011) the lease counts as lapsed.
|
||||
* otherApplied only a lost reply sets.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Stored(@JsonProperty(required = true) String setpoint,
|
||||
@JsonProperty(required = true) int register,
|
||||
@JsonProperty(required = true) int originalValue,
|
||||
@JsonProperty(required = true) int appliedValue,
|
||||
@JsonProperty(required = true) Instant due,
|
||||
Instant due,
|
||||
Instant leaseUntil,
|
||||
Integer otherApplied) {
|
||||
|
||||
static Stored of(Restore r) {
|
||||
return new Stored(r.setpoint(), r.register(), r.originalValue(), r.appliedValue(), r.due(),
|
||||
r.otherApplied());
|
||||
r.leaseUntil(), r.otherApplied());
|
||||
}
|
||||
|
||||
Restore restore() throws IOException {
|
||||
if (setpoint == null || due == null) {
|
||||
throw new IOException("restore without setpoint or due time");
|
||||
if (setpoint == null) {
|
||||
throw new IOException("restore without setpoint");
|
||||
}
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due, otherApplied);
|
||||
return new Restore(setpoint, register, originalValue, appliedValue, due,
|
||||
leaseUntil == null ? Instant.EPOCH : leaseUntil, otherApplied);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -27,7 +30,8 @@ import java.util.stream.Stream;
|
||||
/**
|
||||
* The only way the gateway writes to the controller (S1-S4). It checks the allowlist and the rate
|
||||
* limit, writes, reads back, audits every attempt and keeps the timed restores of ADR-006.
|
||||
* Calls are serialized: the controller takes one request at a time.
|
||||
* Calls are serialized: the controller takes one request at a time. After a write that took,
|
||||
* {@code pollSoon} asks for an extra poll, so the derived values show the change.
|
||||
*/
|
||||
public class CommandService implements Commands {
|
||||
|
||||
@@ -48,11 +52,13 @@ public class CommandService implements Commands {
|
||||
private final Instant earliestPlausible;
|
||||
private final Duration minInterval;
|
||||
private final Pause pause;
|
||||
private final Runnable pollSoon;
|
||||
// Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier.
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
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) {
|
||||
@@ -61,7 +67,8 @@ public class CommandService implements Commands {
|
||||
/** Fails when the store cannot be read: pending restores must never be dropped silently. */
|
||||
public CommandService(List<Setpoint> setpoints, SetpointRegisters registers, RestoreStore store,
|
||||
ReadingSink audit, Clock clock, LongSupplier nanoTime,
|
||||
Instant earliestPlausible, Duration minInterval, Pause pause) throws IOException {
|
||||
Instant earliestPlausible, Duration minInterval, Pause pause,
|
||||
Runnable pollSoon) throws IOException {
|
||||
setpoints.forEach(SafetyLimits::require);
|
||||
setpoints.forEach(setpoint -> allowlist.put(setpoint.name(), setpoint));
|
||||
this.registers = registers;
|
||||
@@ -72,6 +79,7 @@ public class CommandService implements Commands {
|
||||
this.earliestPlausible = earliestPlausible;
|
||||
this.minInterval = minInterval;
|
||||
this.pause = pause;
|
||||
this.pollSoon = pollSoon;
|
||||
store.load().forEach(restore -> restores.put(restore.register(), restore));
|
||||
List.copyOf(restores.values()).forEach(this::isValid);
|
||||
}
|
||||
@@ -104,12 +112,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 +133,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 +156,10 @@ 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);
|
||||
pollSoon.run();
|
||||
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 +170,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 +188,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 +223,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 +240,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 +278,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 +298,49 @@ 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);
|
||||
pollSoon.run();
|
||||
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 +348,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 +412,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);
|
||||
|
||||
@@ -5,13 +5,19 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
/** Polls every device on its own virtual thread, so a slow or broken device affects no other. */
|
||||
/**
|
||||
* Polls every device on its own virtual thread, so a slow or broken device affects no other.
|
||||
* {@link #requestPoll} adds one poll soon on that same thread, so it never overlaps another read
|
||||
* of the device, and leaves the regular schedule as it is.
|
||||
*/
|
||||
public class Poller implements AutoCloseable {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(Poller.class);
|
||||
@@ -20,16 +26,22 @@ public class Poller implements AutoCloseable {
|
||||
private final Snapshot snapshot;
|
||||
private final ReadingSink sink;
|
||||
private final Clock clock;
|
||||
private final Duration repollDelay;
|
||||
private final List<Thread> threads = new CopyOnWriteArrayList<>();
|
||||
private final Map<String, Thread> threadsByDevice = new ConcurrentHashMap<>();
|
||||
// Device name to the nanoTime of its extra poll; a request while one is waiting joins it.
|
||||
private final Map<String, Long> extraPolls = new ConcurrentHashMap<>();
|
||||
private final Set<String> devicesWithSinkFailure = ConcurrentHashMap.newKeySet();
|
||||
// A read may swallow the interrupt, so the loop cannot rely on the thread's flag alone.
|
||||
private volatile boolean closed;
|
||||
|
||||
public Poller(List<Device> devices, Snapshot snapshot, ReadingSink sink, Clock clock) {
|
||||
public Poller(List<Device> devices, Snapshot snapshot, ReadingSink sink, Clock clock,
|
||||
Duration repollDelay) {
|
||||
this.devices = devices;
|
||||
this.snapshot = snapshot;
|
||||
this.sink = sink;
|
||||
this.clock = clock;
|
||||
this.repollDelay = repollDelay;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -37,8 +49,18 @@ public class Poller implements AutoCloseable {
|
||||
snapshot.register(device.name(), device.interval(), clock.instant());
|
||||
}
|
||||
for (Device device : devices) {
|
||||
threads.add(Thread.ofVirtual().name("poll-" + device.name())
|
||||
.start(() -> pollForever(device)));
|
||||
var thread = Thread.ofVirtual().name("poll-" + device.name()).start(() -> pollForever(device));
|
||||
threads.add(thread);
|
||||
threadsByDevice.put(device.name(), thread);
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls the device once more after the repoll delay, e.g. to show the effect of a write. */
|
||||
public void requestPoll(String deviceName) {
|
||||
var thread = threadsByDevice.get(deviceName);
|
||||
if (thread != null) {
|
||||
extraPolls.putIfAbsent(deviceName, System.nanoTime() + repollDelay.toNanos());
|
||||
LockSupport.unpark(thread);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,17 +85,26 @@ public class Poller implements AutoCloseable {
|
||||
// fire several back to back and hammer a device that just stalled.
|
||||
next = System.nanoTime();
|
||||
} else {
|
||||
parkUntil(next);
|
||||
up = parkUntil(device, next, up);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parkNanos may return early, and an early return would shorten the interval.
|
||||
private void parkUntil(long deadline) {
|
||||
private boolean parkUntil(Device device, long deadline, boolean up) {
|
||||
long wait;
|
||||
while (!closed && (wait = deadline - System.nanoTime()) > 0) {
|
||||
LockSupport.parkNanos(wait);
|
||||
var extra = extraPolls.get(device.name());
|
||||
if (extra == null) {
|
||||
LockSupport.parkNanos(wait);
|
||||
} else if (extra - System.nanoTime() > 0) {
|
||||
LockSupport.parkNanos(Math.min(wait, extra - System.nanoTime()));
|
||||
} else {
|
||||
extraPolls.remove(device.name());
|
||||
up = pollOnce(device, up);
|
||||
}
|
||||
}
|
||||
return up;
|
||||
}
|
||||
|
||||
private boolean pollOnce(Device device, boolean wasUp) {
|
||||
|
||||
@@ -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,16 +8,24 @@ import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/** Local Modbus TCP fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. */
|
||||
/**
|
||||
* Local Modbus fake answering FC3, FC4 and FC6; unknown addresses get exception code 2. It speaks
|
||||
* Modbus TCP, or RTU frames over TCP when made with {@link #rtuOverTcp()}.
|
||||
*/
|
||||
public final class MockModbusServer implements Closeable {
|
||||
|
||||
private record Request(int transactionId, int unitId, byte[] pdu) {
|
||||
}
|
||||
|
||||
private final ServerSocket server;
|
||||
private final boolean rtu;
|
||||
private final Map<Integer, Integer> holding = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, Integer> input = new ConcurrentHashMap<>();
|
||||
private final AtomicInteger connections = new AtomicInteger();
|
||||
@@ -26,15 +34,26 @@ public final class MockModbusServer implements Closeable {
|
||||
private volatile Duration responseDelay = Duration.ZERO;
|
||||
private volatile boolean dropNextRequest;
|
||||
private volatile boolean wrongTransactionId;
|
||||
private volatile boolean wrongCrc;
|
||||
private volatile boolean wrongUnit;
|
||||
private volatile boolean overlapped;
|
||||
private volatile boolean wrongWriteEcho;
|
||||
private volatile int writeException;
|
||||
|
||||
public MockModbusServer() throws IOException {
|
||||
this(false);
|
||||
}
|
||||
|
||||
private MockModbusServer(boolean rtu) throws IOException {
|
||||
this.rtu = rtu;
|
||||
server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
|
||||
Thread.ofVirtual().start(this::acceptLoop);
|
||||
}
|
||||
|
||||
static MockModbusServer rtuOverTcp() throws IOException {
|
||||
return new MockModbusServer(true);
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getLocalPort();
|
||||
}
|
||||
@@ -90,6 +109,16 @@ public final class MockModbusServer implements Closeable {
|
||||
wrongTransactionId = true;
|
||||
}
|
||||
|
||||
/** RTU only: answers the next request with a CRC that does not match. */
|
||||
void answerWithWrongCrc() {
|
||||
wrongCrc = true;
|
||||
}
|
||||
|
||||
/** Answers the next request as if another unit had sent it. */
|
||||
void answerFromAnotherUnit() {
|
||||
wrongUnit = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
server.close();
|
||||
@@ -112,15 +141,13 @@ public final class MockModbusServer implements Closeable {
|
||||
try (client) {
|
||||
DataInputStream in = new DataInputStream(client.getInputStream());
|
||||
while (true) {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
|
||||
if (header.remaining() < 7) {
|
||||
Request request = rtu ? readRtu(in) : readMbap(in);
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
int transactionId = header.getShort() & 0xFFFF;
|
||||
header.getShort();
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
int transactionId = request.transactionId();
|
||||
int unitId = request.unitId();
|
||||
byte[] pdu = request.pdu();
|
||||
if (dropNextRequest) {
|
||||
dropNextRequest = false;
|
||||
return;
|
||||
@@ -131,7 +158,12 @@ public final class MockModbusServer implements Closeable {
|
||||
wrongTransactionId = false;
|
||||
transactionId++;
|
||||
}
|
||||
client.getOutputStream().write(frame(transactionId, unitId, answer(unitId, pdu)));
|
||||
byte[] answer = answer(unitId, pdu);
|
||||
if (wrongUnit) {
|
||||
wrongUnit = false;
|
||||
unitId++;
|
||||
}
|
||||
client.getOutputStream().write(rtu ? rtuFrame(unitId, answer) : frame(transactionId, unitId, answer));
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// the client went away, which is a normal end of a test
|
||||
@@ -140,6 +172,43 @@ public final class MockModbusServer implements Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
private static Request readMbap(DataInputStream in) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(7));
|
||||
if (header.remaining() < 7) {
|
||||
return null;
|
||||
}
|
||||
int transactionId = header.getShort() & 0xFFFF;
|
||||
header.getShort();
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
return new Request(transactionId, unitId, in.readNBytes(length - 1));
|
||||
}
|
||||
|
||||
/** Every read request (FC3, FC4) is unit, function, four bytes and the CRC. */
|
||||
private static Request readRtu(DataInputStream in) throws IOException {
|
||||
byte[] frame = in.readNBytes(8);
|
||||
if (frame.length < 8) {
|
||||
return null;
|
||||
}
|
||||
int crc = RtuFraming.crc(frame, 6);
|
||||
if ((frame[6] & 0xFF) != (crc & 0xFF) || (frame[7] & 0xFF) != crc >>> 8) {
|
||||
throw new IOException("request with a wrong CRC");
|
||||
}
|
||||
return new Request(0, frame[0] & 0xFF, Arrays.copyOfRange(frame, 1, 6));
|
||||
}
|
||||
|
||||
private byte[] rtuFrame(int unitId, byte[] pdu) {
|
||||
byte[] frame = ByteBuffer.allocate(1 + pdu.length + 2).put((byte) unitId).put(pdu).array();
|
||||
int crc = RtuFraming.crc(frame, 1 + pdu.length);
|
||||
if (wrongCrc) {
|
||||
wrongCrc = false;
|
||||
crc ^= 1;
|
||||
}
|
||||
frame[frame.length - 2] = (byte) crc;
|
||||
frame[frame.length - 1] = (byte) (crc >>> 8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private void pause() {
|
||||
try {
|
||||
Thread.sleep(responseDelay);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The house meter's RS485 converter: RTU frames with their CRC over TCP, no MBAP header. */
|
||||
class RtuOverTcpTransportTest {
|
||||
|
||||
private static final byte[] READ_INPUT_0 = {4, 0, 0, 0, 2};
|
||||
|
||||
private MockModbusServer server;
|
||||
private TcpTransport transport;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = MockModbusServer.rtuOverTcp();
|
||||
server.input(0, 0x436B);
|
||||
server.input(1, 0xA8F6);
|
||||
transport = TcpTransport.rtuOverTcp(new TcpTransport.Settings(
|
||||
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() throws IOException {
|
||||
transport.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void framesTheRequestWithTheModbusCrc() {
|
||||
// The specification's example: read 10 holding registers from unit 1 ends in C5 CD.
|
||||
assertArrayEquals(new byte[] {1, 3, 0, 0, 0, 10, (byte) 0xC5, (byte) 0xCD},
|
||||
new RtuFraming().frame(1, new byte[] {3, 0, 0, 0, 10}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsInputRegisters() throws IOException {
|
||||
int[] words = new PduModbusClient(transport).readInputRegisters(1, 0, 2);
|
||||
|
||||
assertEquals(235.66f, Float.intBitsToFloat(words[0] << 16 | words[1]), 0.005f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsExceptionPduAsIs() throws IOException {
|
||||
assertArrayEquals(new byte[] {(byte) 0x84, 2}, transport.exchange(1, new byte[] {4, 0, 99, 0, 1}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAWrongCrcAndReconnectsAfterwards() throws IOException {
|
||||
server.answerWithWrongCrc();
|
||||
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
|
||||
|
||||
assertEquals(6, transport.exchange(1, READ_INPUT_0).length);
|
||||
assertEquals(2, server.connections());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnAnswerFromAnotherUnit() {
|
||||
server.answerFromAnotherUnit();
|
||||
|
||||
assertThrows(IOException.class, () -> transport.exchange(1, READ_INPUT_0));
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class TcpTransportTest {
|
||||
transport.close();
|
||||
transport = new TcpTransport(new TcpTransport.Settings(
|
||||
"localhost", server.port(), Duration.ofSeconds(2), Duration.ofSeconds(2), Duration.ZERO),
|
||||
Duration.ofMillis(100));
|
||||
new MbapFraming(), Duration.ofMillis(100));
|
||||
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
transport.exchange(1, READ_HOLDING_10);
|
||||
|
||||
@@ -21,7 +21,7 @@ class JsonRestoreStoreTest {
|
||||
Path dir;
|
||||
|
||||
private final Restore restore = new Restore("cwu_comfort", 4144, 450, 500,
|
||||
Instant.parse("2026-10-24T11:00:00Z"));
|
||||
Instant.parse("2026-10-24T11:00:00Z"), Instant.parse("2026-10-24T10:15:00Z"));
|
||||
|
||||
@Test
|
||||
void missingFileIsEmpty() throws IOException {
|
||||
@@ -70,8 +70,7 @@ class JsonRestoreStoreTest {
|
||||
var cases = List.of("{not json", "", "garbage \u0000\u0001", "null", "{}", "[null]",
|
||||
"[{\"setpoint\":\"x\"}]", restoreJson("4144", "\"5\""), restoreJson("null", "5"),
|
||||
restoreJson("4144", "5").replace("}]", ",\"extra\":1}]"),
|
||||
restoreJson("4144", "5").replace("\"x\"", "null"),
|
||||
restoreJson("4144", "5").replace("\"2026-10-24T11:00:00Z\"", "null"));
|
||||
restoreJson("4144", "5").replace("\"x\"", "null"));
|
||||
for (var content : cases) {
|
||||
var file = dir.resolve("restores.json");
|
||||
Files.writeString(file, content);
|
||||
@@ -90,7 +89,8 @@ class JsonRestoreStoreTest {
|
||||
@Test
|
||||
void aLostReplyValueSurvivesSaveAndLoad() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z")).alsoApplied(47);
|
||||
var restore = new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T11:00:00Z"),
|
||||
Instant.parse("2026-10-24T10:15:00Z")).alsoApplied(47);
|
||||
|
||||
new JsonRestoreStore(file).save(List.of(restore));
|
||||
|
||||
@@ -105,6 +105,28 @@ class JsonRestoreStoreTest {
|
||||
assertEquals(1, new JsonRestoreStore(file).load().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFileFromBeforeLeasesLoadsWithALapsedLease() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
Files.writeString(file, restoreJson("4144", "450"));
|
||||
|
||||
var loaded = new JsonRestoreStore(file).load().getFirst();
|
||||
|
||||
assertEquals(Instant.EPOCH, loaded.leaseUntil());
|
||||
assertEquals(Instant.parse("2026-10-24T11:00:00Z"), loaded.due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDueInTheFile() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
var untilChanged = new Restore("cwu_comfort", 4144, 450, 100, null, Instant.parse("2026-10-24T10:15:00Z"));
|
||||
|
||||
new JsonRestoreStore(file).save(List.of(untilChanged));
|
||||
|
||||
assertFalse(Files.readString(file).contains("due"));
|
||||
assertEquals(List.of(untilChanged), new JsonRestoreStore(file).load());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterTheRenameTheNextLoadIsEmpty() throws IOException {
|
||||
var file = dir.resolve("restores.json");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +39,7 @@ class CommandServiceTest {
|
||||
private boolean storeFails;
|
||||
private boolean pauseInterrupted;
|
||||
private int pauses;
|
||||
private int repolls;
|
||||
private CountDownLatch inPause = new CountDownLatch(1);
|
||||
private CountDownLatch pauseGate = new CountDownLatch(0);
|
||||
|
||||
@@ -67,7 +70,7 @@ class CommandServiceTest {
|
||||
if (pauseInterrupted) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
});
|
||||
}, () -> repolls++);
|
||||
}
|
||||
|
||||
private static Command command(String setpoint, int value, Duration duration) {
|
||||
@@ -194,7 +197,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 +209,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
|
||||
@@ -261,7 +266,7 @@ class CommandServiceTest {
|
||||
};
|
||||
|
||||
assertThrows(IOException.class, () -> new CommandService(List.of(CWU), registers, broken,
|
||||
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }));
|
||||
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -279,7 +284,7 @@ class CommandServiceTest {
|
||||
@Test
|
||||
void doesNothingBeforeDue() throws IOException {
|
||||
var service = boosted();
|
||||
clock.advance(Duration.ofMinutes(59));
|
||||
advanceRenewing(service, Duration.ofMinutes(59));
|
||||
|
||||
service.runDueRestores();
|
||||
|
||||
@@ -303,6 +308,49 @@ class CommandServiceTest {
|
||||
assertEquals(45.0, audit.getLast().fields().get("new_value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAppliedCommandAsksForAnExtraPoll() throws IOException {
|
||||
service().apply(command("cwu_comfort", 50, null));
|
||||
|
||||
assertEquals(1, repolls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRejectedOrFailedCommandAsksForNoPoll() throws IOException {
|
||||
var service = service();
|
||||
service.apply(command("cwu_comfort", 51, null));
|
||||
registers.failWrites = true;
|
||||
service.apply(command("cwu_comfort", 50, null));
|
||||
|
||||
assertEquals(0, repolls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRestoreAsksForAnExtraPollOnlyWhenItWorked() throws IOException {
|
||||
var service = boosted();
|
||||
repolls = 0;
|
||||
clock.advance(HOUR);
|
||||
registers.failWrites = true;
|
||||
service.runDueRestores();
|
||||
assertEquals(0, repolls);
|
||||
|
||||
registers.failWrites = false;
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
service.runDueRestores();
|
||||
assertEquals(1, repolls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreNowAsksForAnExtraPoll() throws IOException {
|
||||
var service = boosted();
|
||||
repolls = 0;
|
||||
clock.advance(Duration.ofMinutes(2));
|
||||
|
||||
service.restoreNow("cwu_comfort", "app");
|
||||
|
||||
assertEquals(1, repolls);
|
||||
}
|
||||
|
||||
@Test
|
||||
void leavesAChangedValueAndAuditsAConflict() throws IOException {
|
||||
var service = boosted();
|
||||
@@ -382,7 +430,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 +458,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 +510,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 +574,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);
|
||||
|
||||
@@ -563,7 +612,7 @@ class CommandServiceTest {
|
||||
var typo = new Setpoint("cwu_comfort", 4144, 30, 500, 1);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> new CommandService(List.of(typo), registers, store,
|
||||
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }));
|
||||
audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { }, () -> { }));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -614,6 +663,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 +852,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;
|
||||
|
||||
@@ -4,6 +4,7 @@ import home.gateway.domain.Reading;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -35,7 +36,7 @@ class PollerTest {
|
||||
}
|
||||
|
||||
private void startPolling(Device... devices) {
|
||||
poller = new Poller(List.of(devices), snapshot, sink, clock);
|
||||
poller = new Poller(List.of(devices), snapshot, sink, clock, Duration.ofMillis(50));
|
||||
poller.start();
|
||||
}
|
||||
|
||||
@@ -94,6 +95,50 @@ class PollerTest {
|
||||
assertNotNull(state("pump").lastSuccess());
|
||||
}
|
||||
|
||||
private static final Duration HOUR = Duration.ofHours(1);
|
||||
|
||||
@Test
|
||||
void anExtraPollRunsAfterTheDelayOutsideTheSchedule() throws Exception {
|
||||
var device = FakeDevice.working("pump", HOUR);
|
||||
startPolling(device);
|
||||
await(() -> device.calls() == 1);
|
||||
|
||||
poller.requestPoll("pump");
|
||||
|
||||
await(() -> device.calls() == 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestsWithinTheDelayCoalesceIntoOnePoll() throws Exception {
|
||||
var device = FakeDevice.working("pump", HOUR);
|
||||
startPolling(device);
|
||||
await(() -> device.calls() == 1);
|
||||
|
||||
poller.requestPoll("pump");
|
||||
poller.requestPoll("pump");
|
||||
poller.requestPoll("pump");
|
||||
|
||||
await(() -> device.calls() == 2);
|
||||
Thread.sleep(300);
|
||||
assertEquals(2, device.calls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailingExtraPollIsRecordedAndTheNextRegularPollRecovers() throws Exception {
|
||||
var device = new FakeDevice("pump", Duration.ofMillis(400), call -> {
|
||||
if (call == 2) {
|
||||
throw new IOException("unreachable");
|
||||
}
|
||||
});
|
||||
startPolling(device);
|
||||
await(() -> device.calls() == 1);
|
||||
|
||||
poller.requestPoll("pump");
|
||||
|
||||
await(() -> state("pump").lastError() != null);
|
||||
await(() -> device.calls() == 3 && state("pump").lastError() == null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failingOrSlowDeviceDoesNotAffectOthers() throws Exception {
|
||||
var slow = new FakeDevice("slow", FAST, call -> Thread.sleep(60_000));
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user