Compare commits

..
Author SHA1 Message Date
rootandClaude Opus 5.5 a2eaf2d0ef archive: unfinished Spring Boot migration and React frontend
CI / Backend tests (Maven / Java 21) (push) Failing after 4m39s
Snapshot of uncommitted work (Feb-Mar 2026): Spring Boot ports & adapters
backend, control loop, Frisko register analysis, React/MUI dashboard and docs.
Never deployed; production (Raspberry Pi) runs e345063. Kept for reference only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:50:26 +02:00
root dd739e16c0 chore: harden lifecycle, health checks and CI
CI / Backend tests (Maven / Java 21) (push) Failing after 7m9s
- add PR trigger and Maven cache in CI

- centralize shutdown cleanup in Main

- make HealthMonitor failure counter thread-safe

- fix UTF-8 response length handling in health endpoint

- improve Influx username null-safety

- reduce expected modbus read failures from ERROR to WARN
2026-02-12 22:50:37 +01:00
2505 changed files with 58945 additions and 33272 deletions

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
---
name: close-milestone
description: Close the current milestone in docs/roadmap.md, remove its working notes and start the next one. Use when every item of the current milestone is done.
---
# Close milestone
1. Read `docs/roadmap.md`. Every item of "Current" is checked and its "Done when" holds.
Anything you cannot verify yourself (e.g. "a week on the Pi") you ask the user about.
Stop and report if something is open.
2. Branch `docs/close-<milestone>` (e.g. `docs/close-m1`).
3. Under "Done", add one line: `- M<n> <name> · <tag or commit>: <what it delivered>`.
Remove the "Current" checklist and any placeholder line.
4. Remove the milestone's working notes: fixed defects (D…), consumed feasibility checks,
notes that served only this milestone. Verified knowledge moves first: controller facts
to `docs/frisko/`, scope or architecture to `docs/spec.md`, decisions to a new ADR.
5. Make the next row of "Next" the new "Current": a checklist of PR-sized items in build
order, keeping its "Done when". Ask the user when the split is not obvious.
6. `scripts/check.sh` (roadmap ≤ 80 lines), then `/ship`.
-31
View File
@@ -1,31 +0,0 @@
---
name: ship
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally release it and update the Pi. Use when a change is ready to merge or release.
---
# Ship
Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/home-pump
(no API token here; reads need none).
1. On a topic branch (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), clean tree, commits
in Conventional Commits. Run `scripts/check.sh`.
2. `git push -u origin <branch>`; CI runs on every pushed branch.
3. Open the PR by push (Gitea AGit), title in Conventional Commits, description says why:
`git push origin HEAD:refs/for/master -o topic=<branch> -o title="…" -o description="…"`.
The description is one line (push options cannot hold newlines). After later fixes push
both the branch and `refs/for/master` with the same topic, so CI and the PR both update.
If that is refused, give the user the compare link `…/compare/master...<branch>`.
4. Wait for CI on the head commit, polling every 30 s:
`curl -s …/api/v1/repos/mariusz.pacek/home-pump/commits/<sha>/status` → `state`.
On `failure`, read the run under `…/actions`, fix, push again. Never merge red.
5. Merge only if the user asked for it in this task:
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
6. Release and update only with the user's explicit approval for this one:
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
report what you saw. Backend and frontend reach Kraków through Arcane (homelab-compose).
+18
View File
@@ -0,0 +1,18 @@
kind: pipeline
type: docker
name: default
steps:
- name: test
image: maven:3-jdk-11
commands:
- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
- mvn test -B
- name: docker
image: plugins/docker
settings:
repo: registry.mariuszpacek.pl/home-pump
tags: latest
when:
branch:
- master
+16 -14
View File
@@ -4,27 +4,29 @@ on:
push:
branches:
- "**"
pull_request:
branches:
- "**"
jobs:
check:
name: Check (gateway, backend, frontend)
backend:
name: Backend tests (Maven / Java 21)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Java 21
- name: Show runner info
run: |
echo "Runner name: ${{ runner.name }}"
echo "Runner labels: ${{ toJSON(runner.labels) }}"
- name: Set up Java 21 + Maven
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '21'
- name: Set up Maven
uses: stCarolas/setup-maven@v5
with:
maven-version: 3.9.6
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Check
run: scripts/check.sh
java-version: "21"
cache: maven
- name: Run Maven tests (parallel)
run: mvn -B -T 1C test
-59
View File
@@ -1,59 +0,0 @@
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
name: Release
on:
push:
tags:
- "v*.*.*"
env:
REGISTRY: git.mariuszpacek.pl
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
jobs:
images:
name: Images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
- name: Docker CLI
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.repository_owner }}
# The owner's token with package write access (the secret homelab-upgrade uses too).
password: ${{ secrets.REGISTRY_TOKEN }}
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
# final stage, so the arm64 image needs no emulation.
- name: Gateway
uses: docker/build-push-action@v6
with:
context: gateway
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/arm64,linux/amd64
push: true
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
- name: Backend
uses: docker/build-push-action@v6
with:
context: backend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
- name: Frontend
uses: docker/build-push-action@v6
with:
context: frontend
build-args: VERSION=${{ gitea.ref_name }}
platforms: linux/amd64
push: true
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
# Enabled once per clone: git config core.hooksPath .githooks
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
+81
View File
@@ -0,0 +1,81 @@
# Copilot instructions for `home-pump`
## Build and test commands
### Backend (repo root)
- `mvn clean package` builds the Spring Boot jar at `target/home-pump-1.0.0.jar`.
- `mvn test` runs the current backend test suite. CI (`.drone.yml`) uses plain `mvn test`.
- `mvn -Dtest=ControlLoopUseCaseTests test` runs a single unit test class.
- `mvn -Dtest=ControlLoopUseCaseTests#shouldApplyCwuPriorityBeforeSendingCommands test` runs a single JUnit test method.
- `mvn -Dtest=ControlApiContractIntegrationTests test` runs one tagged integration test class.
- `java -jar target/home-pump-1.0.0.jar` starts the packaged backend.
### Frontend (`frontend/`)
- `npm install`
- `npm run dev`
- `npm run build`
- `npm run build:ssr`
## High-level architecture
- The backend is a Spring Boot application (`HomePumpApplication`) with most runtime behavior driven by `ScheduledTasks`. The scheduler runs five separate loops: the control loop, live telemetry polling, legacy Influx publishing, schedule application, and health-based shutdown/restart.
- Configuration is split across two sources and both matter:
- `src/main/resources/application.yml` contains Spring-managed control settings, schedule/poll intervals, SQLite path, heating-circuit strategy definitions, and optional Modbus command register overrides.
- `src/main/resources/registryMapping.json` is loaded from the classpath by `JsonFileBasedConfiguration` and carries device address/port, retry settings, `useMock`, live polling window, Influx connection info, and the register metadata used by the Modbus layer. `registryMapping.example.json` is only a sample; the loader looks for `registryMapping.json`.
- The backend is organized as a ports-and-adapters style application, but it still contains legacy integration packages:
- `domain`: control models, temperature strategies, and rules like CWU priority, anti-freeze, disinfection, and pump runtime protection.
- `application`: use cases and output ports. `ControlLoopUseCase` decides commands from live readings; `ManualControlUseCase` owns operator-controlled state such as AUTO/MANUAL, ECO/COMFORT, SUMMER/WINTER, per-circuit override mode, and the weekly schedule.
- `infrastructure`: REST controllers, Modbus/Influx adapters, Frisko services, scheduler wiring, and SQLite persistence.
- `configuration`, `modbus`, `influxdb`, and `health` still contain important lower-level classes used by the Spring wiring.
- `ManualControlUseCase` is the source of truth for mutable control state. It persists state through `SqliteControlStateStore` to `data/control-state.db`, including operating mode, season/program mode, circuit overrides, control policy settings, and the program schedule.
- The Modbus side has two distinct responsibilities:
- `ModbusDeviceAdapter` is the bridge used by the control loop for current readings and write commands.
- `ModbusPollingService` builds the rolling telemetry/history used by `/api/telemetry` and contributes the Modbus component state to `/health`.
- Frisko support is driven by `frisko/mr208_m3p.csv`. `FriskoRegisterCatalogService` categorizes symbols from that CSV, and `FriskoRuntimeService` exposes runtime read/write, controller time sync, alarms, schedules, and thresholds through `/api/v1/frisko/*`.
- There are currently two Influx write paths:
- `ControlLoopUseCase` publishes through `MetricsPublisher`, which is wired to `InfluxMetricsAdapter`.
- `ScheduledTasks.sendLegacyInfluxMetrics()` still runs `InfluxMetricModbusSender`.
Any Influx-related change needs to account for both paths.
- The frontend is a Vite + React + TypeScript dashboard under `frontend/`. `App.tsx` composes feature panels for health, control, telemetry, Frisko analysis/runtime, thresholds, schedule editing, and the schematic view. API access lives in `services/*`, React Query polling/mutations live in `hooks/*`, and `lib/http.ts` centralizes Axios setup plus normalized API errors.
- Workspace-level browser automation is configured through `.vscode/mcp.json` with the Playwright MCP server. Prefer that path for UI verification when you need to inspect the dashboard behavior end-to-end.
## Key conventions
- Treat circuit IDs as uppercase domain identifiers (`CO1`, `CO2`, `CO3`, `CWU`). The backend normalizes them to uppercase in multiple places (`ManualControlUseCase`, `InMemoryHeatingCircuitRepository`), and frontend services/UI use the same IDs.
- Heating-circuit behavior is declarative in `application.yml` under `app.heating.circuits`. `InMemoryHeatingCircuitRepository` turns each definition into a `HeatingCircuit` with either a weather-compensated or constant strategy, and wraps it in `ClampedTemperatureStrategy` when both min/max temperatures are provided.
- Real hardware writes are disabled by default. `ModbusDeviceAdapter.applyCommands()` only writes when `app.modbus.commands.write-enabled=true`. If you change control flows or manual actions, remember the UI can still update persisted logical state even when physical writes are skipped.
- Command-register mapping is symbol-driven, not just hard-coded register-driven. `FriskoCircuitRegisterMappingService` builds default mappings from the Frisko CSV, preferring `WY_*` symbols and falling back to `TEST_*`, with `application.yml` only overriding specific circuit registers when needed.
- Telemetry and device-state extraction are also symbol/description driven. `ModbusDeviceAdapter` and `ModbusPollingService` search registry descriptions using Frisko symbols first and older human-readable descriptions as fallbacks. When adding a new signal, update the matching candidates and the related tests instead of relying on a single label spelling.
- `SystemReadings` may contain `NaN` internally for missing numeric values. API-facing controllers such as `SystemController` sanitize those values to `null` before returning JSON. Keep that distinction when extending the model or frontend types.
- Local backend development can avoid a real device by setting top-level `useMock: true` in `registryMapping.json`. `AppConfig` swaps the Modbus repository to `MockRepository` in that mode.
- The REST API is intentionally split by bounded context:
- legacy monitoring endpoints: `/health`, `/api/telemetry`
- control endpoints: `/api/v1/control/*`
- current system snapshot endpoints: `/api/v1/system/*`
- Frisko analysis/runtime endpoints: `/api/v1/frisko/*`
Frontend service modules mirror these base paths; follow that pattern instead of calling Axios directly from panels.
- Frontend polling and error handling live in hooks, not components. Typical pattern: `services/*` perform the request, `hooks/*` wrap them with React Query and toast handling, and panels stay mostly presentational.
- The telemetry UI can show mock data even when the backend telemetry endpoint is unavailable. `frontend/src/services/telemetryService.ts` falls back to `mocks/telemetryMock.ts` after a failed `/api/telemetry` request, so do not treat rendered charts as proof that live telemetry is healthy.
- Integration tests are tagged with `@Tag("integration")`, but the current repo and CI still run plain `mvn test`. Do not assume tagged tests are separated into a dedicated Maven lifecycle unless you also wire Surefire/Failsafe to do it.
- Treat Java 21 as the backend build target. The active `maven-compiler-plugin` is set to `release 21`, even though some legacy files in the repo still reference older Java versions.
+2 -4
View File
@@ -18,7 +18,7 @@ target/
/.idea/
/.vscode/
/.java-version
dependency-reduced-pom.xml
/dependency-reduced-pom.xml
/workspace.code-workspace
/target/
@@ -26,6 +26,7 @@ dependency-reduced-pom.xml
/node_modules/
/frontend/node_modules/
/frontend/dist/
/frontend/.env*
/dist/
# Vite
@@ -55,6 +56,3 @@ coverage/
/apk/
/old/
/old_backup/
# Agent worktrees
/.claude/worktrees/
-16
View File
@@ -1,16 +0,0 @@
Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP) from a phone.
@CONTRIBUTING.md
## For agents
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
- Production: `pi@raspberrypi.local`, the gateway in Docker (`gateway/compose.yaml`; until it is
installed, the old `home-pump.service`). Read-only inspection is fine; installing, updating,
restarting or changing files on the Pi needs explicit approval each time.
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
or reinstall the Pi's OS.
- Never write to the controller `192.168.0.150` and never to the `Dom_Pompa` database.
- Ask before force-pushing or rewriting history on Gitea.
-62
View File
@@ -1,62 +0,0 @@
# Repository rules
What we build: [docs/spec.md](docs/spec.md). Plan: [docs/roadmap.md](docs/roadmap.md).
Reasons: [docs/decisions/](docs/decisions/).
## Branches and pull requests
- `master` is always deployable and matches what runs on the Pi.
- Every change goes through a pull request on Gitea; no direct commits to `master`.
- Branch names: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `refactor/…`.
- One topic per PR, small enough to review in one sitting. Merge only with green CI.
- `archive/spring-boot-attempt` is reference only. Never merge it.
## Commits
- English, Conventional Commits: `fix(health): restart without /health polling`.
- Imperative subject ≤ 72 characters; the body says why when it is not obvious.
- Each commit builds and passes tests.
## Code
- Three parts, each with its `Dockerfile` and `compose.yaml` (ADR-010): `gateway/` and `backend/`
in Java 21 with Maven and no framework (ADR-001), `frontend/` in React and TypeScript (ADR-008).
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
- Readable first: small classes and methods, names from the domain, comments only for the why.
- No dead code, no unused config, no duplicated logic, no speculative abstractions.
- Every behaviour change has a test. Modbus is tested against `MockModbusServer` only.
## Safety
- Tests and local runs never use the real controller or the `Dom_Pompa` database.
- A register is written only if it is on the allowlist (min, max, step) and verified in
`docs/frisko/`.
- Changes to how or when we write to the controller need a test of the failure case.
## Knowledge and docs
Docs are written so an agent needs little context: read only what the task touches.
| File | Limit | Changes |
|---|---|---|
| `CLAUDE.md` | 40 lines | rarely |
| `docs/spec.md` | 120 lines | when scope or architecture changes |
| `docs/roadmap.md` | 80 lines | every milestone |
| `docs/decisions/NNN-*.md` | 40 lines | never after acceptance; supersede with a new ADR |
| `docs/frisko/<topic>.md` | 120 lines | when knowledge is verified |
| `<package>/CLAUDE.md` | 30 lines | rules of that layer |
| `research/<source>/` | none | raw vendor material, kept as found; read only on demand |
- Limits are checked by `scripts/check.sh`.
- No status, audit or log documents: the state is git history and the roadmap.
- Closing a milestone compresses it to one line in the roadmap and removes its working notes.
- English everywhere; the UI is Polish.
## Configuration, secrets, releases
- No secrets in git. Production config lives on the Pi and next to the Kraków stacks, outside
the images.
- Release only from `master` with `scripts/release.sh vMAJOR.MINOR.PATCH`: it tags master and
CI publishes the three images. On the Pi `./update.sh <version>` goes back if it is unhealthy.
- After an update, watch the journal and Grafana for at least one polling cycle.
+4
View File
@@ -0,0 +1,4 @@
FROM amazoncorretto:11.0.17
COPY target/test*.jar app/home-pump.jar
CMD ["java", "-jar", "app/home-pump.jar"]
+192 -17
View File
@@ -1,25 +1,200 @@
# home-pump
# Home Pump Monitor
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
- What we build: [docs/spec.md](docs/spec.md)
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
## Features
| Part | Runs | Readme |
|---|---|---|
| `gateway/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
| `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
| `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
- Reads Modbus holding registers from a heat pump
- Configurable send interval to InfluxDB (default: 5 minutes)
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
- Health check endpoint for monitoring application status
- Automatic restart on Modbus connection failures
- Graceful shutdown with InfluxDB buffer flush
- Systemd service with automatic recovery
- Configurable via JSON file
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
## Quick Start
### Local Development
```bash
scripts/check.sh # docs, secret scan, build and tests of all three parts
git config core.hooksPath .githooks # once per clone: check before every push
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
mvn clean package
java -jar target/home-pump-1.0.0.jar
```
### Production Deployment (Raspberry Pi / Ubuntu)
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
```bash
# Build
mvn clean package
# Install as systemd service
sudo scripts/install-service.sh
# Check status
systemctl status home-pump.service
curl http://localhost:8080/health
```
## Configuration
Edit `src/main/resources/registryMapping.json` to configure:
- Modbus TCP connection (address, port)
- InfluxDB connection (address, username, password)
- Send interval (influxSendIntervalSeconds, default: 300)
- Connection timeouts and retry settings
- List of registers to read with descriptions
Additional mapping fields (optional):
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
- # Example Configuration
```json
{
"address": "192.168.0.150",
"port": 502,
"useMock": false,
"influxSendIntervalSeconds": 300,
"timeoutMs": 5000,
"maxRetries": 2,
"retryBackoffMs": 1000,
"influx_db": {
"address": "https://influxdb.example.com",
"username": "",
"password": ""
},
"registry_mapping": [
{
"registry": 4065,
"desc": "External temperature",
"unitId": 1,
"scale": 0.1,
"dataType": "TWO_BYTE_INT_SIGNED"
}
]
}
```
## Monitoring & Health Check
The application exposes a health check endpoint on port 8080:
```bash
curl http://localhost:8080/health
```
**Healthy response (HTTP 200):**
```json
{
"status": "UP",
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
"message": "Last successful read 15 seconds ago"
}
```
**Unhealthy response (HTTP 503):**
```json
{
"status": "DOWN",
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
"message": "No successful read for 620 seconds. Last error: Connection timeout"
}
```
### Important Notes
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
- Only Modbus read failures trigger unhealthy status and potential restart
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
- Automatic monitoring script restarts the service on health check failures
## Production Deployment
For production use on Raspberry Pi or Ubuntu server:
1. **Install as systemd service** - Auto-start on boot with resource limits
2. **Enable health monitoring** - Cron job checks health every 2 minutes
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
## Data Buffering
When InfluxDB is unavailable, data is automatically buffered:
- **Buffer size**: 50,000 data points in memory
- **Batch size**: 50 points per flush
- **Flush interval**: 180 seconds (3 minutes)
- **No data loss**: Buffer persists during InfluxDB outages
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
## Troubleshooting
### Check application logs
```bash
journalctl -u home-pump.service -f
```
### Test health endpoint
```bash
./scripts/test-health.sh
```
### View monitor logs
```bash
tail -f /var/log/home-pump-monitor.log
```
### Common Issues
1. **Service won't start**: Check JAR file exists and Java 21 is installed
2. **Health check fails**: Verify Modbus device is accessible
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
java -jar target/home-pump-1.0.0.jar
```
## Requirements
- Java 21
- Modbus TCP accessible heat pump
- InfluxDB instance
## Frontend Dashboard (React + TypeScript + MUI)
Nowy frontend znajduje się w folderze `frontend` i zawiera:
- Vite + React + TypeScript
- Material UI w ciemnym stylu
- Recharts do wykresów operacyjnych
- Architektura isomorphic-ready (`entry-client.tsx` i `entry-server.tsx`)
- Reużywalne komponenty i warstwa usług gotowa pod rozbudowę API
### Uruchomienie frontendu
```bash
cd frontend
npm install
npm run dev
```
### Build produkcyjny
```bash
cd frontend
npm run build
```
### Build SSR (isomorphic)
```bash
cd frontend
npm run build:ssr
```
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
backend and frontend images.
-1
View File
@@ -1 +0,0 @@
target
-22
View File
@@ -1,22 +0,0 @@
# Built on the build machine's own platform; the image only holds the jar and a JRE.
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src src
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
RUN mvn -B -q package -Dmaven.test.skip=true
FROM eclipse-temurin:21-jre-alpine
# The release tag, shown in /api/health; the release workflow passes it.
ARG VERSION=dev
ENV VERSION=$VERSION
USER 10001:10001
WORKDIR /app
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
EXPOSE 8080
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
-86
View File
@@ -1,86 +0,0 @@
# Backend
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
the three values are null (`pi-sensor-stale.json`).
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back 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).
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
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`.
| Variable | Default | Meaning |
|---|---|---|
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
| `PORT` | `8080` | listening port |
| `GATEWAY_TIMEOUT` | `5` | seconds |
| `INFLUX_TIMEOUT` | `10` | seconds |
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
```bash
mvn -f backend/pom.xml clean package
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
```
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
pending change of that setpoint now and answers with the gateway's verdict, example
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
pending), 503 gateway unreachable.
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
rules (ADR-009).
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
-16
View File
@@ -1,16 +0,0 @@
# The backend in Kraków: the API only (ADR-010), reached by the frontend on the network home-pump
# and published on no host port (ADR-009). Settings in .env next to this file (README).
name: home-pump-backend
services:
backend:
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:${VERSION:-latest}
build: .
restart: unless-stopped
env_file: .env
environment:
TZ: Europe/Warsaw
networks: [home-pump]
networks:
home-pump:
name: home-pump
-84
View File
@@ -1,84 +0,0 @@
<?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>
@@ -1,79 +0,0 @@
package home.backend;
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.InfluxPvEnergy;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.adapters.weather.ImgwClient;
import home.backend.adapters.weather.OpenMeteoClient;
import home.backend.adapters.weather.WeatherSettings;
import home.backend.application.AuditService;
import home.backend.application.BatteryService;
import home.backend.application.EnergyDetailsService;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.LeaseRenewer;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import java.io.IOException;
import java.net.URI;
import java.time.Clock;
import java.time.Duration;
import java.util.Objects;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
private Backend() {
}
/**
* Binds the port but does not start serving.
*
* @throws IllegalArgumentException when PRESETS_FILE is unusable
*/
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
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(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
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 AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
weather(influxSettings, clock));
}
private static WeatherService weather(InfluxSettings influx, Clock clock) {
var place = WeatherSettings.fromEnvironment(System.getenv());
return new WeatherService(
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WEATHER_TIMEOUT),
new InfluxPvEnergy(influx), clock);
}
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
static LeaseRenewer leaseRenewer(Settings settings) {
return new LeaseRenewer(commands(settings));
}
private static GatewayHttpCommands commands(Settings settings) {
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
}
}
@@ -1,35 +0,0 @@
package home.backend;
import home.backend.adapters.http.ApiServer;
import java.io.IOException;
import java.time.Clock;
public final class Main {
private Main() {
}
public static void main(String[] args) throws IOException {
Settings settings;
ApiServer server;
try {
settings = Settings.fromEnvironment(System.getenv());
server = Backend.assemble(settings, Clock.systemUTC());
} catch (IllegalArgumentException e) {
System.err.println("home-pump-backend: " + e.getMessage());
System.exit(2);
return;
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
} else {
var renewer = Backend.leaseRenewer(settings);
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
renewer.start();
}
server.start();
System.out.println("home-pump-backend listening on port " + server.port());
}
}
@@ -1,65 +0,0 @@
package home.backend;
import java.net.URI;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
/** Twelve-factor settings: everything comes from environment variables. */
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
String gatewayToken, Path presetsFile) {
public static Settings fromEnvironment(Map<String, String> env) {
return new Settings(
url(env, "GATEWAY_URL"),
url(env, "INFLUX_URL"),
(int) number(env, "PORT", 8080, 0, 65535),
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
optional(env, "GATEWAY_TOKEN"),
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
}
/** Null when unset or blank. */
private static String optional(Map<String, String> env, String name) {
String value = env.get(name);
return value == null || value.isBlank() ? null : value.strip();
}
private static URI url(Map<String, String> env, String name) {
String value = env.get(name);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
}
URI uri = URI.create(value.strip());
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
}
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
+ value);
}
return uri;
}
/** Timeouts are whole seconds. */
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
long number = Long.parseLong(value.strip());
if (number >= min && number <= max) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
+ ", got: " + value);
}
}
@@ -1,41 +0,0 @@
package home.backend.adapters.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.domain.Preset;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
public final class PresetFile {
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
};
private PresetFile() {
}
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
public static List<Preset> load(Path file) {
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
List<Preset> presets = JSON.readValue(in, PRESETS);
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
throw new IllegalArgumentException("preset ids must be unique");
}
return presets;
} catch (IOException | IllegalArgumentException e) {
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
}
}
private static InputStream defaults() {
return PresetFile.class.getResourceAsStream("/presets-default.json");
}
}
@@ -1,88 +0,0 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayClient;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
public class GatewayHttpClient implements GatewayClient {
private record Health(String version, List<DeviceHealth> devices) {
}
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
}
private record DeviceReadings(String name, List<Reading> readings) {
}
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
}
// Unknown properties are ignored so the gateway can add fields without breaking us.
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI baseUri;
private final Duration timeout;
private final HttpClient http;
public GatewayHttpClient(URI baseUri, Duration timeout) {
this.baseUri = baseUri;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public GatewaySnapshot snapshot() throws IOException {
var health = JSON.readValue(get("/health", true), Health.class);
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
var devices = health.devices().stream()
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
device.lastSuccess(), device.lastError(),
measurements.getOrDefault(device.name(), List.of())))
.toList();
return new GatewaySnapshot(health.version(), devices);
}
private static List<Measurement> measurements(DeviceReadings device) {
return device.readings().stream()
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
.toList();
}
/** A gateway with a device down answers /health with 503 and a valid body. */
private byte[] get(String path, boolean downIsValid) throws IOException {
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
try {
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
int status = response.statusCode();
if (status != 200 && !(downIsValid && status == 503)) {
throw new IOException("gateway " + path + " answered " + status);
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway " + path, e);
}
}
}
@@ -1,164 +0,0 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
/** Sends commands to the gateway's {@code /commands} and {@code /battery} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
private record ReserveRequest(int pct) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes, Instant until) {
}
private record RestoreRequest(String setpoint) {
}
private record Renewal(int renewed) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final URI renewEndpoint;
private final URI restoreEndpoint;
private final URI reserveEndpoint;
private final URI scheduleEndpoint;
private final String token;
private final Duration timeout;
private final HttpClient http;
/** @param token null when not configured: every call then fails without touching the network */
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
this.endpoint = baseUri.resolve("/commands");
this.renewEndpoint = baseUri.resolve("/commands/renew");
this.restoreEndpoint = baseUri.resolve("/commands/restore");
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
this.token = token;
this.timeout = timeout;
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
}
@Override
public CommandResult apply(Command command) throws IOException {
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
HttpRequest.Builder request = request().header("Content-Type", "application/json")
.header("X-Requested-By", command.requestedBy())
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
var request = request(restoreEndpoint).header("Content-Type", "application/json")
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
}
@Override
public SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
@Override
public int renewLeases() throws IOException {
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
}
@Override
public Reserve reserve() throws IOException {
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
slot.socPct(), slot.gridCharge())).toList();
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
}
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
}
private HttpRequest.Builder request() throws IOException {
return request(endpoint);
}
private HttpRequest.Builder request(URI uri) throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var built = request.build();
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling the gateway", e);
}
}
}
@@ -1,426 +0,0 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
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.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;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.SetpointService;
import home.backend.application.WeatherService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import home.backend.domain.RestoreResult;
import java.io.IOException;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService;
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)
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
private record ReserveRequest(Integer pct) {
}
private record SetpointRequest(String setpoint, Integer value, Double hours) {
}
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
private record PresetRequest(String until, Map<String, Integer> values) {
PresetRequest {
if (values == null) {
values = Map.of();
} else if (values.containsValue(null)) {
throw new IllegalArgumentException("a value must be a number");
}
}
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
private static final String SETPOINTS_PATH = "/api/setpoints/";
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
private static final String USER_HEADER = "Remote-User";
private static final String DEFAULT_USER = "phone";
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
private static final int MAX_BODY_BYTES = 4096;
private static final int STOP_GRACE_SECONDS = 5;
private final HttpServer server;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
/** Binds the port now; 0 picks a free one. */
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
EnergyDetailsService energyDetails, HealthService health,
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather)
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).
context("/", exchange -> {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
});
api("/api/heat-pump", query -> heatPump.current());
api("/api/energy", query -> energy.current());
api("/api/energy/details", query -> energyDetails.current());
api("/api/battery", query -> battery.current());
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
api("/api/pi-sensor", query -> piSensor.current());
api("/api/weather", query -> weather.current());
api("/api/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/audit", exchange -> handleAudit(exchange, audit));
route("/api/stats", exchange -> handleStats(exchange, stats));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(SETPOINTS_PATH, exchange -> {
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
if (restore.matches()) {
handle(exchange, Map.of("POST", () -> guarded(exchange,
body -> postRestore(exchange, restore.group(1), setpoints))));
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
}
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
private void route(String path, HttpHandler handler) {
context(path, exchange -> {
if (path.equals(exchange.getRequestURI().getPath())) {
handler.handle(exchange);
} else {
try (exchange) {
exchange.sendResponseHeaders(404, -1);
}
}
});
}
private void context(String path, HttpHandler handler) {
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
}
public void start() {
server.start();
}
public void stop() {
server.stop(STOP_GRACE_SECONDS);
executor.shutdown();
}
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) {
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, gateway.get(Map.of()));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
})));
}
/**
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
*/
private static void guarded(HttpExchange exchange, Write write) throws IOException {
var headers = exchange.getRequestHeaders();
String origin = headers.getFirst("Origin");
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
send(exchange, 403, error("cross-site request"));
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
} else if (!isJson(headers.getFirst("Content-Type"))) {
send(exchange, 415, error("expected Content-Type: application/json"));
} else {
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
if (body.length > MAX_BODY_BYTES) {
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
} else {
write.run(body);
}
}
}
private static boolean isJson(String contentType) {
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
}
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
try {
send(exchange, 200, setpoints.current());
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
try {
var request = JSON.readValue(body, SetpointRequest.class);
if (request == null || request.setpoint() == null || request.value() == null) {
throw new IllegalArgumentException("setpoint and value are required");
}
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
send(exchange, httpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var change = JSON.readValue(body, BatteryChange.class);
if (change == null) {
throw new IllegalArgumentException("a change is required");
}
var result = battery.changeSchedule(change, user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
+ "\"socPct\"?, \"gridCharge\"?}]}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
try {
var request = JSON.readValue(body, ReserveRequest.class);
if (request == null || request.pct() == null) {
throw new IllegalArgumentException("pct is required");
}
var result = battery.changeReserve(request.pct(), user(exchange));
send(exchange, batteryHttpStatus(result.status()), result);
} catch (JsonProcessingException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"pct\"}"));
} catch (IOException e) {
send(exchange, 503, error(UNAVAILABLE));
}
}
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
throws IOException {
try {
var result = setpoints.restore(setpoint, user(exchange));
send(exchange, restoreHttpStatus(result.outcome()), result);
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such preset"));
}
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
+ "for setpoints of the preset"));
} catch (IOException e) {
send(exchange, 503, error(UNREACHABLE));
}
}
private static int httpStatus(Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED -> 502;
};
}
private static int batteryHttpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
return switch (outcome) {
case RESTORED -> 200;
case NOT_PENDING -> 404;
case CONFLICT -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
private static int presetHttpStatus(PresetResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case FAILED, PARTIAL -> 502;
};
}
private static String user(HttpExchange exchange) {
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
}
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
handle(exchange, Map.of("GET", () -> {
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 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 void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
handle(exchange, Map.of("GET", () -> {
try {
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
} catch (IllegalArgumentException e) {
send(exchange, 400, error(e.getMessage()));
} catch (IOException e) {
send(exchange, 502, error("stats unavailable"));
}
}));
}
private interface Action {
void run() throws IOException;
}
private interface Write {
void run(byte[] body) throws IOException;
}
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
try (exchange) {
Action action = byMethod.get(exchange.getRequestMethod());
if (action == null) {
exchange.sendResponseHeaders(405, -1);
return;
}
try {
action.run();
} catch (RuntimeException e) {
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
if (exchange.getResponseCode() == -1) {
send(exchange, 502, error("bad gateway response"));
}
}
}
}
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);
}
}
@@ -1,28 +0,0 @@
package home.backend.adapters.http;
import com.sun.net.httpserver.Filter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
/** Sets the browser hardening headers on every response, whatever handler answers. */
final class SecurityHeaders extends Filter {
private static final String POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; "
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; "
+ "form-action 'self'; frame-ancestors 'none'";
@Override
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
var headers = exchange.getResponseHeaders();
headers.set("Content-Security-Policy", POLICY);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Referrer-Policy", "no-referrer");
chain.doFilter(exchange);
}
@Override
public String description() {
return "security headers";
}
}
@@ -1,88 +0,0 @@
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();
}
}
@@ -1,47 +0,0 @@
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);
}
}
@@ -1,69 +0,0 @@
package home.backend.adapters.influx;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryRepository;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ExecutionException;
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, HeatPumpSamples {
private final InfluxClient influx;
public InfluxHistoryRepository(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
if (maxPoints < 1 || !from.isBefore(to)) {
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
}
long stepMinutes = stepMinutes(from, to, maxPoints);
Map<Long, Double> summed = new TreeMap<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var parts = SeriesSources.of(series).stream()
.map(source -> executor.submit(() -> part(source, from, to, stepMinutes)))
.toList();
// Summed in source order, so the result does not depend on which answer comes first.
for (var part : parts) {
// A part without data is absent, not zero, so a bucket is kept if any part has it.
part.get().forEach((t, v) -> summed.merge(t, v, Double::sum));
}
} catch (ExecutionException e) {
throw e.getCause() instanceof IOException io ? io : new IOException(e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
return summed.entrySet().stream()
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
.toList();
}
@Override
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
}
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(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()));
}
}
@@ -1,51 +0,0 @@
package home.backend.adapters.influx;
import home.backend.application.PvEnergyRepository;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Map;
import java.util.TreeMap;
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
public final class InfluxPvEnergy implements PvEnergyRepository {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final String DATABASE = "electricity";
private final InfluxClient influx;
public InfluxPvEnergy(InfluxSettings settings) {
this.influx = new InfluxClient(settings);
}
@Override
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
Map<LocalDate, Double> total = new TreeMap<>();
deye.forEach((day, kwh) -> {
Double wh = fronius.get(day);
if (wh != null) {
total.put(day, kwh + wh / 1000);
}
});
return total;
}
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
throws IOException {
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
Map<LocalDate, Double> days = new TreeMap<>();
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
return days;
}
private static long millis(LocalDate day) {
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
}
}
@@ -1,50 +0,0 @@
package home.backend.adapters.influx;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.Map;
import java.util.TreeMap;
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
final class InfluxResponse {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxResponse() {
}
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
static Map<Long, Double> parse(String body) throws IOException {
JsonNode root = read(body);
failOnError(root);
Map<Long, Double> means = new TreeMap<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
if (row.size() >= 2 && row.get(1).isNumber()) {
means.put(row.get(0).asLong(), row.get(1).asDouble());
}
}
}
}
return means;
}
private static JsonNode read(String body) throws IOException {
try {
return JSON.readTree(body);
} catch (JsonProcessingException e) {
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
}
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -1,58 +0,0 @@
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.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
final class InfluxSamples {
private static final ObjectMapper JSON = new ObjectMapper();
private InfluxSamples() {
}
static String query(long fromMs, long toMs) {
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
}
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
static List<HeatPumpSample> parse(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);
List<HeatPumpSample> samples = new ArrayList<>();
for (JsonNode result : root.path("results")) {
failOnError(result);
for (JsonNode series : result.path("series")) {
for (JsonNode row : series.path("values")) {
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
number(row, 5), number(row, 6)));
}
}
}
return samples;
}
private static Double number(JsonNode row, int column) {
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
}
private static void failOnError(JsonNode node) throws IOException {
if (node.hasNonNull("error")) {
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
}
}
}
@@ -1,7 +0,0 @@
package home.backend.adapters.influx;
import java.net.URI;
import java.time.Duration;
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
}
@@ -1,44 +0,0 @@
package home.backend.adapters.influx;
import home.backend.domain.Series;
import java.util.List;
import java.util.Map;
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
final class SeriesSources {
static final String LEGACY = "Dom_Pompa";
private static final String ELECTRICITY = "electricity";
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
Map.entry(Series.PV_POWER, List.of(
electricity("deye", "pv1_power_w"),
electricity("deye", "pv2_power_w"),
electricity("fronius", "pv_power_w"))),
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
private SeriesSources() {
}
static List<Source> of(Series series) {
return SOURCES.get(series);
}
private static Source pompa(String field) {
return new Source(LEGACY, "POMPA", field);
}
private static Source electricity(String measurement, String field) {
return new Source(ELECTRICITY, measurement, field);
}
}
@@ -1,15 +0,0 @@
package home.backend.adapters.influx;
/** One field of one measurement; the parts of a series are summed. */
record Source(String database, String measurement, String field) {
String query(long fromMs, long toMs, long stepMinutes) {
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
}
private static String quote(String identifier) {
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
}
}
@@ -1,42 +0,0 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
final class HttpJson {
private static final ObjectMapper JSON = new ObjectMapper();
private final HttpClient http;
private final Duration timeout;
HttpJson(Duration timeout) {
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
this.timeout = timeout;
}
JsonNode get(URI uri) throws IOException {
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
}
return JSON.readTree(response.body());
} catch (JsonProcessingException e) {
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("request to " + uri.getHost() + " interrupted", e);
}
}
}
@@ -1,76 +0,0 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.WarningSource;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
* that is malformed (no degree or times) is left out rather than failing the others.
*/
public final class ImgwClient implements WarningSource {
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final HttpJson http;
private final URI uri;
private final List<String> teryt;
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
this.teryt = place.teryt();
}
@Override
public List<Warning> warnings() throws IOException {
JsonNode root = http.get(uri);
if (!root.isArray()) {
throw new IOException("IMGW returned no list of warnings");
}
List<Warning> ours = new ArrayList<>();
for (JsonNode item : root) {
if (covers(item.path("teryt"))) {
warning(item).ifPresent(ours::add);
}
}
return ours;
}
private boolean covers(JsonNode codes) {
for (JsonNode code : codes) {
if (teryt.contains(code.asText())) {
return true;
}
}
return false;
}
private static Optional<Warning> warning(JsonNode item) {
try {
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
Integer.parseInt(item.path("stopien").asText()),
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
item.path("tresc").asText()));
} catch (NumberFormatException | DateTimeException e) {
return Optional.empty();
}
}
private static OffsetDateTime time(JsonNode text) {
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
}
}
@@ -1,68 +0,0 @@
package home.backend.adapters.weather;
import com.fasterxml.jackson.databind.JsonNode;
import home.backend.application.ForecastSource;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import java.io.IOException;
import java.net.URI;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Map;
import java.util.TreeMap;
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 3, and the temperature now. */
public final class OpenMeteoClient implements ForecastSource {
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
+ "shortwave_radiation_sum,sunshine_duration";
private final HttpJson http;
private final URI uri;
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
this.http = new HttpJson(timeout);
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
+ "&current=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=3");
}
@Override
public Forecast forecast() throws IOException {
JsonNode root = http.get(uri);
JsonNode daily = root.path("daily");
JsonNode dates = daily.path("time");
if (!dates.isArray() || dates.isEmpty()) {
throw new IOException("Open-Meteo returned no daily forecast");
}
Map<LocalDate, DailyForecast> days = new TreeMap<>();
for (int i = 0; i < dates.size(); i++) {
days.put(date(dates.get(i).asText()), new DailyForecast(
integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i),
number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i),
number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i)));
}
JsonNode now = root.path("current").path("temperature_2m");
return new Forecast(now.isNumber() ? now.asDouble() : null, days);
}
private static LocalDate date(String text) throws IOException {
try {
return LocalDate.parse(text);
} catch (DateTimeException e) {
throw new IOException("Open-Meteo returned a bad date: " + text, e);
}
}
private static Double number(JsonNode daily, String name, int i) {
JsonNode value = daily.path(name).path(i);
return value.isNumber() ? value.asDouble() : null;
}
private static Integer integer(JsonNode daily, String name, int i) {
JsonNode value = daily.path(name).path(i);
return value.isNumber() ? value.asInt() : null;
}
}
@@ -1,35 +0,0 @@
package home.backend.adapters.weather;
import java.util.List;
import java.util.Map;
/**
* Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with
* powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead.
*/
public record WeatherSettings(double latitude, double longitude, List<String> teryt) {
public static WeatherSettings fromEnvironment(Map<String, String> env) {
return new WeatherSettings(
coordinate(env, "WEATHER_LAT", 49.684, 90),
coordinate(env, "WEATHER_LON", 21.770, 180),
List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*")));
}
private static double coordinate(Map<String, String> env, String name, double fallback, double limit) {
String value = env.get(name);
if (value == null || value.isBlank()) {
return fallback;
}
try {
double number = Double.parseDouble(value.strip());
if (Math.abs(number) <= limit) {
return number;
}
} catch (NumberFormatException e) {
// reported below with the same message
}
throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit
+ ", got: " + value);
}
}
@@ -1,17 +0,0 @@
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;
}
@@ -1,70 +0,0 @@
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);
}
}
@@ -1,109 +0,0 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.BatteryView.Energy;
import home.backend.domain.BatteryView.Limits;
import home.backend.domain.BatteryView.Mode;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.BatteryView.Slot;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static home.backend.application.SnapshotFields.on;
import static home.backend.application.SnapshotFields.whole;
/** F7: the Deye's battery now, and how it is set up. */
public class BatteryService {
/** The settings are read every 10 minutes; three missed reads and they are not current. */
private static final Duration SETTINGS_MAX_AGE = Duration.ofMinutes(30);
private static final int SLOTS = 6;
/** The nominal voltage of a 16-cell lithium pack; the Deye reports capacity in Ah only. */
private static final double NOMINAL_VOLTS = 51.2;
private final GatewayClient gateway;
private final GatewayBattery battery;
private final Clock clock;
public BatteryService(GatewayClient gateway, GatewayBattery battery, Clock clock) {
this.gateway = gateway;
this.battery = battery;
this.clock = clock;
}
public BatteryResult changeSchedule(BatteryChange change, String user) throws IOException {
return battery.changeSchedule(change, user);
}
public BatteryResult changeReserve(int pct, String user) throws IOException {
return battery.changeReserve(pct, user);
}
/** Asked on its own: a gateway without battery commands still serves the live view. */
private Reserve reserve() {
try {
return battery.reserve();
} catch (IOException e) {
return null;
}
}
public BatteryView current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live settings = SnapshotFields.live(snapshot, "deye-settings", "deye_settings", clock.instant(),
SETTINGS_MAX_AGE);
Map<String, Double> d = deye.fields();
Map<String, Double> s = settings.fields();
boolean settingsStale = settings.source().stale();
Reserve reserve = reserve();
return new BatteryView(deye.source().time(), deye.source().stale(),
d.get("battery_soc_pct"), d.get("battery_power_w"), d.get("battery_voltage_v"),
d.get("battery_current_a"), d.get("battery_temperature_c"), capacityKwh(s),
new Energy(d.get("battery_charge_today_kwh"), d.get("battery_discharge_today_kwh")),
new Energy(d.get("battery_charge_total_kwh"), d.get("battery_discharge_total_kwh")),
settingsStale ? null : mode(s), settingsStale ? List.of() : schedule(s),
settingsStale ? null : limits(s), settings.source().time(), settingsStale, reserve, reserve != null);
}
private static Double capacityKwh(Map<String, Double> settings) {
Double ah = settings.get("battery_capacity_ah");
return ah == null ? null : Math.round(ah * NOMINAL_VOLTS) / 1000.0;
}
private static Mode mode(Map<String, Double> s) {
boolean tou = on(s, "tou_enabled");
return new Mode(tou, tou ? "Harmonogram: bateria pracuje według przedziałów"
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
}
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first. */
private static List<Slot> schedule(Map<String, Double> s) {
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
String prefix = "prog" + n + "_";
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
return new Slot(time(whole(s, prefix + "start_hhmm")), time(whole(s, next)),
whole(s, prefix + "power_w"), whole(s, prefix + "soc_pct"), on(s, prefix + "grid_charge"));
}).toList();
}
private static String time(int hhmm) {
return "%02d:%02d".formatted(hhmm / 100, hhmm % 100);
}
private static Limits limits(Map<String, Double> s) {
return new Limits(whole(s, "max_charge_current_a"), whole(s, "max_discharge_current_a"),
whole(s, "shutdown_soc_pct"), whole(s, "low_soc_pct"), whole(s, "restart_soc_pct"),
on(s, "grid_charge_enabled"), whole(s, "grid_charge_current_a"),
whole(s, "grid_charge_start_soc_pct"));
}
}
@@ -1,47 +0,0 @@
package home.backend.application;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
/** One remote answer kept for a while; when a refresh fails the older answer is served and marked stale. */
final class Cached<T> {
interface Fetch<T> {
T fetch() throws IOException;
}
record Entry<T>(T value, boolean stale) {
}
private final Duration ttl;
private final Clock clock;
private final Fetch<T> source;
private T value;
private Instant fetchedAt;
Cached(Duration ttl, Clock clock, Fetch<T> source) {
this.ttl = ttl;
this.clock = clock;
this.source = source;
}
/** @throws IOException when the refresh fails and there is nothing older to serve */
synchronized Entry<T> get() throws IOException {
Instant now = clock.instant();
if (value != null && now.isBefore(fetchedAt.plus(ttl))) {
return new Entry<>(value, false);
}
try {
value = source.fetch();
fetchedAt = now;
return new Entry<>(value, false);
} catch (IOException e) {
if (value == null) {
throw e;
}
return new Entry<>(value, true);
}
}
}
@@ -1,96 +0,0 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView.Day;
import home.backend.domain.StatsView.Totals;
import java.time.Duration;
import java.time.LocalDate;
/** What the heat pump did over some span of polls; days are added up into the totals. */
final class DayTally {
private double compressorSeconds;
private double cwuSeconds;
private double coSeconds;
private double heaterSeconds;
private int starts;
private int samples;
private double outdoorSum;
private int outdoorCount;
private Double minCwu;
private Double maxCwu;
void sample(HeatPumpSample s) {
samples++;
if (s.outdoorTemp() != null) {
outdoorSum += s.outdoorTemp();
outdoorCount++;
}
if (s.cwuTemp() != null) {
minCwu = lower(minCwu, s.cwuTemp());
maxCwu = higher(maxCwu, s.cwuTemp());
}
}
/** The outputs of {@code s} held for {@code held}. */
void hold(HeatPumpSample s, Duration held) {
double seconds = held.toMillis() / 1000.0;
compressorSeconds += on(s.compressor()) ? seconds : 0;
cwuSeconds += on(s.cwu()) ? seconds : 0;
coSeconds += on(s.co()) ? seconds : 0;
heaterSeconds += on(s.heater()) ? seconds : 0;
}
void start() {
starts++;
}
void add(DayTally other) {
compressorSeconds += other.compressorSeconds;
cwuSeconds += other.cwuSeconds;
coSeconds += other.coSeconds;
heaterSeconds += other.heaterSeconds;
starts += other.starts;
samples += other.samples;
outdoorSum += other.outdoorSum;
outdoorCount += other.outdoorCount;
if (other.minCwu != null) {
minCwu = lower(minCwu, other.minCwu);
maxCwu = higher(maxCwu, other.maxCwu);
}
}
Day day(LocalDate date) {
if (samples == 0) {
return new Day(date, null, null, null, null, null, null, null, null);
}
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
Totals totals() {
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
}
static boolean on(Double output) {
return output != null && output > 0;
}
private static Double lower(Double known, double value) {
return known == null ? value : Math.min(known, value);
}
private static Double higher(Double known, double value) {
return known == null ? value : Math.max(known, value);
}
private Double avgOutdoor() {
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
}
private static double hours(double seconds) {
return Math.round(seconds / 3600 * 100) / 100.0;
}
}
@@ -1,48 +0,0 @@
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();
}
}
@@ -1,157 +0,0 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyDetails;
import home.backend.domain.EnergyDetails.Device;
import home.backend.domain.EnergyDetails.Group;
import home.backend.domain.EnergyDetails.Item;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
/**
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
* catalog below is the only place that knows a field's label, unit and place; showing a new field
* is one line here.
*/
public class EnergyDetailsService {
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
private record Field(String key, String label, String unit, int digits, double scale) {
}
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
}
/** {@code measurement} is null for the device's own one. */
private record GroupSpec(String title, String measurement, List<Field> fields) {
}
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
private static Field[] field(String key, String label, String unit, int digits) {
return new Field[] {new Field(key, label, unit, digits, 1)};
}
private static Field[] kwhFromWh(String key, String label, int digits) {
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
}
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
private static Field[] phases(String key, String label, String unit, int digits) {
return IntStream.rangeClosed(1, 3)
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
}
private static GroupSpec group(String title, Field[]... parts) {
return groupOf(null, title, parts);
}
/** A group whose fields come from another measurement of the same device. */
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
}
private static final List<Spec> CATALOG = List.of(
new Spec("deye", "Falownik Deye", "deye", List.of(
group("Bateria",
field("battery_soc_pct", "Naładowanie", "%", 0),
field("battery_power_w", "Moc", "W", 0),
field("battery_voltage_v", "Napięcie", "V", 1),
field("battery_current_a", "Prąd", "A", 1),
field("battery_temperature_c", "Temperatura", "°C", 1)),
group("Sieć",
field("grid_power_w", "Moc razem", "W", 0),
phases("grid_power_l%d_w", "Moc", "W", 0),
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
group("Odbiór domu",
field("load_power_w", "Moc razem", "W", 0),
phases("load_power_l%d_w", "Moc", "W", 0)),
group("Panele PV",
field("pv1_power_w", "PV1 moc", "W", 0),
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
field("pv1_current_a", "PV1 prąd", "A", 1),
field("pv2_power_w", "PV2 moc", "W", 0),
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
field("pv2_current_a", "PV2 prąd", "A", 1)),
group("Dziś",
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
field("load_today_kwh", "Zużycie domu", "kWh", 1),
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
group("Od początku",
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
field("load_total_kwh", "Zużycie domu", "kWh", 0),
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
group("Falownik",
field("inverter_power_w", "Moc wyjściowa", "W", 0),
field("inverter_temperature_c", "Temperatura", "°C", 1),
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
group("Produkcja",
kwhFromWh("energy_day_wh", "Dziś", 1),
kwhFromWh("energy_year_wh", "Ten rok", 0),
kwhFromWh("energy_total_wh", "Od początku", 0)))),
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
group("Moc czynna",
field("total_power_active", "Razem", "W", 0),
phases("p%d_power_active", "Moc", "W", 0)),
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
group("Energia od początku",
field("import_energy_active", "Pobrana", "kWh", 0),
field("export_energy_active", "Oddana", "kWh", 0)),
groupOf("house_meter_phases", "Energia na fazę",
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
private final GatewayClient gateway;
private final Clock clock;
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public EnergyDetails current() throws IOException {
GatewaySnapshot snapshot = gateway.snapshot();
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
}
private Device device(Spec spec, GatewaySnapshot snapshot) {
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
List<Group> groups = spec.groups().stream()
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
.filter(g -> !g.items().isEmpty()).toList();
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
}
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
}
private static Group group(GroupSpec spec, Map<String, Double> fields) {
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
return new Group(spec.title(), items);
}
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
private static double scaled(double value, Field f) {
double factor = Math.pow(10, f.digits());
return Math.round(value * f.scale() * factor) / factor;
}
}
@@ -1,57 +0,0 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
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();
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
Map<String, Double> d = deye.fields();
Map<String, Double> f = fronius.fields();
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"));
List<Source> sources = List.of(deye.source(), fronius.source());
return new EnergyView(oldest(sources), sources,
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 static Instant oldest(List<Source> sources) {
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
}
/** 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;
}
}
@@ -1,11 +0,0 @@
package home.backend.application;
import home.backend.domain.Forecast;
import java.io.IOException;
/** The weather forecast service: the past 14 days and the next 3. */
public interface ForecastSource {
Forecast forecast() throws IOException;
}
@@ -1,19 +0,0 @@
package home.backend.application;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import java.io.IOException;
/** Battery settings through the gateway, which checks every change (ADR-013). */
public interface GatewayBattery {
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
Reserve reserve() throws IOException;
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
}
@@ -1,11 +0,0 @@
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;
}
@@ -1,23 +0,0 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.RestoreResult;
import home.backend.domain.SetpointState;
import java.io.IOException;
/** The only way the backend writes anything: through the gateway, which checks every command. */
public interface GatewayCommands {
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
CommandResult apply(Command command) throws IOException;
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
SetpointState setpoints() throws IOException;
/** Keeps the gateway's live restores alive; returns how many it extended. */
int renewLeases() throws IOException;
}
@@ -1,38 +0,0 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
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;
private final String version;
public HealthService(GatewayClient gateway, Clock clock, String version) {
this.gateway = gateway;
this.clock = clock;
this.version = version;
}
public HealthView current() {
try {
GatewaySnapshot snapshot = gateway.snapshot();
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of(), version, null);
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -1,13 +0,0 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
/** The heat pump polls stored in InfluxDB, oldest first. */
public interface HeatPumpSamples {
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
}
@@ -1,87 +0,0 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot;
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 java.util.Optional;
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 {
GatewaySnapshot snapshot = gateway.snapshot();
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
.filter(m -> DATABASE.equals(m.database()));
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
return view(reading.map(Measurement::time).orElse(null), stale,
reading.map(Measurement::fields).orElse(Map.of()));
}
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
Integer alarmCode = whole(f, "alarm_code");
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
on(f, "output_ground"), on(f, "output_cwu")),
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
tenths(f, "outdoor_avg_temp")),
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
tenths(f, "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")));
}
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
private static String alarmText(Integer code) {
if (code == null || code == 0) {
return null;
}
return switch (code) {
case 2 -> "Usterka czujnika temperatury zewnętrznej";
case 3 -> "Usterka czujnika temperatury CO";
case 4 -> "Usterka czujnika temperatury wewnętrznej";
case 5 -> "Usterka czujnika temperatury CWU";
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
case 15 -> "Usterka czujnika temperatury dolnego źródła";
case 1003 -> "Zadziałał presostat (ciśnienie)";
default -> "Kod usterki " + code;
};
}
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
private static Double tenths(Map<String, Double> fields, String name) {
Double value = fields.get(name);
return value == null ? null : Math.round(value * 10) / 10.0;
}
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];
}
}
@@ -1,14 +0,0 @@
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;
}
@@ -1,43 +0,0 @@
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));
}
}
@@ -1,53 +0,0 @@
package home.backend.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
*/
public class LeaseRenewer implements AutoCloseable {
/** A third of the gateway's lease, so two renewals in a row may fail. */
public static final Duration PERIOD = Duration.ofMinutes(5);
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
private final GatewayCommands gateway;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("lease-renewal").factory());
public LeaseRenewer(GatewayCommands gateway) {
this.gateway = gateway;
}
public void start() {
start(PERIOD);
}
void start(Duration period) {
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
}
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
void renew() {
try {
int renewed = gateway.renewLeases();
LOGGER.debug("renewed {} lease(s)", renewed);
} catch (IOException | RuntimeException e) {
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
}
}
@Override
public void close() {
scheduler.shutdownNow();
}
}
@@ -1,27 +0,0 @@
package home.backend.application;
import home.backend.application.SnapshotFields.Live;
import home.backend.domain.PiSensorView;
import java.io.IOException;
import java.time.Clock;
import java.util.Map;
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
public class PiSensorService {
private final GatewayClient gateway;
private final Clock clock;
public PiSensorService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public PiSensorView current() throws IOException {
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
Map<String, Double> f = live.fields();
return new PiSensorView(live.source().time(), live.source().stale(),
f.get("temperature"), f.get("humidity"), f.get("pressure"));
}
}
@@ -1,12 +0,0 @@
package home.backend.application;
import java.io.IOException;
import java.time.LocalDate;
import java.util.Map;
/** What the panels produced, from InfluxDB. */
public interface PvEnergyRepository {
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
}
@@ -1,152 +0,0 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
import home.backend.domain.PresetResult;
import home.backend.domain.PresetResult.ChangeResult;
import home.backend.domain.PresetResult.ChangeStatus;
import home.backend.domain.RestoreResult;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
public class SetpointService {
private final GatewayCommands gateway;
private final List<Preset> presets;
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
this.gateway = gateway;
this.presets = List.copyOf(presets);
}
public SetpointsView current() throws IOException {
SetpointState state = gateway.setpoints();
return new SetpointsView(state.setpoints(), state.restores(), presets);
}
/** @param hours how long to keep the value, null until changed back */
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
return gateway.apply(new Command(setpoint, value, duration(hours), user));
}
/** Puts the value from before the pending change back now, instead of waiting for its end. */
public RestoreResult restore(String setpoint, String user) throws IOException {
return gateway.restore(setpoint, user);
}
/**
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
*
* @param until the end the user chose; required by a held preset, not allowed for any other
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
* preset's value
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
* for a setpoint the preset does not change or outside the gateway's limits
*/
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
if (preset.get().isHeld() != (until != null)) {
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
: "preset " + id + " has no end time to choose");
}
List<Change> changes = withValues(preset.get().changes(), values);
List<Setpoint> limits = gateway.setpoints().setpoints();
requireWithinLimits(values, limits);
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, until, user)));
}
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
if (!own.containsAll(values.keySet())) {
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
}
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
c.hours())).toList();
}
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
for (Setpoint limit : limits) {
Integer value = values.get(limit.name());
if (value != null && !limit.allows(value)) {
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
+ " in steps of " + limit.step());
}
}
}
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
List<ChangeResult> results = new ArrayList<>();
for (Change change : changes) {
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
}
return results;
}
private ChangeResult attempt(Change change, Instant until, String user) {
try {
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
: new Command(change.setpoint(), change.value(), null, until, user);
return ChangeResult.of(gateway.apply(command));
} catch (IOException e) {
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
}
}
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
String problem = problem(change, held, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, boolean held, List<Setpoint> limits) {
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
if (known.isEmpty()) {
return "preset sets " + change.setpoint() + ", which the gateway does not know";
}
if (!known.get().allows(change.value())) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
}
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
if (held && known.get().value() != null && change.value() >= known.get().value()) {
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
+ known.get().value();
}
return null;
}
private static Duration duration(Double hours) {
if (hours == null) {
return null;
}
long minutes = Math.round(hours * 60);
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
}
return Duration.ofMinutes(minutes);
}
}
@@ -1,71 +0,0 @@
package home.backend.application;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.time.Duration;
import java.time.Instant;
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() {
}
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
private static final Duration MAX_AGE = Duration.ofSeconds(600);
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
}
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
return stale(device, reading, now, MAX_AGE);
}
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
Duration maxAge) {
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
}
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
record Live(Source source, Map<String, Double> fields) {
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
return live(snapshot, device, measurement, now, MAX_AGE);
}
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
Optional<Measurement> reading = find(snapshot, device, measurement);
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
return new Live(source, stale ? Map.of() : reading.get().fields());
}
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;
}
}
@@ -1,89 +0,0 @@
package home.backend.application;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.StatsView;
import home.backend.domain.StatsView.Day;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
*/
public class StatsService {
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
static final Duration MAX_HOLD = Duration.ofSeconds(600);
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
private final HeatPumpSamples repository;
private final Clock clock;
public StatsService(HeatPumpSamples repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
/**
* @param range {@code 7d} or {@code 30d}
* @throws IllegalArgumentException for an unknown range
*/
public StatsView stats(String range) throws IOException {
Integer length = range == null ? null : RANGE_DAYS.get(range);
if (length == null) {
throw new IllegalArgumentException("unknown range: " + range);
}
Instant now = clock.instant();
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
Map<LocalDate, DayTally> tallies = new TreeMap<>();
for (int i = 0; i < length; i++) {
tallies.put(first.plusDays(i), new DayTally());
}
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
DayTally all = new DayTally();
List<Day> days = new ArrayList<>();
tallies.forEach((date, tally) -> {
days.add(tally.day(date));
all.add(tally);
});
return new StatsView(range, days, all.totals());
}
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
HeatPumpSample previous = null;
for (int i = 0; i < samples.size(); i++) {
HeatPumpSample s = samples.get(i);
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
day.sample(s);
if (previous != null && previous.compressor() != null && previous.compressor() == 0
&& DayTally.on(s.compressor())) {
day.start();
}
previous = s;
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
}
}
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
Instant at = s.t();
while (at.isBefore(end)) {
LocalDate date = at.atZone(ZONE).toLocalDate();
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
Instant stop = midnight.isBefore(end) ? midnight : end;
tallies.get(date).hold(s, Duration.between(at, stop));
at = stop;
}
}
}
@@ -1,12 +0,0 @@
package home.backend.application;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.util.List;
/** Weather warnings that cover the house, whether in force now or announced. */
public interface WarningSource {
List<Warning> warnings() throws IOException;
}
@@ -1,98 +0,0 @@
package home.backend.application;
import home.backend.application.Cached.Entry;
import home.backend.domain.Forecast;
import home.backend.domain.Forecast.DailyForecast;
import home.backend.domain.PvCalibration;
import home.backend.domain.WeatherCodes;
import home.backend.domain.WeatherView;
import home.backend.domain.WeatherView.Day;
import home.backend.domain.WeatherView.Now;
import home.backend.domain.WeatherView.Warning;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
public class WeatherService {
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
private static final int CALIBRATION_DAYS = 14;
private record Calibrated(Forecast forecast, Double factor) {
}
private final Clock clock;
private final PvEnergyRepository pv;
private final Cached<Calibrated> forecast;
private final Cached<List<Warning>> warnings;
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
Clock clock) {
this.clock = clock;
this.pv = pv;
this.forecast = new Cached<>(Duration.ofMinutes(30), clock, () -> calibrated(forecastSource.forecast()));
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
}
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
public WeatherView current() throws IOException {
Entry<Calibrated> weather = forecast.get();
Entry<List<Warning>> warned = warnings();
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Calibrated w = weather.value();
return new WeatherView(clock.instant(),
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
weather.stale() || warned.stale());
}
private Entry<List<Warning>> warnings() {
try {
return warnings.get();
} catch (IOException e) {
return new Entry<>(List.of(), true);
}
}
private List<Warning> inForce(List<Warning> all) {
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
}
private Calibrated calibrated(Forecast fetched) {
LocalDate today = LocalDate.now(clock.withZone(ZONE));
Map<LocalDate, Double> radiation = new HashMap<>();
fetched.days().forEach((date, day) -> {
if (day.radiationMj() != null) {
radiation.put(date, day.radiationMj());
}
});
try {
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
} catch (IOException e) {
return new Calibrated(fetched, null);
}
}
private static Day day(Calibrated w, LocalDate date) throws IOException {
DailyForecast d = w.forecast().days().get(date);
if (d == null) {
throw new IOException("forecast has no " + date);
}
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
d.precipitationMm(), sunshine, kwh);
}
private static double round(double value) {
return Math.round(value * 10) / 10.0;
}
}
@@ -1,14 +0,0 @@
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";
}
@@ -1,7 +0,0 @@
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) {
}
@@ -1,19 +0,0 @@
package home.backend.domain;
import java.time.LocalTime;
import java.util.List;
/**
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
* The gateway checks every rule (ADR-013).
*/
public record BatteryChange(Boolean enabled, List<Slot> slots) {
public BatteryChange {
slots = slots == null ? List.of() : List.copyOf(slots);
}
/** {@code from} is the slot's start, to the minute. */
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
}
}
@@ -1,13 +0,0 @@
package home.backend.domain;
import java.util.Map;
/**
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
}
@@ -1,39 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
* changes; they are null and false when it does not.
*/
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
boolean editable) {
/** The share of the battery kept for a power cut, and the lowest the user may set. */
public record Reserve(int pct, int floorPct) {
}
/** Battery energy in kWh. */
public record Energy(Double chargeKwh, Double dischargeKwh) {
}
/** Time of use on means the battery follows the schedule; off means backup only. */
public record Mode(boolean touEnabled, String description) {
}
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
}
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
Integer gridChargeStartSocPct) {
}
}
@@ -1,19 +0,0 @@
package home.backend.domain;
import java.time.Duration;
import java.time.Instant;
/**
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
* while the backend renews the lease (ADR-011).
*/
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
public Command(String setpoint, int value, Duration duration, String requestedBy) {
this(setpoint, value, duration, null, requestedBy);
}
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
public static final int MAX_HOURS = 72;
}
@@ -1,10 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/** The gateway's verdict on a command, passed on to the phone unchanged. */
public record CommandResult(Status status, String message, Instant restoreAt) {
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
public enum Status { APPLIED, REJECTED, FAILED }
}
@@ -1,22 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* Every value the energy devices report, grouped for a details panel (F7). A device without a
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
*/
public record EnergyDetails(List<Device> devices) {
/** One device; {@code id} is the gateway's device name. */
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
}
public record Group(String title, List<Item> items) {
}
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
public record Item(String label, double value, String unit, int digits) {
}
}
@@ -1,21 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
*/
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
/** One inverter; time is null and stale true when the gateway has no reading of it. */
public record Source(String name, Instant time, boolean stale) {
}
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
}
}
@@ -1,13 +0,0 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
Double radiationMj, Double sunshineSeconds) {
}
}
@@ -1,18 +0,0 @@
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.
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
public record GatewaySnapshot(String version, 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) {
}
}
@@ -1,13 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
* The gateway's version is null when it is unreachable or too old to report it. */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
String backendVersion, String gatewayVersion) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -1,8 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
Double outdoorTemp, Double cwuTemp) {
}
@@ -1,30 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/**
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
*/
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
boolean alarm, Integer alarmCode, String alarmText, 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) {
}
}
@@ -1,11 +0,0 @@
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) {
}
}
@@ -1,11 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/**
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
* without any reading.
*/
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
}
@@ -1,50 +0,0 @@
package home.backend.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/**
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
* then without renewals (ADR-012).
*/
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
List<Change> changes) {
public static final String CHOSEN = "chosen";
/** {@code hours} null means "until changed back". */
public record Change(String setpoint, int value, Double hours) {
public Change {
if (setpoint == null || setpoint.isBlank()) {
throw new IllegalArgumentException("a preset change needs a setpoint");
}
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
}
}
}
@JsonIgnore
public boolean isHeld() {
return CHOSEN.equals(until);
}
public Preset {
if (id == null || id.isBlank() || name == null || name.isBlank()) {
throw new IllegalArgumentException("a preset needs an id and a name");
}
if (changes == null || changes.isEmpty()) {
throw new IllegalArgumentException("preset " + id + " has no changes");
}
if (until != null && !until.equals(CHOSEN)) {
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
}
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
}
}
}
@@ -1,44 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/**
* What happened to each change of a preset, one entry per change in preset order. Changes run in
* order and stop at the first that is not applied; the rest are NOT_ATTEMPTED.
*
* <p>{@code status}: APPLIED when every change was; PARTIAL when some were and some were not;
* otherwise FAILED if any change failed or went unanswered (the pump may have changed), else
* REJECTED (nothing was written).
*/
public record PresetResult(Status status, List<ChangeResult> results) {
public enum Status { APPLIED, REJECTED, FAILED, PARTIAL }
/** UNKNOWN: the gateway did not answer, so the change may or may not have reached the pump. */
public enum ChangeStatus { APPLIED, REJECTED, FAILED, NOT_ATTEMPTED, UNKNOWN }
public record ChangeResult(ChangeStatus status, String message, Instant restoreAt) {
public static ChangeResult of(CommandResult verdict) {
return new ChangeResult(ChangeStatus.valueOf(verdict.status().name()), verdict.message(),
verdict.restoreAt());
}
public static ChangeResult notAttempted() {
return new ChangeResult(ChangeStatus.NOT_ATTEMPTED, null, null);
}
}
public static PresetResult of(List<ChangeResult> results) {
boolean applied = results.stream().anyMatch(r -> r.status() == ChangeStatus.APPLIED);
boolean all = results.stream().allMatch(r -> r.status() == ChangeStatus.APPLIED);
boolean maybeWritten = results.stream()
.anyMatch(r -> r.status() == ChangeStatus.FAILED || r.status() == ChangeStatus.UNKNOWN);
Status status = all ? Status.APPLIED
: applied ? Status.PARTIAL
: maybeWritten ? Status.FAILED
: Status.REJECTED;
return new PresetResult(status, results);
}
}
@@ -1,32 +0,0 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.Map;
/**
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
* measured kWh over forecast MJ/m², summed over the days that have both.
*/
public final class PvCalibration {
static final int MIN_DAYS = 5;
private PvCalibration() {
}
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
double kwh = 0;
double mj = 0;
int days = 0;
for (var day : measuredKwh.entrySet()) {
Double radiation = radiationMj.get(day.getKey());
if (radiation != null) {
kwh += day.getValue();
mj += radiation;
days++;
}
}
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
}
}
@@ -1,10 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/**
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
*/
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -1,11 +0,0 @@
package home.backend.domain;
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
public record RestoreResult(Outcome outcome, String message) {
/**
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
*/
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
}
@@ -1,39 +0,0 @@
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();
}
}
@@ -1,9 +0,0 @@
package home.backend.domain;
/** One of the four writable setpoints as the gateway reports it; {@code value} is null until it has been read. */
public record Setpoint(String name, int register, int min, int max, int step, Integer value) {
public boolean allows(int candidate) {
return candidate >= min && candidate <= max && (candidate - min) % step == 0;
}
}
@@ -1,7 +0,0 @@
package home.backend.domain;
import java.util.List;
/** What the gateway knows about the setpoints: their limits, values and the pending restores. */
public record SetpointState(List<Setpoint> setpoints, List<Restore> restores) {
}
@@ -1,7 +0,0 @@
package home.backend.domain;
import java.util.List;
/** The setpoints screen: gateway state plus the presets from the backend config. */
public record SetpointsView(List<Setpoint> setpoints, List<Restore> restores, List<Preset> presets) {
}
@@ -1,19 +0,0 @@
package home.backend.domain;
import java.time.LocalDate;
import java.util.List;
/**
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
* between polls, so short cycles are missed. A day without polls has null values.
*/
public record StatsView(String range, List<Day> days, Totals totals) {
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
}
}
@@ -1,37 +0,0 @@
package home.backend.domain;
/** WMO weather interpretation codes as Polish text. */
public final class WeatherCodes {
private WeatherCodes() {
}
public static String describe(Integer code) {
if (code == null) {
return "Brak danych";
}
return switch (code) {
case 0 -> "Bezchmurnie";
case 1 -> "Przeważnie słonecznie";
case 2 -> "Częściowe zachmurzenie";
case 3 -> "Pochmurno";
case 45, 48 -> "Mgła";
case 51, 53, 55 -> "Mżawka";
case 56, 57 -> "Marznąca mżawka";
case 61 -> "Słaby deszcz";
case 63 -> "Deszcz";
case 65 -> "Silny deszcz";
case 66, 67 -> "Marznący deszcz";
case 71 -> "Słaby śnieg";
case 73 -> "Śnieg";
case 75 -> "Intensywny śnieg";
case 77 -> "Ziarna śnieżne";
case 80, 81 -> "Przelotny deszcz";
case 82 -> "Ulewa";
case 85, 86 -> "Przelotny śnieg";
case 95 -> "Burza";
case 96, 99 -> "Burza z gradem";
default -> "Brak danych";
};
}
}
@@ -1,27 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
* because a source could not be reached.
*/
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
List<Warning> warnings, Double pvFactor, boolean stale) {
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
}
public record Now(Double temperature) {
}
/** {@code level} is IMGW's degree 1 to 3. */
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
}
}
@@ -1,16 +0,0 @@
[
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
},
{
"id": "away",
"name": "Wyjazd",
"until": "chosen",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
@@ -1,312 +0,0 @@
package home.backend;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.adapters.http.ApiServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
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.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
class BackendTest {
private static final ObjectMapper JSON = new ObjectMapper();
/** Just after the readings in the snapshot fixture, so they count as fresh. */
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
private final HttpClient client = HttpClient.newHttpClient();
private HttpServer gateway;
private HttpServer influx;
private ApiServer backend;
@BeforeEach
void start() throws IOException {
gateway = stub(Map.of(
"/health", "src/test/resources/gateway/health-all-up.json",
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
gateway.createContext("/commands", this::commands);
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
"s3cret-token", null);
backend = Backend.assemble(settings, CLOCK);
backend.start();
}
private record Command(String authorization, String requestedBy, String body) {
}
private final List<Command> commandsSeen = new ArrayList<>();
/** The gateway's /commands, answering with the gateway's own contract examples. */
private void commands(HttpExchange exchange) throws IOException {
boolean post = exchange.getRequestMethod().equals("POST");
if (post) {
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
}
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
+ (post ? "response-applied.json" : "setpoints.json")));
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
private HttpResponse<String> post(String path, String body) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
.header("Remote-User", "anna").header("Content-Type", "application/json")
.header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
@AfterEach
void stop() {
backend.stop();
gateway.stop(0);
influx.stop(0);
}
private static HttpServer stub(Map<String, String> bodies) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
byte[] body = Files.readAllBytes(Path.of(file));
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}));
server.start();
return server;
}
private static URI url(HttpServer server) {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
}
private HttpResponse<String> get(String path) throws Exception {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
}
/** The names of every field, nested ones as a.b; array elements are not descended into. */
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
node.fieldNames().forEachRemaining(name -> {
names.add(prefix + name);
fieldNames(node.get(name), prefix + name + ".", names);
});
return names;
}
private void assertServesApiShapeOf(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
assertEquals(fieldNames(expected, "", new TreeSet<>()),
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
}
/** The body equals the documented example, value by value. */
private void assertServesExactly(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode(), path);
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
JSON.readTree(response.body()), path);
}
@Test
void servesHeatPumpFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/heat-pump", "heat-pump");
}
@Test
void servesPiSensorFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/pi-sensor", "pi-sensor");
}
@Test
void servesEnergyFromTheGatewayReadings() throws Exception {
assertServesExactly("/api/energy", "energy");
}
@Test
void servesHealthOfEveryGatewayDevice() throws Exception {
assertServesApiShapeOf("/api/health", "health");
JsonNode health = JSON.readTree(get("/api/health").body());
assertTrue(health.get("gatewayReachable").asBoolean());
List<String> names = new ArrayList<>();
health.get("devices").forEach(device -> {
names.add(device.get("name").asText());
assertTrue(device.get("up").asBoolean(), names.getLast());
});
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
}
@Test
void servesHistoryFromInflux() throws Exception {
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
assertFalse(points.isEmpty());
}
@Test
void servesSetpointsWithTheBuiltInPresets() throws Exception {
JsonNode view = JSON.readTree(get("/api/setpoints").body());
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
}
@Test
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
var response = post("/api/setpoints",
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
assertEquals(200, response.statusCode());
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
JSON.readTree(response.body()));
assertEquals(1, commandsSeen.size());
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
assertEquals("anna", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
JSON.readTree(commandsSeen.getFirst().body()));
}
@Test
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
Path file = Files.writeString(dir.resolve("presets.json"),
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
custom.start();
try {
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
.POST(HttpRequest.BodyPublishers.noBody()).build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertEquals("phone", commandsSeen.getFirst().requestedBy());
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
JSON.readTree(commandsSeen.getFirst().body()));
} finally {
custom.stop();
}
}
@Test
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
Duration.ofSeconds(2), null, null), CLOCK);
noToken.start();
try {
var base = "http://127.0.0.1:" + noToken.port();
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
HttpResponse.BodyHandlers.ofString()).statusCode());
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
HttpResponse.BodyHandlers.ofString()).statusCode());
assertTrue(commandsSeen.isEmpty());
} finally {
noToken.stop();
}
}
@Test
void settingsNeedBothUrls() {
var e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
assertTrue(e.getMessage().contains("GATEWAY_URL"));
e = assertThrows(IllegalArgumentException.class,
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
assertTrue(e.getMessage().contains("INFLUX_URL"));
}
@Test
void urlsMayEndWithASingleSlash() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
}
@Test
void settingsHaveDefaults() {
var settings = Settings.fromEnvironment(
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
assertEquals(8080, settings.port());
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
}
@Test
void tokenAndPresetsFileAreOptional() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
var none = Settings.fromEnvironment(urls);
var env = new HashMap<>(urls);
env.put("GATEWAY_TOKEN", " s3cret-token ");
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
var set = Settings.fromEnvironment(env);
env.put("GATEWAY_TOKEN", " ");
env.put("PRESETS_FILE", "");
assertEquals(null, none.gatewayToken());
assertEquals(null, none.presetsFile());
assertEquals("s3cret-token", set.gatewayToken());
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
}
@Test
void settingsRejectBadValues() {
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
for (Map<String, String> bad : List.of(
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
}
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
var env = new HashMap<>(urls);
env.put(name, value);
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
assertTrue(e.getMessage().contains(name));
}
}
}
}
@@ -1,68 +0,0 @@
package home.backend.adapters.config;
import home.backend.domain.Preset;
import home.backend.domain.Preset.Change;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PresetFileTest {
@TempDir
Path dir;
private Path file(String json) throws IOException {
return Files.writeString(dir.resolve("presets.json"), json);
}
@Test
void defaultsAreBoostAndAway() {
var presets = PresetFile.load(null);
assertEquals(List.of(
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
new Change("co_comfort", 15, null)))), presets);
}
@Test
void fileReplacesTheDefaults() throws Exception {
var presets = PresetFile.load(file("""
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
"""));
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
}
@Test
void unusableFilesAreRefusedWithTheirName() throws Exception {
for (String json : List.of(
"not json",
"{}",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
Path file = file(json);
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
}
}
@Test
void missingFileIsRefused() {
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
}
}
@@ -1,187 +0,0 @@
package home.backend.adapters.gateway;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
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.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpTimeoutException;
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.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 GatewayHttpClientTest {
private HttpServer server;
private GatewayHttpClient client;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofMillis(500));
}
@AfterEach
void stopServer() {
server.stop(0);
}
private void serve(String path, int status, String body) {
server.createContext(path, exchange -> {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
}
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
serve("/health", healthStatus, resource(healthFile));
serve("/snapshot", 200, resource(snapshotFile));
}
private static String resource(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/gateway", name));
}
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
}
@Test
void mapsEveryDeviceWithItsMeasurements() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
var devices = client.snapshot().devices();
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
devices.stream().map(DeviceSnapshot::name).toList());
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
DeviceSnapshot pump = device(devices, "heat-pump");
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
assertNull(pump.lastError());
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
var measurement = pump.measurements().get(1);
assertEquals("Dom_Pompa", measurement.database());
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
}
@Test
void readsTheGatewayVersion() throws Exception {
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
assertEquals("v1.2.1", client.snapshot().version());
}
@Test
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
assertNull(client.snapshot().version());
}
@Test
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
var devices = client.snapshot().devices();
assertTrue(device(devices, "heat-pump").up());
DeviceSnapshot deye = device(devices, "deye");
assertFalse(deye.up());
assertEquals("Connect timed out", deye.lastError());
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
// The last good reading stays available, with its own time.
var last = deye.measurements().get(0);
assertEquals(570.0, last.fields().get("load_power_w"));
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
}
@Test
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
var devices = client.snapshot().devices();
var pump = device(devices, "heat-pump");
assertFalse(pump.up());
assertNull(pump.lastSuccess());
assertEquals("Connection refused", pump.lastError());
assertTrue(pump.measurements().isEmpty());
assertTrue(device(devices, "deye").up());
}
@Test
void ignoresPropertiesItDoesNotKnow() throws Exception {
serve("/health", 200, """
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
serve("/snapshot", 200, """
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
}
@Test
void failsWhenSnapshotAnswers503() throws Exception {
serve("/health", 200, resource("health-all-up.json"));
serve("/snapshot", 503, "[]");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnServerError() {
serve("/health", 500, "{\"error\":\"boom\"}");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsOnBadJson() {
serve("/health", 200, "<html>not json</html>");
assertThrows(IOException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
server.createContext("/health", exchange -> {
try {
Thread.sleep(2000);
} catch (InterruptedException ignored) {
// the client gave up; nothing to answer
}
exchange.close();
});
assertThrows(HttpTimeoutException.class, client::snapshot);
}
@Test
void failsWhenTheGatewayIsUnreachable() {
server.stop(0);
assertThrows(IOException.class, client::snapshot);
}
}
@@ -1,348 +0,0 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult.Outcome;
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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
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;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
class GatewayHttpCommandsTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
}
private final List<Seen> seen = new ArrayList<>();
private HttpServer server;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
answerCommands();
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
private int status;
private String body = "{}";
private void serve(int status, String body) {
this.status = status;
this.body = body;
}
private void answerCommands() {
for (String path : List.of("/commands", "/battery")) {
server.createContext(path, this::answer);
}
}
private void answer(HttpExchange exchange) throws IOException {
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Requested-By"),
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
private GatewayHttpCommands commands(String token) {
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
Duration.ofMillis(500));
}
private static String contract(String name) throws IOException {
return Files.readString(CONTRACT.resolve(name));
}
@Test
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
serve(200, contract("response-applied.json"));
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
assertEquals(Status.APPLIED, result.status());
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
assertEquals(1, seen.size());
assertEquals("POST", seen.getFirst().method());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
}
@Test
void aHeldChangeSendsTheEndAsUntil() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
}
@Test
void awayHasNoMinutes() throws Exception {
serve(200, contract("response-applied.json"));
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
}
@Test
void rejectionIsAVerdictNotAnError() throws Exception {
serve(422, contract("response-rejected.json"));
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
assertEquals(Status.REJECTED, result.status());
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
assertNull(result.restoreAt());
}
@Test
void failedWriteIsAVerdict() throws Exception {
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
}
@Test
void readsSetpointsWithRestores() throws Exception {
serve(200, contract("setpoints.json"));
var state = commands("s3cret-token").setpoints();
assertEquals("GET", seen.getFirst().method());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals(4, state.setpoints().size());
assertEquals(48, state.setpoints().get(2).max());
assertEquals(1, state.restores().size());
assertEquals(8, state.restores().getFirst().originalValue());
}
@Test
void anUntilChangedRestoreHasNoDue() throws Exception {
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
}
@Test
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
serve(200, contract("response-restored.json"));
var result = commands("s3cret-token").restore("cwu_economy", "anna");
assertEquals(Outcome.RESTORED, result.outcome());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/restore", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
}
@Test
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
serve(409, contract("response-restore-conflict.json"));
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
@Test
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
}
}
@Test
void restoreFailsWithoutAVerdictOrToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
serve(200, "{\"renewed\":2}");
assertEquals(2, commands("s3cret-token").renewLeases());
assertEquals("POST", seen.getFirst().method());
assertEquals("/commands/renew", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void renewFailsOnAnyOtherStatusOrWithoutToken() {
serve(503, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).renewLeases());
assertEquals(seenBefore, seen.size());
}
@Test
void readsTheBatteryReserveWithTheToken() throws Exception {
serve(200, contract("battery-reserve.json"));
var reserve = commands("s3cret-token").reserve();
assertEquals(new BatteryView.Reserve(50, 30), reserve);
assertEquals("GET", seen.getFirst().method());
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
}
@Test
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
serve(200, contract("response-battery-applied.json"));
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null)));
var result = commands("s3cret-token").changeSchedule(change, "anna");
assertEquals(BatteryResult.Status.APPLIED, result.status());
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
assertEquals("POST", seen.getFirst().method());
assertEquals("/battery/schedule", seen.getFirst().path());
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
assertEquals("anna", seen.getFirst().requestedBy());
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
}
@Test
void sendsAReserveChange() throws Exception {
serve(200, contract("response-battery-applied.json"));
commands("s3cret-token").changeReserve(60, "anna");
assertEquals("/battery/reserve", seen.getFirst().path());
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
}
@Test
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
for (var entry : expected.entrySet()) {
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
}
}
@Test
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
serve(502, contract("response-battery-failed.json"));
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
assertEquals(BatteryResult.Status.FAILED, result.status());
assertEquals(java.util.Map.of(167, 80), result.read());
}
@Test
void batteryCallsFailWithoutAVerdictOrToken() {
for (int status : List.of(400, 401, 404, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
}
var seenBefore = seen.size();
assertThrows(IOException.class, () -> commands(null).reserve());
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
assertEquals(seenBefore, seen.size());
}
@Test
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
+ "\"value\":null}],\"restores\":[]}");
assertNull(commands("s3cret-token").setpoints().setpoints().getFirst().value());
}
@Test
void otherStatusesAreNoVerdict() throws Exception {
for (int status : List.of(401, 404, 500, 503)) {
serve(status, "{\"error\":\"x\"}");
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints(), "GET " + status);
assertThrows(IOException.class,
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")), "POST " + status);
}
}
@Test
void unreadableAnswerIsNoVerdict() {
serve(200, "<html>proxy</html>");
assertThrows(IOException.class,
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")));
}
@Test
void withoutTokenNothingIsSent() {
serve(200, "{}");
var commands = commands(null);
assertThrows(IOException.class, () -> commands.setpoints());
assertThrows(IOException.class, () -> commands.apply(new Command("cwu_economy", 45, null, "phone")));
assertTrue(seen.isEmpty());
}
@Test
void unreachableGatewayIsAnIOException() {
server.stop(0);
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints());
}
/** A contract file by name, or JSON text as is, parsed so that formatting does not matter. */
private static JsonNode contractJson(String nameOrJson) throws IOException {
return JSON.readTree(nameOrJson.endsWith(".json") ? contract(nameOrJson) : nameOrJson);
}
}
@@ -1,945 +0,0 @@
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;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayBattery;
import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService;
import home.backend.domain.BatteryChange;
import home.backend.domain.BatteryResult;
import home.backend.domain.BatteryView.Reserve;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.RestoreResult;
import home.backend.domain.RestoreResult.Outcome;
import home.backend.domain.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryRepository;
import home.backend.application.HeatPumpSamples;
import home.backend.application.HistoryService;
import home.backend.application.StatsService;
import home.backend.application.PiSensorService;
import home.backend.application.WeatherService;
import home.backend.domain.Forecast;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.WeatherView.Warning;
import home.backend.domain.HeatPumpSample;
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.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
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 boolean auditDown;
private HeatPumpSamples samples;
private RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private Outcome restoreOutcome;
private final List<String> restored = new ArrayList<>();
private boolean commandsDown;
private boolean batteryDown;
private BatteryResult.Status batteryVerdict;
private final List<String> batterySent = new ArrayList<>();
private int answersBeforeDrop;
private ApiServer server;
@BeforeEach
void start() throws IOException {
snapshot = exampleSnapshot();
GatewayClient gateway = () -> {
if (gatewayDown) {
throw new IOException("tunnel down");
}
if (gatewayBug != null) {
throw gatewayBug;
}
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));
};
samples = (from, to) -> {
if (historyDown) {
throw new IOException("influx down");
}
return List.of(
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
};
verdict = Status.APPLIED;
restoreOutcome = Outcome.RESTORED;
answersBeforeDrop = Integer.MAX_VALUE;
GatewayCommands commands = new GatewayCommands() {
@Override
public CommandResult apply(Command command) throws IOException {
if (commandsDown || sent.size() >= answersBeforeDrop) {
throw new IOException("tunnel down");
}
sent.add(command);
return switch (verdict) {
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
};
}
@Override
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
restored.add(setpoint + " by " + requestedBy);
return new RestoreResult(restoreOutcome, "restored to 8");
}
@Override
public SetpointState setpoints() throws IOException {
if (commandsDown) {
throw new IOException("tunnel down");
}
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
}
@Override
public int renewLeases() {
return 0;
}
};
batteryDown = false;
batteryVerdict = BatteryResult.Status.APPLIED;
GatewayBattery batteryCommands = new GatewayBattery() {
@Override
public Reserve reserve() throws IOException {
if (batteryDown) {
throw new IOException("gateway /battery/reserve answered 404");
}
return new Reserve(50, 30);
}
@Override
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
return answer("schedule " + change + " by " + requestedBy);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
return answer("reserve " + pct + " by " + requestedBy);
}
private BatteryResult answer(String what) throws IOException {
if (batteryDown) {
throw new IOException("tunnel down");
}
batterySent.add(what);
return switch (batteryVerdict) {
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
case FAILED -> new BatteryResult(batteryVerdict,
"registers not as written: [167]; not retried", Map.of(167, 80));
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
};
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
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 AuditService(auditRows(), clock), new StatsService(samples, clock),
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
(from, to) -> exampleMeasuredPv(), clock));
server.start();
}
@AfterEach
void stop() {
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,
"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 = new HashMap<>(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));
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
return new GatewaySnapshot("v1.2.1", List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
new DeviceSnapshot("deye", true, energyTime, null,
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))),
new DeviceSnapshot("house-meter", true, energyTime, null,
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
new DeviceSnapshot("deye-settings", true, energyTime, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
new DeviceSnapshot("bme280", true, energyTime, null,
List.of(new Measurement("measurement", "MEASUREMENT",
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
}
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
private static Map<String, Double> deyeSettings() {
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
double[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 1; slot <= 6; slot++) {
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
settings.put("prog" + slot + "_power_w", 10000.0);
settings.put("prog" + slot + "_soc_pct", 80.0);
settings.put("prog" + slot + "_grid_charge", 0.0);
}
return settings;
}
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 String awayRequest() throws IOException {
return example("preset-away-request").toString();
}
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 energyDetailsMatchExample() throws Exception {
assertMatchesExample("/api/energy/details", "energy-details");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
assertMatchesExample("/api/health", "health");
}
@Test
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
snapshot = new GatewaySnapshot("v1.2.1", List.of());
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
}
@Test
void staleDeyeMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement deye = devices.get(1).measurements().get(0);
devices.set(1, new DeviceSnapshot("deye", true, old, null,
List.of(new Measurement("home", "deye", deye.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void batteryMatchesExample() throws Exception {
assertMatchesExample("/api/battery", "battery");
}
@Test
void batteryWithStaleSettingsMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/battery", "battery-settings-stale");
}
@Test
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
batteryDown = true;
var body = JSON.readTree(get("/api/battery").body());
assertTrue(body.get("reserve").isNull());
assertEquals(false, body.get("editable").asBoolean());
assertEquals(99.0, body.get("socPct").asDouble());
}
@Test
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
"Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
JSON.readTree(response.body()));
}
@Test
void reserveChangeGoesToTheGateway() throws Exception {
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
assertEquals(200, response.statusCode());
assertEquals(List.of("reserve 60 by phone"), batterySent);
}
@Test
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
for (var entry : expected.entrySet()) {
batteryVerdict = entry.getKey();
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
batteryVerdict = BatteryResult.Status.FAILED;
assertEquals(example("battery-failed"),
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
}
@Test
void batteryChangeWithTheGatewayDownIs503() throws Exception {
batteryDown = true;
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
}
@Test
void badBatteryRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
"{\"pct\":60,\"x\":1}")) {
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
}
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
}
assertTrue(batterySent.isEmpty());
}
@Test
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
assertEquals(405, get(path).statusCode(), path);
}
assertEquals(404, post("/api/battery/other", "{}").statusCode());
assertTrue(batterySent.isEmpty());
}
private static Forecast exampleForecast() {
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
for (int i = 0; i < 5; i++) {
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
}
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
return new Forecast(10.6, days);
}
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
private static Map<LocalDate, Double> exampleMeasuredPv() {
Map<LocalDate, Double> measured = new HashMap<>();
for (int i = 0; i < 5; i++) {
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
}
return measured;
}
private static Warning exampleWarning() {
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
}
@Test
void weatherMatchesExample() throws Exception {
assertMatchesExample("/api/weather", "weather");
}
@Test
void piSensorMatchesExample()throws Exception {
assertMatchesExample("/api/pi-sensor", "pi-sensor");
}
@Test
void stalePiSensorMatchesExample() throws Exception {
Instant old = Instant.parse("2026-10-23T21:00:00Z");
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
Measurement bme = devices.getLast().measurements().get(0);
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
snapshot = new GatewaySnapshot("v1.2.1", devices);
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void statsMatchExample() throws Exception {
assertMatchesExample("/api/stats?range=7d", "stats");
}
@Test
void statsNeedAKnownRange() throws Exception {
assertEquals(400, get("/api/stats").statusCode());
assertEquals(400, get("/api/stats?range=24h").statusCode());
assertEquals(200, get("/api/stats?range=30d").statusCode());
}
@Test
void statsAre502WhenInfluxIsDown() throws Exception {
historyDown = true;
assertEquals(502, get("/api/stats?range=7d").statusCode());
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
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;
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
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 malformedQueryIs400NotADroppedConnection() throws Exception {
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
assertEquals(400, rawGet(path), path);
}
}
@Test
void unusableGatewayPayloadIs502() throws Exception {
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
new NullPointerException("status"))) {
gatewayBug = bug;
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
HttpResponse<String> response = get(path);
assertEquals(502, response.statusCode(), path);
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
}
}
}
@Test
void influxFailureIs502() throws Exception {
historyDown = true;
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
}
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
/** A POST as the web UI sends it; extra headers come after the defaults. */
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
all.addAll(List.of(headers));
return rawPost(path, body, all.toArray(String[]::new));
}
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.POST(HttpRequest.BodyPublishers.ofString(body));
if (headers.length > 0) {
request.headers(headers);
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
}
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
@Test
void setpointsMatchExample() throws Exception {
assertMatchesExample("/api/setpoints", "setpoints");
}
@Test
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
HttpResponse<String> response = post("/api/setpoints", BOOST);
assertEquals(200, response.statusCode());
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
}
@Test
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
assertEquals(200, response.statusCode());
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
assertEquals(List.of("cwu_economy by anna"), restored);
}
@Test
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
Outcome.FAILED, 502);
for (var entry : expected.entrySet()) {
restoreOutcome = entry.getKey();
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
}
@Test
void restoreWithTheGatewayDownIs503() throws Exception {
commandsDown = true;
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
}
@Test
void restoreIsGuardedLikeEveryWrite() throws Exception {
String path = "/api/setpoints/cwu_economy/restore";
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
assertEquals(405, get(path).statusCode());
assertTrue(restored.isEmpty());
}
@Test
void otherPathsUnderSetpointsAre404() throws Exception {
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
assertEquals(404, post(path, "{}").statusCode(), path);
}
assertTrue(restored.isEmpty());
}
@Test
void rejectedIs422AndMatchesExample() throws Exception {
verdict = Status.REJECTED;
HttpResponse<String> response = post("/api/setpoints", BOOST);
assertEquals(422, response.statusCode());
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
}
@Test
void failedWriteIs502() throws Exception {
verdict = Status.FAILED;
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
}
@Test
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
post("/api/setpoints", BOOST);
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
}
@Test
void badSetpointRequestsAre400AndSendNothing() throws Exception {
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
}
assertTrue(sent.isEmpty());
}
@Test
void presetResultsMatchExample() throws Exception {
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(200, response.statusCode());
JsonNode body = JSON.readTree(response.body());
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
assertEquals("APPLIED", body.get("status").asText());
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
}
@Test
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
answersBeforeDrop = 1;
HttpResponse<String> response = post("/api/presets/away", awayRequest());
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@Test
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
post("/api/presets/away", awayRequest());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
new Command("co_comfort", 15, null, end, "phone")), sent);
}
@Test
void awayWithOwnValuesSendsThem() throws Exception {
var response = post("/api/presets/away", example("preset-away-values-request").toString());
assertEquals(200, response.statusCode());
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
new Command("co_comfort", 16, null, end, "phone")), sent);
}
@Test
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
values);
}
assertTrue(sent.isEmpty());
}
@Test
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
assertEquals(422, post("/api/presets/away", body).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
}
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
}
@Test
void unknownPresetIs404() throws Exception {
assertEquals(404, post("/api/presets/nope", "").statusCode());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideLimitsIs422() throws Exception {
verdict = Status.REJECTED;
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
void unreachableGatewayOnSetpointsIs503() throws Exception {
commandsDown = true;
assertEquals(503, get("/api/setpoints").statusCode());
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
}
@Test
void writesWithoutJsonContentTypeAre415() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
.statusCode(), path);
}
assertTrue(sent.isEmpty());
}
@Test
void jsonContentTypeMayCarryACharset() throws Exception {
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
"Content-Type", "application/json; charset=utf-8").statusCode());
}
@Test
void writesWithoutTheCustomHeaderAre403() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
}
assertTrue(sent.isEmpty());
}
@Test
void writesFromAnotherOriginAre403() throws Exception {
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
}
}
assertTrue(sent.isEmpty());
}
@Test
void writesFromTheSameOriginAreAccepted() throws Exception {
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
}
@Test
void setpointMethodsAreChecked() throws Exception {
assertEquals(405, post("/api/history", "").statusCode());
assertEquals(405, get("/api/presets/away").statusCode());
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
.PUT(HttpRequest.BodyPublishers.noBody()).build();
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).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 everyResponseCarriesTheSecurityHeaders() throws Exception {
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
for (String path : paths) {
assertSecurityHeaders(get(path), path);
}
assertSecurityHeaders(post("/api/health", ""), "405");
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
}
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
var headers = response.headers();
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
headers.firstValue("Content-Security-Policy").orElse(null), what);
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
}
@Test
void unknownApiPathIs404() throws Exception {
assertEquals(404, get("/api/nothing").statusCode());
}
@Test
void apiRoutesMatchTheExactPathOnly() throws Exception {
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
assertEquals(404, get(path).statusCode(), path);
}
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
}
@Test
void stopClosesThePort() {
server.stop();
assertThrows(IOException.class, () -> get("/api/health"));
}
@Test
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
String padding = " ".repeat(4096 - BOOST.length());
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
sent.clear();
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
assertEquals(0, sent.size());
}
@Test
void answersOnlyTheApi() throws Exception {
// The web UI is the frontend container's (ADR-010); the backend serves no files.
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
assertEquals(404, get(path).statusCode(), 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]);
}
}
}
@@ -1,105 +0,0 @@
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));
}
}
@@ -1,274 +0,0 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.HeatPumpSample;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
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.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxHistoryRepositoryTest {
private static final Instant FROM = Instant.parse("2026-10-24T00:00:00Z");
private static final Instant TO = Instant.parse("2026-10-24T06:00:00Z");
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private HttpServer server;
private final List<Map<String, String>> requests = Collections.synchronizedList(new ArrayList<>());
private volatile CountDownLatch rendezvous = new CountDownLatch(0);
private final AtomicInteger metTogether = new AtomicInteger();
private final Map<String, String> bodyByField = new HashMap<>();
private int status = 200;
private String failingField = "";
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
// Concurrent handlers: the repository asks for the parts of a series at the same time.
server.setExecutor(Executors.newCachedThreadPool());
server.createContext("/query", exchange -> {
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
requests.add(params);
rendezvous.countDown();
try {
if (rendezvous.await(5, TimeUnit.SECONDS)) {
metTogether.incrementAndGet();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
String body = bodyByField.entrySet().stream()
.filter(e -> params.get("q").contains("\"" + e.getKey() + "\""))
.map(Map.Entry::getValue).findFirst().orElse(defaultBody);
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
boolean fails = !failingField.isEmpty() && params.get("q").contains("\"" + failingField + "\"");
exchange.sendResponseHeaders(fails ? 500 : status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() {
server.stop(0);
}
@Test
void asksTheLegacyDatabaseForATemperatureWithQuotedFieldName() throws IOException {
defaultBody = fixture("influx-query-cwu-6h.json");
List<Point> points = repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 360);
assertEquals(1, requests.size());
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("ms", requests.get(0).get("epoch"));
assertEquals("SELECT mean(\"Temp cwu\") FROM \"POMPA\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(1m) fill(none)", requests.get(0).get("q"));
assertEquals(7, points.size());
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
}
@Test
void asksTheElectricityDatabaseForGridPower() throws IOException {
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
assertEquals("electricity", requests.get(0).get("db"));
assertEquals("SELECT mean(\"total_power_active\") FROM \"PRAD2\" WHERE time >= 1792800000000ms"
+ " AND time < 1792821600000ms GROUP BY time(5m) fill(none)", requests.get(0).get("q"));
}
@Test
void roundsTheStepUpToWholeMinutes() throws IOException {
repository(TIMEOUT).history(Series.LOAD_POWER, FROM, TO, 100);
assertTrue(requests.get(0).get("q").contains("GROUP BY time(4m)"), requests.get(0).get("q"));
}
@Test
void sumsPvPowerAcrossMeasurementsAndKeepsBucketsWithOnePart() throws IOException {
bodyByField.put("pv1_power_w", series(1000, 100.0, 2000, 200.0));
bodyByField.put("pv2_power_w", series(1000, 50.0));
bodyByField.put("pv_power_w", series(2000, 5.0, 3000, 7.0));
List<Point> points = repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
assertEquals(3, requests.size());
assertEquals(List.of(
new Point(Instant.ofEpochMilli(1000), 150.0),
new Point(Instant.ofEpochMilli(2000), 205.0),
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
}
@Test
void asksForTheThreePvPartsAtTheSameTime() throws IOException {
rendezvous = new CountDownLatch(3);
repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
// A handler passes the latch only when all three requests are in flight together.
assertEquals(3, metTogether.get());
}
@Test
void oneFailingPartFailsTheWholeSeries() {
failingField = "pv2_power_w";
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
}
@Test
void sortsPointsByTime() throws IOException {
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
List<Point> points = repository(TIMEOUT).history(Series.BATTERY_SOC, FROM, TO, 72);
assertEquals(List.of(1000L, 2000L, 3000L), points.stream().map(p -> p.t().toEpochMilli()).toList());
}
@Test
void noDataGivesAnEmptyList() throws IOException {
defaultBody = fixture("influx-query-empty.json");
assertEquals(List.of(), repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
}
@Test
void topLevelErrorIsAnIOException() throws IOException {
defaultBody = fixture("influx-query-error.json");
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("error parsing query"), e.getMessage());
}
@Test
void perResultErrorIsAnIOException() {
defaultBody = "{\"results\":[{\"statement_id\":0,\"error\":\"database not found: electricity\"}]}";
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72));
assertTrue(e.getMessage().contains("database not found"), e.getMessage());
}
@Test
void httpErrorIsAnIOException() {
status = 503;
IOException e = assertThrows(IOException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
assertTrue(e.getMessage().contains("503"), e.getMessage());
}
@Test
void badJsonIsAnIOException() {
defaultBody = "<html>not json";
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void slowServerTimesOut() {
server.removeContext("/query");
server.createContext("/query", exchange -> {
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
exchange.close();
});
assertThrows(IOException.class,
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
}
@Test
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
defaultBody = fixture("influx-query-heat-pump.json");
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
assertEquals("Dom_Pompa", requests.get(0).get("db"));
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
requests.get(0).get("q"));
assertEquals(3, samples.size());
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
samples.get(1));
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
samples.get(2));
}
@Test
void noHeatPumpPollsIsAnEmptyList() throws IOException {
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
}
@Test
void heatPumpQueryErrorIsAnIoException() throws IOException {
defaultBody = fixture("influx-query-error.json");
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
}
@Test
void rejectsAnEmptyRange() {
assertThrows(IllegalArgumentException.class,
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, FROM, 72));
}
private InfluxHistoryRepository repository(Duration timeout) {
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
return new InfluxHistoryRepository(new InfluxSettings(base, timeout));
}
private static String series(Object... timeAndValue) {
StringBuilder rows = new StringBuilder();
for (int i = 0; i < timeAndValue.length; i += 2) {
rows.append(i == 0 ? "" : ",").append('[').append(timeAndValue[i]).append(',')
.append(timeAndValue[i + 1]).append(']');
}
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"mean\"],"
+ "\"values\":[" + rows + "]}]}]}";
}
private static String fixture(String name) throws IOException {
return Files.readString(Path.of("src/test/resources/influx", name));
}
private static Map<String, String> params(String rawQuery) {
Map<String, String> params = new HashMap<>();
for (String pair : rawQuery.split("&")) {
String[] kv = pair.split("=", 2);
params.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
return params;
}
}
@@ -1,89 +0,0 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
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.time.Duration;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class InfluxPvEnergyTest {
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
private static final LocalDate TO = LocalDate.parse("2026-10-24");
private HttpServer server;
private InfluxPvEnergy pv;
private final List<String> queries = new ArrayList<>();
private int status = 200;
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
@BeforeEach
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/query", exchange -> {
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
queries.add(query);
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
Duration.ofSeconds(2));
pv = new InfluxPvEnergy(settings);
}
@AfterEach
void stop() {
server.stop(0);
}
private static String rows(String t1, double v1, String t2, double v2) {
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
}
@Test
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
}
@Test
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
pv.dailyKwh(FROM, TO);
assertEquals(2, queries.size());
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
}
@Test
void failureIsAnIoError() {
status = 500;
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
}
}
Loaded 100 of 2505 files, more files were not shown because too many files have changed in this diff. Show more