feat(backend): add the backend project with the API contract
CI / Backend tests (Maven / Java 21) (push) Successful in 50s

The phone API and the web UI are built in parallel, so they need one
contract first: view records, the history allowlist, the gateway and
history ports, and example JSON. ADR-008 records the stack (proposed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 00:30:35 +02:00
1 parent 821f818b1e
commit 51a68f0d44
16 files changed
+318

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.home</groupId>
<artifactId>home-pump-backend</artifactId>
<version>1.0.0</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
<jackson.version>2.20.1</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.32</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.8.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>home.backend.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
/** The live state, read from the gateway on the Pi through the tunnel. */
public interface GatewayClient {
GatewaySnapshot snapshot() throws IOException;
}
@@ -0,0 +1,14 @@
package home.backend.application;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
public interface HistoryRepository {
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
}
@@ -0,0 +1,15 @@
package home.backend.domain;
import java.time.Instant;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Null when the source device is not read.
*/
public record EnergyView(Instant time, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
Double batterySocPct, Double loadPowerW, Today today) {
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
}
}
@@ -0,0 +1,17 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/** The gateway's latest state of every device, as the backend sees it. */
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
}
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5). */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -0,0 +1,26 @@
package home.backend.domain;
import java.time.Instant;
/** What the heat pump does now (F1). Values are null when the gateway has not read them yet. */
public record HeatPumpView(Instant time, State state, Integer compressorPercent, boolean heaterOn,
boolean alarm, Pumps pumps, Temperatures temperatures, Targets targets, Setpoints setpoints) {
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
}
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
}
/** Targets the controller works to right now (economy or comfort period). */
public record Targets(Double room, Double co, Double cwu) {
}
/** The four setpoints F3 will change; whole °C. */
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
}
}
@@ -0,0 +1,11 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** One series over a time range, already averaged into chart-sized steps. */
public record HistoryView(String series, String unit, List<Point> points) {
public record Point(Instant t, double v) {
}
}
@@ -0,0 +1,39 @@
package home.backend.domain;
import java.util.Arrays;
import java.util.Optional;
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
public enum Series {
OUTDOOR_TEMP("outdoor_temp", "°C"),
INDOOR_TEMP("indoor_temp", "°C"),
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
CWU_TEMP("cwu_temp", "°C"),
GROUND_IN_TEMP("ground_in_temp", "°C"),
GROUND_OUT_TEMP("ground_out_temp", "°C"),
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
GRID_POWER("grid_power", "W"),
PV_POWER("pv_power", "W"),
LOAD_POWER("load_power", "W"),
BATTERY_SOC("battery_soc", "%");
private final String id;
private final String unit;
Series(String id, String unit) {
this.id = id;
this.unit = unit;
}
public String id() {
return id;
}
public String unit() {
return unit;
}
public static Optional<Series> fromId(String id) {
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
}
}
@@ -0,0 +1,20 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SeriesTest {
@Test
void findsSeriesByItsId() {
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
}
@Test
void unknownIdFindsNothing() {
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
}
}
@@ -0,0 +1,9 @@
{
"time": "2026-10-23T22:26:00Z",
"pvPowerW": 0.0,
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
}
@@ -0,0 +1,8 @@
{
"time": "2026-10-23T22:14:00Z",
"gatewayReachable": true,
"devices": [
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
]
}
@@ -0,0 +1,14 @@
{
"time": "2026-10-23T22:13:35Z",
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
},
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
}
@@ -0,0 +1,9 @@
{
"series": "cwu_temp",
"unit": "°C",
"points": [
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
]
}
@@ -0,0 +1,29 @@
# ADR-008: Backend and web UI stack
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
## Context
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
the gateway already shows the stack is enough (ADR-007).
## Decision
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
the contract between backend and UI.
- **Web UI**: static HTML, CSS and plain JavaScript in `backend/src/main/resources/web/`, no
build step, no npm. Charts and the schematic are small hand-written SVG. Polish texts.
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
`Dom_Pompa.POMPA` fields, which already hold years of data.
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
## Consequences
- No JavaScript toolchain to install, update or audit; the UI is a few files a person can read.
- Richer interactions later may need a library; revisit then (M6 decides native or not).
- `scripts/check.sh` builds and tests both projects.
+1
View File
@@ -55,4 +55,5 @@ if (( failed )); then
fi
mvn -B -q clean package
mvn -B -q -f backend/pom.xml clean package
echo "check: OK"