Merge PR #84: feat: change history in Ustawienia
CI / Check (gateway, backend, frontend) (push) Successful in 1m23s

This commit is contained in:
root committed 2026-10-24 17:03:31 +02:00
commit e1e50eb7c2
23 files changed
+741 -42

No files matched your search

+6
View File
@@ -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"));
}
}
+12
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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).
+69
View File
@@ -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();
});
});
+59
View File
@@ -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>
);
}
+2
View File
@@ -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 />
</>
);
}
+15
View File
@@ -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;