feat(backend): add the backend project with the API contract
CI / Backend tests (Maven / Java 21) (push) Successful in 50s
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:
1 parent
821f818b1e
commit
51a68f0d44
16 files changed
+318
No files matched your search
@@ -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.
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user