Merge PR #84: feat: change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s
This commit is contained in:
commit
e1e50eb7c2
23 files changed
+741
-42
No files matched your search
@@ -22,6 +22,12 @@ Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
|
||||
@@ -4,8 +4,10 @@ import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
@@ -33,7 +35,8 @@ 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 influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||
var influx = new InfluxHistoryRepository(influxSettings);
|
||||
var commands = commands(settings);
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
@@ -42,7 +45,8 @@ final class Backend {
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock));
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock));
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
@@ -87,7 +88,7 @@ public class ApiServer {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery) throws IOException {
|
||||
BatteryService battery, AuditService audit) 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).
|
||||
@@ -107,6 +108,7 @@ public class ApiServer {
|
||||
api("/api/pi-sensor", query -> piSensor.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
@@ -337,6 +339,19 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, audit.audit(query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("audit unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
|
||||
public final class InfluxAuditRepository implements AuditRepository {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxAuditRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
return rows("electricity", "deye_settings_audit", "register", from, to);
|
||||
}
|
||||
|
||||
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
|
||||
throws IOException {
|
||||
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
|
||||
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
|
||||
var rows = new ArrayList<Row>();
|
||||
for (JsonNode result : results(influx.query(database, query))) {
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode values : series.path("values")) {
|
||||
rows.add(row(series.path("columns"), values, targetTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static JsonNode results(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
}
|
||||
return root.path("results");
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
|
||||
Map<String, JsonNode> byName = new HashMap<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
byName.put(columns.get(i).asText(), values.get(i));
|
||||
}
|
||||
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
|
||||
text(byName.get("requested_by")), text(byName.get("status")),
|
||||
number(byName.get("old_value")), number(byName.get("new_value")));
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText();
|
||||
}
|
||||
|
||||
private static Double number(JsonNode node) {
|
||||
return node == null || !node.isNumber() ? null : node.asDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
|
||||
final class InfluxClient {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
InfluxClient(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
String query(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,6 @@ import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -22,12 +16,10 @@ import java.util.concurrent.Executors;
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -59,37 +51,11 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
|
||||
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
return InfluxResponse.parse(fetch(source.database(), query));
|
||||
return InfluxResponse.parse(influx.query(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's audit rows in InfluxDB (S3), read only. */
|
||||
public interface AuditRepository {
|
||||
|
||||
/** One audited write as recorded; {@code target} is the setpoint name or the Deye register. */
|
||||
record Row(Instant time, String target, String requestedBy, String status, Double oldValue, Double newValue) {
|
||||
}
|
||||
|
||||
List<Row> heatPump(Instant from, Instant to) throws IOException;
|
||||
|
||||
List<Row> battery(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import home.backend.domain.AuditView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** S3: who changed what and when, from the gateway's audit rows of the heat pump and of the battery. */
|
||||
public class AuditService {
|
||||
|
||||
private static final String DEFAULT_RANGE = "7d";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "CWU eko", "cwu_comfort", "CWU komfort",
|
||||
"co_economy", "CO eko", "co_comfort", "CO komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
private final AuditRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public AuditService(AuditRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h}, {@code 7d} or {@code 30d}; null means 7d
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public AuditView audit(String range) throws IOException {
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
Instant from = to.minus(length);
|
||||
var heatPump = repository.heatPump(from, to).stream().map(AuditService::heatPumpEntry);
|
||||
var battery = repository.battery(from, to).stream().map(AuditService::batteryEntry);
|
||||
return new AuditView(Stream.concat(heatPump, battery)
|
||||
.sorted(Comparator.comparing(AuditEntry::time).reversed())
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static AuditEntry heatPumpEntry(Row row) {
|
||||
return entry(AuditEntry.HEAT_PUMP, row, SETPOINTS.getOrDefault(row.target(), row.target()), "°C");
|
||||
}
|
||||
|
||||
private static AuditEntry batteryEntry(Row row) {
|
||||
var label = DeyeSettingLabels.of(row.target());
|
||||
return entry(AuditEntry.BATTERY, row, label.what(), label.unit());
|
||||
}
|
||||
|
||||
private static AuditEntry entry(String source, Row row, String what, String unit) {
|
||||
return new AuditEntry(row.time(), source, what, whole(row.oldValue()), whole(row.newValue()), unit,
|
||||
RESTORE_USER.equals(row.requestedBy()) ? RESTORE_LABEL : row.requestedBy(), row.status());
|
||||
}
|
||||
|
||||
private static Integer whole(Double value) {
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||
final class DeyeSettingLabels {
|
||||
|
||||
record Label(String what, String unit) {
|
||||
}
|
||||
|
||||
private static final int TOU_ENABLE = 146;
|
||||
private static final int FIRST_START = 148;
|
||||
private static final int FIRST_SOC = 166;
|
||||
private static final int FIRST_GRID_CHARGE = 172;
|
||||
private static final int SLOTS = 6;
|
||||
|
||||
private DeyeSettingLabels() {
|
||||
}
|
||||
|
||||
static Label of(String register) {
|
||||
if ("reserve".equals(register)) {
|
||||
return new Label("Rezerwa baterii", "%");
|
||||
}
|
||||
int address;
|
||||
try {
|
||||
address = Integer.parseInt(register);
|
||||
} catch (NumberFormatException e) {
|
||||
return unknown(register);
|
||||
}
|
||||
if (address == TOU_ENABLE) {
|
||||
return new Label("Harmonogram baterii", "0/1");
|
||||
}
|
||||
return slot(address, FIRST_START, "start", "HHMM")
|
||||
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||
.orElseGet(() -> unknown(register));
|
||||
}
|
||||
|
||||
private static Label unknown(String register) {
|
||||
return new Label("Rejestr " + register, "");
|
||||
}
|
||||
|
||||
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||
return address >= first && address < first + SLOTS
|
||||
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
|
||||
* the values are null when the gateway did not record them.
|
||||
*/
|
||||
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
|
||||
String unit, String who, String status) {
|
||||
|
||||
public static final String HEAT_PUMP = "heat_pump";
|
||||
public static final String BATTERY = "battery";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The audited writes of the heat pump and the battery, newest first. */
|
||||
public record AuditView(List<AuditEntry> entries) {
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
@@ -63,6 +65,7 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private boolean auditDown;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
@@ -178,7 +181,8 @@ class ApiServerTest {
|
||||
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),
|
||||
new BatteryService(gateway, batteryCommands, clock));
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -187,6 +191,27 @@ class ApiServerTest {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private AuditRepository auditRows() {
|
||||
return new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
if (auditDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) {
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
|
||||
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
@@ -426,6 +451,24 @@ class ApiServerTest {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/audit?range=7d", "audit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditRangeDefaultsAndIsChecked() throws Exception {
|
||||
assertEquals(200, get("/api/audit").statusCode());
|
||||
assertEquals(400, get("/api/audit?range=90d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditInfluxFailureIs502() throws Exception {
|
||||
auditDown = true;
|
||||
|
||||
assertEquals(502, get("/api/audit?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxAuditRepositoryTest {
|
||||
|
||||
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
|
||||
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
|
||||
|
||||
private HttpServer server;
|
||||
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
|
||||
private String body;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
|
||||
body = fixture("influx-audit-setpoints.json");
|
||||
|
||||
List<Row> rows = repository().heatPump(FROM, TO);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
rows.get(0));
|
||||
assertNull(rows.get(1).oldValue());
|
||||
assertEquals("co_comfort", rows.get(1).target());
|
||||
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
|
||||
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
|
||||
queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBatteryRowsByRegisterTag() throws IOException {
|
||||
body = fixture("influx-audit-battery.json");
|
||||
|
||||
List<Row> rows = repository().battery(FROM, TO);
|
||||
|
||||
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
|
||||
assertEquals(60.0, rows.getFirst().newValue());
|
||||
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
|
||||
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRowsIsAnEmptyList() throws IOException {
|
||||
body = fixture("influx-query-empty.json");
|
||||
|
||||
assertTrue(repository().battery(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInfluxErrorIsAnIOException() throws IOException {
|
||||
body = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
|
||||
}
|
||||
|
||||
private InfluxAuditRepository repository() {
|
||||
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private List<Row> heatPump = List.of();
|
||||
private List<Row> battery = List.of();
|
||||
private final List<Instant> asked = new ArrayList<>();
|
||||
|
||||
private final AuditRepository repository = new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) {
|
||||
asked.add(from);
|
||||
return heatPump;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
if (battery == null) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return battery;
|
||||
}
|
||||
};
|
||||
private final AuditService service = new AuditService(repository, CLOCK);
|
||||
|
||||
private static Row row(String time, String target, String by, Double old, Double value) {
|
||||
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesBothSourcesNewestFirst() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
|
||||
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
|
||||
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("CWU eko", "Rezerwa baterii", "CO komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheDeyeRegisters() throws IOException {
|
||||
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
|
||||
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
|
||||
|
||||
var entries = service.audit("24h").entries();
|
||||
|
||||
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
|
||||
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
|
||||
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
|
||||
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
|
||||
|
||||
var entry = service.audit("7d").entries().getFirst();
|
||||
|
||||
assertEquals("automatycznie (przywrócenie)", entry.who());
|
||||
assertNull(entry.oldValue());
|
||||
assertEquals(8, entry.newValue());
|
||||
assertEquals("°C", entry.unit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
|
||||
service.audit(null);
|
||||
service.audit("30d");
|
||||
|
||||
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenOneSourceFails() {
|
||||
battery = null;
|
||||
|
||||
assertThrows(IOException.class, () -> service.audit("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "CWU eko", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "CO komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"deye_settings_audit","columns":["time","new_value","old_value","register","requested_by","status"],"values":[[1761298200000,60,50,"reserve","mariusz","APPLIED"],[1761242400000,1,0,"174","mariusz","FAILED"]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"setpoint_audit","columns":["time","new_value","old_value","register","requested_by","setpoint","status"],"values":[[1761292800000,8,45,4143,"restore","cwu_economy","APPLIED"],[1761202800000,22,null,4146,"mariusz","co_comfort","REJECTED"]]}]}]}
|
||||
+1
-1
@@ -99,7 +99,7 @@ Safety, for every write:
|
||||
|
||||
- S1 Only allowlisted registers, each with min, max and step.
|
||||
- S2 Read back and verify after writing.
|
||||
- S3 Audit log: who, when, register, old and new value.
|
||||
- S3 Audit log: who, when, register, old and new value; shown in the app as the change history.
|
||||
- S4 Rate limit per register.
|
||||
- S5 Tests and development never write to a real device or the production database.
|
||||
- S6 The gateway accepts commands only from the backend: a shared token, and a firewall rule on
|
||||
|
||||
@@ -20,6 +20,8 @@ and gateway's versions in the "Wersje" card of the Stan screen.
|
||||
The Energia screen has three views, kept in the URL hash so Back works: `#energy` (Teraz), `#energy/battery` (Bateria, also
|
||||
opened by tapping the battery in the flow diagram) and `#energy/inverters` (Falowniki, the device details).
|
||||
|
||||
Ustawienia ends with the "Historia zmian" card (`ChangeHistory.tsx`): the last 7 days of `api/audit`, 20 rows and "Pokaż więcej".
|
||||
|
||||
In the Bateria view, `editable` from `api/battery` (the gateway takes battery changes, ADR-013) adds the reserve stepper and "Edytuj" for the
|
||||
schedule: the user confirms a summary, only changed fields are sent to `api/battery/schedule` and `api/battery/reserve`, and the UI
|
||||
checks the gateway's rules first (levels from the reserve to 100 %, starts increasing).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { Audit, AuditEntry } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const history = async () => (await import("./ChangeHistory")).ChangeHistory;
|
||||
|
||||
const entry = (over: Partial<AuditEntry>): AuditEntry => ({
|
||||
time: "2026-10-23T18:00:00Z", source: "battery", what: "Przedział 2: start", oldValue: 1800, newValue: 2130,
|
||||
unit: "HHMM", who: "mariusz", status: "APPLIED", ...over,
|
||||
});
|
||||
|
||||
describe("ChangeHistory", () => {
|
||||
it("lists the changes with what, old and new value, who and a pill for a failed one", async () => {
|
||||
serve({ "api/audit": example<Audit>("audit") });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
const rows = await screen.findAllByRole("listitem");
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(within(rows[0]).getByText("Rezerwa baterii")).toBeInTheDocument();
|
||||
expect(rows[0]).toHaveTextContent("50 % → 60 %");
|
||||
expect(rows[1]).toHaveTextContent("45 °C → 8 °C");
|
||||
expect(rows[1]).toHaveTextContent("automatycznie (przywrócenie)");
|
||||
expect(rows[2]).toHaveTextContent("nie → tak");
|
||||
expect(within(rows[2]).getByText("nieudana")).toBeInTheDocument();
|
||||
expect(rows[3]).toHaveTextContent("— → 22 °C");
|
||||
expect(within(rows[3]).getByText("odrzucona")).toBeInTheDocument();
|
||||
expect(within(rows[0]).queryByText(/nieudana|odrzucona|konflikt|nieważna/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a clock time as HH:MM", async () => {
|
||||
serve({ "api/audit": { entries: [entry({})] } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByRole("listitem")).toHaveTextContent("18:00 → 21:30");
|
||||
});
|
||||
|
||||
it("says when nothing changed", async () => {
|
||||
serve({ "api/audit": { entries: [] } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByText("Brak zmian w ostatnich 7 dniach")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 20 changes and the rest on request", async () => {
|
||||
const entries = Array.from({ length: 25 }, (_, i) => entry({ time: `2026-10-23T${String(i).padStart(2, "0")}:00:00Z` }));
|
||||
serve({ "api/audit": { entries } });
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findAllByRole("listitem")).toHaveLength(20);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Pokaż więcej (5)" }));
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(25);
|
||||
expect(screen.queryByRole("button", { name: /Pokaż więcej/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("says the history is unavailable when the backend does not answer", async () => {
|
||||
serve({});
|
||||
const ChangeHistory = await history();
|
||||
render(<ChangeHistory />);
|
||||
|
||||
expect(await screen.findByText("Historia chwilowo niedostępna.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { dateTime, degrees, number, percent } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Audit, AuditEntry } from "./types";
|
||||
import { Card, Pill } from "./ui";
|
||||
|
||||
const URL = "api/audit?range=7d";
|
||||
const PAGE = 20;
|
||||
const STATUS: Record<string, string> = {
|
||||
FAILED: "nieudana", REJECTED: "odrzucona", CONFLICT: "konflikt", INVALID: "nieważna",
|
||||
};
|
||||
|
||||
// 1830 is a clock time, 0/1 is off/on.
|
||||
const SHOWN: Record<string, (value: number) => string> = {
|
||||
"°C": degrees,
|
||||
"%": percent,
|
||||
HHMM: (value) => `${String(Math.floor(value / 100)).padStart(2, "0")}:${String(value % 100).padStart(2, "0")}`,
|
||||
"0/1": (value) => (value ? "tak" : "nie"),
|
||||
};
|
||||
const shown = (value: number | null, unit: string) => (value == null ? "—" : (SHOWN[unit] ?? number)(value));
|
||||
|
||||
function Row({ entry }: { entry: AuditEntry }) {
|
||||
const status = entry.status === "APPLIED" ? null : (STATUS[entry.status] ?? entry.status);
|
||||
return (
|
||||
<li className="border-t border-edge py-3 first:border-t-0 first:pt-0">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<span className="font-semibold">{entry.what}</span>
|
||||
<span className="text-sm text-soft">{dateTime(entry.time)}</span>
|
||||
</div>
|
||||
<p className="my-1">{shown(entry.oldValue, entry.unit)} → <b>{shown(entry.newValue, entry.unit)}</b></p>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-soft">
|
||||
{entry.who}
|
||||
{status && <Pill tone="off">{status}</Pill>}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeHistory() {
|
||||
const { data, offline } = usePoll<Audit>(URL, 60000);
|
||||
const [all, setAll] = useState(false);
|
||||
const entries = data?.entries ?? [];
|
||||
return (
|
||||
<Card title="Historia zmian" aside={<span className="text-sm text-soft">ostatnie 7 dni</span>} className="mt-4">
|
||||
{!data && <p className="my-1 text-soft">{offline ? "Historia chwilowo niedostępna." : "Wczytuję…"}</p>}
|
||||
{data && entries.length === 0 && <p className="my-1 text-soft">Brak zmian w ostatnich 7 dniach</p>}
|
||||
<ul className="m-0 list-none p-0">
|
||||
{(all ? entries : entries.slice(0, PAGE)).map((entry) => (
|
||||
<Row key={`${entry.source}${entry.time}${entry.what}`} entry={entry} />
|
||||
))}
|
||||
</ul>
|
||||
{!all && entries.length > PAGE && (
|
||||
<button type="button" onClick={() => setAll(true)} className="mt-3 min-h-12 w-full rounded-xl border-2 border-edge font-semibold">
|
||||
Pokaż więcej ({entries.length - PAGE})
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { ChangeHistory } from "./ChangeHistory";
|
||||
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Preset, RestoreOutcome, RestoreResult, Setpoint, Setpoints } from "./types";
|
||||
@@ -330,6 +331,7 @@ export function Settings() {
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
<ChangeHistory />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,21 @@ export interface History {
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
time: string;
|
||||
source: "heat_pump" | "battery";
|
||||
what: string;
|
||||
oldValue: number | null;
|
||||
newValue: number | null;
|
||||
unit: string; // "°C", "%", "HHMM" (1830) or "0/1"
|
||||
who: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Audit {
|
||||
entries: AuditEntry[];
|
||||
}
|
||||
|
||||
export interface Health {
|
||||
time: string;
|
||||
gatewayReachable: boolean;
|
||||
|
||||
Reference in new issue
Block a user