Merge PR #32: feat(backend): serve the JSON API and the static web UI

This commit is contained in:
root committed 2026-10-24 00:39:22 +02:00
commit ae5f2536c8
17 files changed
+909

No files matched your search

@@ -0,0 +1,123 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
public class ApiServer {
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final HttpServer server;
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
HistoryService history) throws IOException {
server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/", new StaticFiles());
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/health", query -> health.current());
server.createContext("/api/history", exchange -> handleHistory(exchange, history));
}
public void start() {
server.start();
}
public void stop() {
server.stop(0);
}
public int port() {
return server.getAddress().getPort();
}
private interface Gateway {
Object get(Map<String, String> query) throws IOException;
}
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
private void api(String path, Gateway gateway) {
server.createContext(path, exchange -> respond(exchange, () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error("gateway unreachable"));
}
}));
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
respond(exchange, () -> {
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
try {
send(exchange, 200, history.history(query.get("series"), query.get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("history unavailable"));
}
});
}
private interface Action {
void run() throws IOException;
}
private static void respond(HttpExchange exchange, Action action) throws IOException {
try (exchange) {
if (!exchange.getRequestMethod().equals("GET")) {
exchange.sendResponseHeaders(405, -1);
return;
}
action.run();
}
}
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
byte[] bytes = JSON.writeValueAsBytes(body);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
private static Map<String, String> error(String message) {
return Map.of("error", message);
}
private static Map<String, String> parseQuery(String raw) {
Map<String, String> query = new HashMap<>();
if (raw == null) {
return query;
}
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
}
return query;
}
private static String decode(String s) {
return URLDecoder.decode(s, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,63 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Map;
/** Serves the web UI from the classpath folder {@code web/}; only files of known types. */
final class StaticFiles implements HttpHandler {
private static final Map<String, String> CONTENT_TYPES = Map.of(
"html", "text/html; charset=utf-8",
"css", "text/css; charset=utf-8",
"js", "text/javascript; charset=utf-8",
"svg", "image/svg+xml",
"png", "image/png",
"ico", "image/x-icon");
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
if (!exchange.getRequestMethod().equals("GET")) {
exchange.sendResponseHeaders(405, -1);
return;
}
String path = exchange.getRequestURI().getPath();
String resource = path.equals("/") ? "index.html" : path.substring(1);
String type = contentType(resource);
try (InputStream in = type == null || !isSafe(resource) ? null : open(resource)) {
if (in == null) {
exchange.sendResponseHeaders(404, -1);
return;
}
byte[] body = in.readAllBytes();
exchange.getResponseHeaders().set("Content-Type", type);
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
}
}
}
private static InputStream open(String resource) {
return StaticFiles.class.getClassLoader().getResourceAsStream("web/" + resource);
}
/** The path is already URL-decoded, so this also catches %2e%2e. */
private static boolean isSafe(String resource) {
return !resource.contains("\\") && !resource.contains("\0") && !resource.contains("//")
&& Arrays.stream(resource.split("/")).noneMatch(s -> s.equals("..") || s.equals("."));
}
private static String contentType(String resource) {
int dot = resource.lastIndexOf('.');
return dot < 0 ? null : CONTENT_TYPES.get(resource.substring(dot + 1));
}
}
@@ -0,0 +1,56 @@
package home.backend.application;
import home.backend.domain.EnergyView;
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.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 {
private final GatewayClient gateway;
private final Clock clock;
public EnergyService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Optional<Measurement> deye = SnapshotFields.find(snapshot, "deye", "deye");
Optional<Measurement> fronius = SnapshotFields.find(snapshot, "fronius", "fronius");
Map<String, Double> d = deye.map(Measurement::fields).orElse(Map.of());
Map<String, Double> f = fronius.map(Measurement::fields).orElse(Map.of());
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
return new EnergyView(time(deye, fronius),
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
d.get("load_power_w"), today);
}
private Instant time(Optional<Measurement> deye, Optional<Measurement> fronius) {
return Stream.of(deye, fronius).flatMap(Optional::stream).map(Measurement::time)
.max(Instant::compareTo).orElseGet(clock::instant);
}
/** Sum of the values that exist; null when none does. */
private static Double sum(Double... values) {
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
}
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
private static Double round(Double kwh) {
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
}
}
@@ -0,0 +1,35 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
import java.io.IOException;
import java.time.Clock;
import java.util.List;
/** F5: whether the gateway answers and which device links are up. */
public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
public HealthService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HealthView current() {
try {
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
.map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices);
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of());
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -0,0 +1,57 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.Pumps;
import home.backend.domain.HeatPumpView.Setpoints;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HeatPumpView.Targets;
import home.backend.domain.HeatPumpView.Temperatures;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
public class HeatPumpService {
private static final String DATABASE = "Dom_Pompa";
private static final State[] STATES = State.values();
private final GatewayClient gateway;
private final Clock clock;
public HeatPumpService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HeatPumpView current() throws IOException {
return SnapshotFields.find(gateway.snapshot(), "heat-pump", "heat_pump")
.filter(m -> DATABASE.equals(m.database()))
.map(m -> view(m.time(), m.fields()))
.orElseGet(() -> view(clock.instant(), Map.of()));
}
private static HeatPumpView view(Instant time, Map<String, Double> f) {
return new HeatPumpView(time, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
on(f, "alarm"),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
new Temperatures(f.get("outdoor_temp"), f.get("indoor_temp"), f.get("co_buffer_temp"),
f.get("cwu_temp"), f.get("ground_in_temp"), f.get("ground_out_temp"),
f.get("pump_outlet_temp"), f.get("outdoor_avg_temp")),
new Targets(f.get("target_room_temp"), f.get("target_co_temp"), f.get("target_cwu_temp")),
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
}
private static State state(Map<String, Double> f) {
Integer mode = whole(f, "pump_mode");
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
}
}
@@ -0,0 +1,43 @@
package home.backend.application;
import home.backend.domain.HistoryView;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
/** F2: one series over the last 24 hours or 7 days. */
public class HistoryService {
static final int MAX_POINTS = 300;
private static final String DEFAULT_RANGE = "24h";
private static final Map<String, Duration> RANGES =
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
private final HistoryRepository repository;
private final Clock clock;
public HistoryService(HistoryRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 24h} or {@code 7d}; null means 24h
* @throws IllegalArgumentException for an unknown series or range
*/
public HistoryView history(String seriesId, String range) throws IOException {
Series series = Series.fromId(seriesId)
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant to = clock.instant();
return new HistoryView(series.id(), series.unit(),
repository.history(series, to.minus(length), to, MAX_POINTS));
}
}
@@ -0,0 +1,35 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
final class SnapshotFields {
private SnapshotFields() {
}
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
return snapshot.devices().stream()
.filter(d -> d.name().equals(device))
.map(DeviceSnapshot::measurements)
.flatMap(List::stream)
.filter(m -> m.measurement().equals(measurement))
.findFirst();
}
static Integer whole(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : (int) Math.round(value);
}
static boolean on(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value != null && value != 0;
}
}
@@ -0,0 +1,248 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.EnergyService;
import home.backend.application.GatewayClient;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HistoryService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ApiServerTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Instant HEALTH_TIME = Instant.parse("2026-10-23T22:14:00Z");
private final HttpClient client = HttpClient.newHttpClient();
private GatewaySnapshot snapshot;
private boolean gatewayDown;
private boolean historyDown;
private ApiServer server;
@BeforeEach
void start() throws IOException {
snapshot = exampleSnapshot();
GatewayClient gateway = () -> {
if (gatewayDown) {
throw new IOException("tunnel down");
}
return snapshot;
};
HistoryRepository history = (series, from, to, max) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(new Point(Instant.parse("2026-10-23T10:00:00Z"), 14.1),
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
};
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));
server.start();
}
@AfterEach
void stop() {
server.stop();
}
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,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"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(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, pumpTime))),
new DeviceSnapshot("deye", false, null, "connect timed out",
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)))));
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
}
}
private void assertMatchesExample(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode());
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
assertEquals(example(example), JSON.readTree(response.body()));
}
@Test
void heatPumpMatchesExample() throws Exception {
assertMatchesExample("/api/heat-pump", "heat-pump");
}
@Test
void energyMatchesExample() throws Exception {
assertMatchesExample("/api/energy", "energy");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
assertMatchesExample("/api/health", "health");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
for (String path : List.of("/api/heat-pump", "/api/energy")) {
HttpResponse<String> response = get(path);
assertEquals(503, response.statusCode());
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
}
}
@Test
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
gatewayDown = true;
HttpResponse<String> response = get("/api/health");
assertEquals(200, response.statusCode());
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
}
@Test
void unknownSeriesOrRangeIs400() throws Exception {
assertEquals(400, get("/api/history?series=nope").statusCode());
assertEquals(400, get("/api/history").statusCode());
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
}
@Test
void influxFailureIs502() throws Exception {
historyDown = true;
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
}
@Test
void apiAcceptsOnlyGet() throws Exception {
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
.POST(HttpRequest.BodyPublishers.noBody()).build();
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void unknownApiPathIs404() throws Exception {
assertEquals(404, get("/api/nothing").statusCode());
}
@Test
void servesIndexAtRoot() throws Exception {
HttpResponse<String> response = get("/");
assertEquals(200, response.statusCode());
assertEquals("text/html; charset=utf-8", response.headers().firstValue("Content-Type").orElseThrow());
assertTrue(response.body().contains("test ui"));
}
@Test
void servesStaticFilesWithContentTypes() throws Exception {
Map<String, String> types = Map.of(
"/index.html", "text/html; charset=utf-8",
"/test.css", "text/css; charset=utf-8",
"/test.js", "text/javascript; charset=utf-8",
"/test.svg", "image/svg+xml",
"/test.png", "image/png",
"/test.ico", "image/x-icon");
for (Map.Entry<String, String> entry : types.entrySet()) {
HttpResponse<String> response = get(entry.getKey());
assertEquals(200, response.statusCode(), entry.getKey());
assertEquals(entry.getValue(), response.headers().firstValue("Content-Type").orElseThrow());
}
}
@Test
void missingUnknownTypeAndDirectoriesAre404() throws Exception {
for (String path : List.of("/missing.css", "/sub/data.txt", "/sub", "/sub/", "/web")) {
assertEquals(404, get(path).statusCode(), path);
}
}
@Test
void pathTraversalIsRefused() throws Exception {
// secret.css sits next to web/ on the test classpath
for (String path : List.of("/../secret.css", "/%2e%2e/secret.css", "/sub/../../secret.css",
"/%2e%2e%2fsecret.css", "/..%5csecret.css")) {
assertEquals(404, rawGet(path), path);
}
}
/** The JDK client normalises dot segments, so send the request line as is. */
private int rawGet(String path) throws IOException {
try (Socket socket = new Socket("localhost", server.port())) {
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
.lines().findFirst().orElseThrow();
return Integer.parseInt(statusLine.split(" ")[1]);
}
}
}
@@ -0,0 +1,241 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
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.HistoryView.Point;
import home.backend.domain.Series;
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.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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 ServicesTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static DeviceSnapshot device(String name, String database, String measurement,
Map<String, Double> fields) {
return new DeviceSnapshot(name, true, READ, null,
List.of(new Measurement(database, measurement, fields, READ)));
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot(List.of(devices));
}
private static GatewayClient unreachable() {
return () -> {
throw new IOException("tunnel down");
};
}
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
.current();
}
@Test
void heatPumpStateComesFromPumpMode() throws IOException {
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
assertEquals(8, view.setpoints().cwuEconomy());
}
@Test
void heatPumpMissingFieldsAreNull() throws IOException {
HeatPumpView view = heatPump(Map.of());
assertNull(view.state());
assertNull(view.compressorPercent());
assertNull(view.temperatures().outdoor());
assertNull(view.targets().cwu());
assertNull(view.setpoints().coComfort());
assertFalse(view.alarm());
}
@Test
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
HeatPumpService service = new HeatPumpService(gateway(
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
HeatPumpView view = service.current();
assertNull(view.state());
assertEquals(NOW, view.time());
}
@Test
void heatPumpPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
@Test
void energySumsPvOfBothInverters() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE),
device("fronius", "home", "fronius", FRONIUS));
assertEquals(3500.0, view.pvPowerW());
assertEquals(14.4, view.today().pvKwh());
assertEquals(-200.0, view.gridPowerW());
assertEquals(80.0, view.batterySocPct());
assertNull(view.batteryPowerW());
}
@Test
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE));
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(READ, view.time());
}
@Test
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithoutSourcesIsNullAtClockTime() throws IOException {
EnergyView view = energy();
assertNull(view.pvPowerW());
assertNull(view.today().pvKwh());
assertEquals(NOW, view.time());
}
@Test
void energyPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
}
@Test
void healthListsDevices() {
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();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
assertEquals(2, view.devices().size());
assertEquals("connect timed out", view.devices().get(1).lastError());
assertFalse(view.devices().get(1).up());
assertEquals(READ, view.devices().get(0).lastSuccess());
}
@Test
void healthWithUnreachableGatewayHasNoDevices() {
HealthView view = new HealthService(unreachable(), CLOCK).current();
assertFalse(view.gatewayReachable());
assertTrue(view.devices().isEmpty());
}
/** Remembers the request and answers with one point. */
private static final class RecordingHistory implements HistoryRepository {
Series series;
Instant from;
Instant to;
int maxPoints;
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
this.series = series;
this.from = from;
this.to = to;
this.maxPoints = maxPoints;
return List.of(new Point(from, 1.5));
}
}
@Test
void historyDefaultsToLast24Hours() throws IOException {
RecordingHistory repository = new RecordingHistory();
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
assertEquals("cwu_temp", view.series());
assertEquals("°C", view.unit());
assertEquals(1, view.points().size());
assertEquals(Series.CWU_TEMP, repository.series);
assertEquals(NOW, repository.to);
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
assertEquals(300, repository.maxPoints);
}
@Test
void historyOfSevenDays() throws IOException {
RecordingHistory repository = new RecordingHistory();
new HistoryService(repository, CLOCK).history("pv_power", "7d");
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
}
@Test
void historyRejectsUnknownSeriesAndRange() {
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
}
@Test
void historyPropagatesRepositoryFailure() {
HistoryService service = new HistoryService((series, from, to, max) -> {
throw new IOException("influx down");
}, CLOCK);
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
}
}
+1
View File
@@ -0,0 +1 @@
secret
@@ -0,0 +1 @@
<!doctype html><title>test ui</title>
@@ -0,0 +1 @@
x
+1
View File
@@ -0,0 +1 @@
body{}
+1
View File
@@ -0,0 +1 @@
ICO
+1
View File
@@ -0,0 +1 @@
a
+1
View File
@@ -0,0 +1 @@
PNG
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 42 B