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
2376 changed files with 58945 additions and 22005 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
-56
View File
@@ -1,56 +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
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
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
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
-19
View File
@@ -1,19 +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
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"]
-30
View File
@@ -1,30 +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.
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 |
| `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 |
```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
```
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).
-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,41 +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.InfluxHistoryRepository;
import home.backend.adapters.influx.InfluxSettings;
import home.backend.application.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.SetpointService;
import java.io.IOException;
import java.time.Clock;
/** Wires the adapters and services together; the only place that knows all of them. */
final class Backend {
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 influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
settings.gatewayTimeout());
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
return new ApiServer(settings.port(),
new HeatPumpService(gateway, clock),
new EnergyService(gateway, clock),
new HealthService(gateway, clock),
new HistoryService(influx, clock), setpoints);
}
}
@@ -1,31 +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;
}
if (settings.gatewayToken() == null) {
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
}
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
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(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(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,81 +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.datatype.jsr310.JavaTimeModule;
import home.backend.application.GatewayCommands;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
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.util.Set;
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
public class GatewayHttpCommands implements GatewayCommands {
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
@JsonInclude(JsonInclude.Include.NON_NULL)
private record Request(String setpoint, int value, Long minutes) {
}
private static final ObjectMapper JSON = new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
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.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));
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 SetpointState setpoints() throws IOException {
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
}
private HttpRequest.Builder request() throws IOException {
if (token == null) {
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
}
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
}
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
try {
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
if (!accepted.contains(response.statusCode())) {
throw new IOException("gateway /commands answered " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while calling gateway /commands", e);
}
}
}
@@ -1,280 +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.EnergyService;
import home.backend.application.HealthService;
import home.backend.application.HeatPumpService;
import home.backend.application.HistoryService;
import home.backend.application.SetpointService;
import home.backend.domain.CommandResult.Status;
import home.backend.domain.PresetResult;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.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 SetpointRequest(String setpoint, Integer value, Double hours) {
}
private static final String UNREACHABLE = "gateway unreachable";
private static final String WRITE_MARKER = "home-pump";
private static final String PRESETS_PATH = "/api/presets/";
/** 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, HealthService health,
HistoryService history, SetpointService setpoints) 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/health", query -> health.current());
route("/api/history", exchange -> handleHistory(exchange, history));
route("/api/setpoints", exchange -> handle(exchange, Map.of(
"GET", () -> getSetpoints(exchange, setpoints),
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
"POST", () -> guarded(exchange, body -> postPreset(exchange, 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 postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
try {
var result = setpoints.applyPreset(id, user(exchange));
if (result.isPresent()) {
send(exchange, presetHttpStatus(result.get().status()), result.get());
} else {
send(exchange, 404, error("no such 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 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 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,95 +0,0 @@
package home.backend.adapters.influx;
import home.backend.application.HistoryRepository;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
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 {
private final InfluxSettings settings;
private final HttpClient http;
public InfluxHistoryRepository(InfluxSettings settings) {
this.settings = settings;
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
}
@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();
}
private Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
return InfluxResponse.parse(fetch(source.database(), query));
}
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
}
private String fetch(String database, String query) throws IOException {
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
.timeout(settings.requestTimeout())
.GET()
.build();
try {
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
}
return response.body();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("InfluxDB request interrupted", e);
}
}
private URI queryUri(String database, String query) {
String base = settings.baseUri().toString().replaceAll("/+$", "");
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -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,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 {
private 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,69 +0,0 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.EnergyView.Today;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
public class EnergyService {
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
private record Inverter(Source source, Map<String, Double> fields) {
}
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();
Inverter deye = inverter(snapshot, "deye");
Inverter fronius = inverter(snapshot, "fronius");
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 Inverter inverter(GatewaySnapshot snapshot, String name) {
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
return new Inverter(source, stale ? Map.of() : reading.get().fields());
}
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.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,16 +0,0 @@
package home.backend.application;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
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;
SetpointState setpoints() throws IOException;
}
@@ -1,35 +0,0 @@
package home.backend.application;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.HealthView;
import home.backend.domain.HealthView.DeviceHealth;
import java.io.IOException;
import java.time.Clock;
import java.util.List;
/** F5: whether the gateway answers and which device links are up. */
public class HealthService {
private final GatewayClient gateway;
private final Clock clock;
public HealthService(GatewayClient gateway, Clock clock) {
this.gateway = gateway;
this.clock = clock;
}
public HealthView current() {
try {
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
.map(HealthService::health).toList();
return new HealthView(clock.instant(), true, devices);
} catch (IOException e) {
return new HealthView(clock.instant(), false, List.of());
}
}
private static DeviceHealth health(DeviceSnapshot d) {
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
}
}
@@ -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,104 +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.Setpoint;
import home.backend.domain.SetpointState;
import home.backend.domain.SetpointsView;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/** 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));
}
/**
* 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.
*/
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
if (preset.isEmpty()) {
return Optional.empty();
}
List<Change> changes = preset.get().changes();
List<Setpoint> limits = gateway.setpoints().setpoints();
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
return Optional.of(PresetResult.of(checked));
}
return Optional.of(PresetResult.of(run(changes, user)));
}
private List<ChangeResult> run(List<Change> changes, 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, user));
}
return results;
}
private ChangeResult attempt(Change change, String user) {
try {
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
} 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, List<Setpoint> limits) {
String problem = problem(change, limits);
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
}
private static String problem(Change change, 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();
}
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,50 +0,0 @@
package home.backend.application;
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 device.isEmpty() || !device.get().up() || reading.isEmpty()
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
}
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,10 +0,0 @@
package home.backend.domain;
import java.time.Duration;
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
public record Command(String setpoint, int value, Duration duration, String 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,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,17 +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. */
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
List<Measurement> measurements) {
}
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
}
}
@@ -1,11 +0,0 @@
package home.backend.domain;
import java.time.Instant;
import java.util.List;
/** Health of the chain phone → backend → gateway → devices (F5). */
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
}
}
@@ -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,29 +0,0 @@
package home.backend.domain;
import java.util.List;
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
public record Preset(String id, String name, List<Change> changes) {
/** {@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);
}
}
}
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");
}
}
}
@@ -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,7 +0,0 @@
package home.backend.domain;
import java.time.Instant;
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
}
@@ -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,15 +0,0 @@
[
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
},
{
"id": "away",
"name": "Wyjazd",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
@@ -1,307 +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 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,66 +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", List.of(new Change("cwu_economy", 45, 2.0))),
new Preset("away", "Wyjazd", 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", 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\", \"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,173 +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 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,186 +0,0 @@
package home.backend.adapters.gateway;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import home.backend.domain.Command;
import home.backend.domain.CommandResult.Status;
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.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 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() {
server.createContext("/commands", exchange -> {
seen.add(new Seen(exchange.getRequestMethod(),
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 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 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,516 +0,0 @@
package home.backend.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.backend.application.EnergyService;
import home.backend.adapters.config.PresetFile;
import home.backend.application.GatewayClient;
import home.backend.application.GatewayCommands;
import home.backend.application.SetpointService;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
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.HistoryService;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HistoryView.Point;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.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 RuntimeException gatewayBug;
private final List<Command> sent = new ArrayList<>();
private Status verdict;
private boolean commandsDown;
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));
};
verdict = Status.APPLIED;
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 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"))));
}
};
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
new HealthService(gateway, clock), new HistoryService(history, clock),
new SetpointService(commands, PresetFile.load(null)));
server.start();
}
@AfterEach
void stop() {
server.stop();
}
private static GatewaySnapshot exampleSnapshot() {
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
"load_today_kwh", 24.2);
return new GatewaySnapshot(List.of(
new DeviceSnapshot("heat-pump", true, pumpTime, null,
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, 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)))));
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
private static JsonNode example(String name) throws IOException {
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
return JSON.readTree(in);
}
}
private void assertMatchesExample(String path, String example) throws Exception {
HttpResponse<String> response = get(path);
assertEquals(200, response.statusCode());
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
assertEquals(example(example), JSON.readTree(response.body()));
}
@Test
void heatPumpMatchesExample() throws Exception {
assertMatchesExample("/api/heat-pump", "heat-pump");
}
@Test
void energyMatchesExample() throws Exception {
assertMatchesExample("/api/energy", "energy");
}
@Test
void healthMatchesExample() throws Exception {
snapshot = new GatewaySnapshot(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(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(devices);
assertMatchesExample("/api/energy", "energy-deye-stale");
}
@Test
void historyMatchesExample() throws Exception {
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
}
@Test
void historyRangeDefaultsTo24Hours() throws Exception {
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
}
@Test
void unreachableGatewayIs503WithJsonError() throws Exception {
gatewayDown = true;
for (String path : List.of("/api/heat-pump", "/api/energy")) {
HttpResponse<String> response = get(path);
assertEquals(503, response.statusCode());
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
}
}
@Test
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
gatewayDown = true;
HttpResponse<String> response = get("/api/health");
assertEquals(200, response.statusCode());
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
}
@Test
void unknownSeriesOrRangeIs400() throws Exception {
assertEquals(400, get("/api/history?series=nope").statusCode());
assertEquals(400, get("/api/history").statusCode());
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
}
@Test
void 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 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", "");
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", "{}");
assertEquals(502, response.statusCode());
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
}
@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", "").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", "").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,244 +0,0 @@
package home.backend.adapters.influx;
import com.sun.net.httpserver.HttpServer;
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 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,349 +0,0 @@
package home.backend.application;
import home.backend.domain.EnergyView;
import home.backend.domain.EnergyView.Source;
import home.backend.domain.GatewaySnapshot;
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
import home.backend.domain.GatewaySnapshot.Measurement;
import home.backend.domain.HealthView;
import home.backend.domain.HeatPumpView;
import home.backend.domain.HeatPumpView.State;
import home.backend.domain.HistoryView;
import home.backend.domain.HistoryView.Point;
import home.backend.domain.Series;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ServicesTest {
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
private static DeviceSnapshot device(String name, String database, String measurement,
Map<String, Double> fields) {
return new DeviceSnapshot(name, true, READ, null,
List.of(new Measurement(database, measurement, fields, READ)));
}
private static GatewayClient gateway(DeviceSnapshot... devices) {
return () -> new GatewaySnapshot(List.of(devices));
}
private static GatewayClient unreachable() {
return () -> {
throw new IOException("tunnel down");
};
}
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
.current();
}
@Test
void heatPumpStateComesFromPumpMode() throws IOException {
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
}
@Test
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
assertEquals(READ, view.time());
assertEquals(66, view.compressorPercent());
assertTrue(view.heaterOn());
assertTrue(view.alarm());
assertTrue(view.pumps().cwu());
assertFalse(view.pumps().co());
assertEquals(8, view.setpoints().cwuEconomy());
}
@Test
void heatPumpAlarmCodeRaisesTheAlarmAndIsNamedInPolish() throws IOException {
HeatPumpView sensor = heatPump(Map.of("alarm", 0.0, "alarm_code", 3.0));
HeatPumpView pressure = heatPump(Map.of("alarm_code", 1003.0));
HeatPumpView unknown = heatPump(Map.of("alarm_code", 77.0));
HeatPumpView healthy = heatPump(Map.of("alarm", 0.0, "alarm_code", 0.0));
assertTrue(sensor.alarm());
assertEquals(3, sensor.alarmCode());
assertEquals("Usterka czujnika temperatury CO", sensor.alarmText());
assertEquals("Zadziałał presostat (ciśnienie)", pressure.alarmText());
assertEquals("Kod usterki 77", unknown.alarmText());
assertFalse(healthy.alarm());
assertNull(healthy.alarmText());
}
@Test
void heatPumpMissingFieldsAreNull() throws IOException {
HeatPumpView view = heatPump(Map.of());
assertNull(view.state());
assertNull(view.compressorPercent());
assertNull(view.temperatures().outdoor());
assertNull(view.targets().cwu());
assertNull(view.setpoints().coComfort());
assertFalse(view.alarm());
assertNull(view.alarmCode());
assertNull(view.alarmText());
}
@Test
void heatPumpTemperaturesDropBinaryNoiseOfTheGatewayScaling() throws IOException {
HeatPumpView view = heatPump(Map.of("indoor_temp", 212 * 0.1, "target_co_temp", 310 * 0.1,
"outdoor_temp", -43 * 0.1));
assertEquals(21.2, view.temperatures().indoor());
assertEquals(31.0, view.targets().co());
assertEquals(-4.3, view.temperatures().outdoor());
}
@Test
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
HeatPumpService service = new HeatPumpService(gateway(
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
HeatPumpView view = service.current();
assertNull(view.state());
assertNull(view.time());
assertTrue(view.stale());
}
@Test
void heatPumpWithoutAReadingHasNoTimeAndIsStale() throws IOException {
HeatPumpView view = new HeatPumpService(gateway(), CLOCK).current();
assertNull(view.time());
assertTrue(view.stale());
assertNull(view.state());
assertNull(view.temperatures().outdoor());
assertNull(view.setpoints().cwuComfort());
}
@Test
void heatPumpPumpModeOutOfRangeHasNoState() throws IOException {
assertNull(heatPump(Map.of("pump_mode", 4.0)).state());
assertNull(heatPump(Map.of("pump_mode", -1.0)).state());
}
@Test
void heatPumpIsFreshOnlyWhileUpAndNotOlderThanTenMinutes() throws IOException {
assertFalse(heatPump(Map.of("pump_mode", 0.0)).stale());
assertTrue(heatPumpRead(true, NOW.minusSeconds(601)).stale());
assertFalse(heatPumpRead(true, NOW.minusSeconds(600)).stale());
HeatPumpView down = heatPumpRead(false, READ);
assertTrue(down.stale());
assertEquals(READ, down.time());
}
private static HeatPumpView heatPumpRead(boolean up, Instant time) throws IOException {
var reading = new Measurement("Dom_Pompa", "heat_pump", Map.of("pump_mode", 0.0), time);
return new HeatPumpService(gateway(new DeviceSnapshot("heat-pump", up, time, null, List.of(reading))),
CLOCK).current();
}
@Test
void heatPumpPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
}
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
return new EnergyService(gateway(devices), CLOCK).current();
}
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
@Test
void energySumsPvOfBothInverters() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE),
device("fronius", "home", "fronius", FRONIUS));
assertEquals(3500.0, view.pvPowerW());
assertEquals(14.4, view.today().pvKwh());
assertEquals(-200.0, view.gridPowerW());
assertEquals(80.0, view.batterySocPct());
assertNull(view.batteryPowerW());
}
@Test
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
EnergyView view = energy(device("deye", "home", "deye", DEYE));
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(READ, view.time());
}
@Test
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithoutSourcesHasNoTimeAndBothSourcesStale() throws IOException {
EnergyView view = energy();
assertNull(view.pvPowerW());
assertNull(view.today().pvKwh());
assertNull(view.time());
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
}
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
}
@Test
void energyWithStaleDeyeKeepsOnlyFreshFroniusAndTheOldestTime() throws IOException {
Instant old = NOW.minusSeconds(3600);
EnergyView view = energy(deyeRead(true, old), device("fronius", "home", "fronius", FRONIUS));
assertEquals(old, view.time());
assertEquals(List.of(new Source("deye", old, true), new Source("fronius", READ, false)), view.sources());
assertEquals(2000.0, view.pvPowerW());
assertEquals(4.4, view.today().pvKwh());
assertNull(view.gridPowerW());
assertNull(view.batteryPowerW());
assertNull(view.batterySocPct());
assertNull(view.loadPowerW());
assertNull(view.today().loadKwh());
}
@Test
void energyWithStaleFroniusKeepsDeyeAlone() throws IOException {
Instant old = NOW.minusSeconds(3600);
var fronius = new DeviceSnapshot("fronius", true, old, null,
List.of(new Measurement("home", "fronius", FRONIUS, old)));
EnergyView view = energy(device("deye", "home", "deye", DEYE), fronius);
assertEquals(1500.0, view.pvPowerW());
assertEquals(10.0, view.today().pvKwh());
assertEquals(80.0, view.batterySocPct());
assertEquals(old, view.time());
}
@Test
void energySourceWhoseLinkIsDownIsStaleEvenWithAFreshReading() throws IOException {
EnergyView view = energy(deyeRead(false, READ));
assertTrue(view.sources().get(0).stale());
assertNull(view.gridPowerW());
assertEquals(READ, view.time());
}
@Test
void energyPropagatesGatewayFailure() {
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
}
@Test
void healthListsDevices() {
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
down), CLOCK).current();
assertTrue(view.gatewayReachable());
assertEquals(NOW, view.time());
assertEquals(2, view.devices().size());
assertEquals("connect timed out", view.devices().get(1).lastError());
assertFalse(view.devices().get(1).up());
assertEquals(READ, view.devices().get(0).lastSuccess());
}
@Test
void healthWithUnreachableGatewayHasNoDevices() {
HealthView view = new HealthService(unreachable(), CLOCK).current();
assertFalse(view.gatewayReachable());
assertTrue(view.devices().isEmpty());
}
/** Remembers the request and answers with one point. */
private static final class RecordingHistory implements HistoryRepository {
Series series;
Instant from;
Instant to;
int maxPoints;
@Override
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
this.series = series;
this.from = from;
this.to = to;
this.maxPoints = maxPoints;
return List.of(new Point(from, 1.5));
}
}
@Test
void historyDefaultsToLast24Hours() throws IOException {
RecordingHistory repository = new RecordingHistory();
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
assertEquals("cwu_temp", view.series());
assertEquals("°C", view.unit());
assertEquals(1, view.points().size());
assertEquals(Series.CWU_TEMP, repository.series);
assertEquals(NOW, repository.to);
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
assertEquals(300, repository.maxPoints);
}
@Test
void historyOfSevenDays() throws IOException {
RecordingHistory repository = new RecordingHistory();
new HistoryService(repository, CLOCK).history("pv_power", "7d");
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
}
@Test
void historyRejectsUnknownSeriesAndRange() {
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
}
@Test
void historyPropagatesRepositoryFailure() {
HistoryService service = new HistoryService((series, from, to, max) -> {
throw new IOException("influx down");
}, CLOCK);
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
}
}
@@ -1,204 +0,0 @@
package home.backend.application;
import home.backend.adapters.config.PresetFile;
import home.backend.domain.Command;
import home.backend.domain.CommandResult;
import home.backend.domain.CommandResult.Status;
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.Restore;
import home.backend.domain.Setpoint;
import home.backend.domain.SetpointState;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
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 SetpointServiceTest {
private static final SetpointState STATE = new SetpointState(List.of(
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.<Restore>of());
private final List<Command> sent = new ArrayList<>();
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
private final List<Object> script = new ArrayList<>();
private boolean down;
private final GatewayCommands gateway = new GatewayCommands() {
@Override
public CommandResult apply(Command command) throws IOException {
if (down) {
throw new IOException("tunnel down");
}
Object next = script.isEmpty() ? Status.APPLIED : script.removeFirst();
if (next instanceof IOException e) {
throw e;
}
sent.add(command);
return new CommandResult((Status) next, command.setpoint(), null);
}
@Override
public SetpointState setpoints() throws IOException {
if (down) {
throw new IOException("tunnel down");
}
return STATE;
}
};
private final List<Preset> presets = PresetFile.load(null);
private final SetpointService service = new SetpointService(gateway, presets);
@Test
void viewJoinsGatewayStateAndPresets() throws Exception {
var view = service.current();
assertEquals(STATE.setpoints(), view.setpoints());
assertEquals(presets, view.presets());
}
@Test
void hoursBecomeAWholeNumberOfMinutes() throws Exception {
service.apply("cwu_economy", 45, 1.5, "anna");
service.apply("cwu_comfort", 10, null, "anna");
assertEquals(new Command("cwu_economy", 45, Duration.ofMinutes(90), "anna"), sent.get(0));
assertEquals(new Command("cwu_comfort", 10, null, "anna"), sent.get(1));
}
@Test
void durationUnderAMinuteIsRefusedBeforeSending() {
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 0.001, "anna"));
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, -1.0, "anna"));
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 72.5, "anna"));
assertTrue(sent.isEmpty());
}
@Test
void seventyTwoHoursIsTheLongestBoost() throws Exception {
service.apply("cwu_economy", 45, 72.0, "anna");
assertEquals(Duration.ofHours(72), sent.getFirst().duration());
}
@Test
void boostPresetSendsOneTimedCommand() throws Exception {
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
assertEquals(PresetResult.Status.APPLIED, result.status());
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
}
@Test
void awayPresetSendsBothChangesUntilChanged() throws Exception {
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(2, result.results().size());
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
sent);
}
@Test
void unknownPresetIsEmpty() throws Exception {
assertTrue(service.applyPreset("nope", "anna").isEmpty());
assertTrue(sent.isEmpty());
}
@Test
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
new Change("cwu_economy", 60, 2.0)));
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
assertTrue(result.results().getLast().message().contains("cwu_economy"));
assertTrue(sent.isEmpty());
}
@Test
void presetWithUnknownSetpointWritesNothing() throws Exception {
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertTrue(sent.isEmpty());
}
private static List<ChangeStatus> statuses(PresetResult result) {
return result.results().stream().map(ChangeResult::status).toList();
}
@Test
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
script.add(Status.FAILED);
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
assertEquals(1, sent.size());
}
@Test
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
}
@Test
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
script.add(Status.REJECTED);
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(PresetResult.Status.REJECTED, result.status());
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
}
@Test
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(PresetResult.Status.PARTIAL, result.status());
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
}
@Test
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
script.add(new IOException("tunnel dropped"));
var result = service.applyPreset("away", "anna").orElseThrow();
assertEquals(PresetResult.Status.FAILED, result.status());
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
}
@Test
void unreachableGatewayIsAnIOException() {
down = true;
assertThrows(IOException.class, service::current);
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
assertTrue(sent.isEmpty());
}
}
@@ -1,20 +0,0 @@
package home.backend.domain;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SeriesTest {
@Test
void findsSeriesByItsId() {
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
}
@Test
void unknownIdFindsNothing() {
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
}
}
@@ -1,13 +0,0 @@
{
"time": "2026-10-23T21:00:00Z",
"sources": [
{ "name": "deye", "time": "2026-10-23T21:00:00Z", "stale": true },
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
],
"pvPowerW": 0.0,
"gridPowerW": null,
"batteryPowerW": null,
"batterySocPct": null,
"loadPowerW": null,
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
}
@@ -1,13 +0,0 @@
{
"time": "2026-10-23T22:26:00Z",
"sources": [
{ "name": "deye", "time": "2026-10-23T22:26:00Z", "stale": false },
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
],
"pvPowerW": 0.0,
"gridPowerW": 645.0,
"batteryPowerW": -24.0,
"batterySocPct": 99.0,
"loadPowerW": 570.0,
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
}
@@ -1,8 +0,0 @@
{
"time": "2026-10-23T22:14:00Z",
"gatewayReachable": true,
"devices": [
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
]
}
@@ -1,17 +0,0 @@
{
"time": null,
"stale": true,
"state": null,
"compressorPercent": null,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": null, "indoor": null, "coBuffer": null, "cwu": null,
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
},
"targets": { "room": null, "co": null, "cwu": null },
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
}
@@ -1,17 +0,0 @@
{
"time": "2026-10-23T22:26:00Z",
"stale": false,
"state": "IDLE",
"compressorPercent": 0,
"heaterOn": false,
"alarm": false,
"alarmCode": null,
"alarmText": null,
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
"temperatures": {
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
},
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
}
@@ -1,9 +0,0 @@
{
"series": "cwu_temp",
"unit": "°C",
"points": [
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
]
}
@@ -1 +0,0 @@
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": "2026-10-24T12:00:00Z" }
@@ -1,7 +0,0 @@
{
"status": "APPLIED",
"results": [
{ "status": "APPLIED", "message": "cwu_comfort 45 → 10", "restoreAt": null },
{ "status": "APPLIED", "message": "co_comfort 19 → 15", "restoreAt": null }
]
}
@@ -1,7 +0,0 @@
{
"status": "PARTIAL",
"results": [
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": null },
{ "status": "UNKNOWN", "message": "no answer from the gateway", "restoreAt": null }
]
}
@@ -1 +0,0 @@
{ "status": "REJECTED", "message": "cwu_economy allows 5–48 in steps of 1, not 60", "restoreAt": null }
@@ -1 +0,0 @@
{ "setpoint": "cwu_economy", "value": 45, "hours": 2 }
@@ -1,26 +0,0 @@
{
"setpoints": [
{ "name": "co_economy", "register": 4131, "min": 5, "max": 25, "step": 1, "value": 18 },
{ "name": "co_comfort", "register": 4132, "min": 5, "max": 25, "step": 1, "value": 19 },
{ "name": "cwu_economy", "register": 4143, "min": 5, "max": 48, "step": 1, "value": 8 },
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
],
"restores": [
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
],
"presets": [
{
"id": "cwu-now",
"name": "Ciepła woda teraz",
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2.0 } ]
},
{
"id": "away",
"name": "Wyjazd",
"changes": [
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
{ "setpoint": "co_comfort", "value": 15, "hours": null }
]
}
]
}
@@ -1 +0,0 @@
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -1 +0,0 @@
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"DOWN","lastSuccess":null,"lastError":"Connection refused"},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -1 +0,0 @@
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"DOWN","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out"},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
@@ -1 +0,0 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
@@ -1 +0,0 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":null,"lastError":"Connection refused","readings":[]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,"total_line_current":2.55,"p1_power_active":55.94,"total_import_demand_power_active":452.07},"time":"2026-10-23T22:26:00Z"}]}]
@@ -1 +0,0 @@
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out","readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:20:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_volLine truncated
@@ -1 +0,0 @@
{"results":[{"statement_id":0,"series":[{"name":"POMPA","columns":["time","mean"],"values":[[1792771200000,43.76666666666667],[1792774800000,42.40000000000001],[1792778400000,26.733333333333334],[1792782000000,19.15833333333333],[1792785600000,13.799999999999997],[1792789200000,13.58333333333333],[1792792800000,13.65]]}]}]}
@@ -1 +0,0 @@
{"results":[{"statement_id":0}]}
@@ -1 +0,0 @@
{"error":"error parsing query: found SELEC, expected SELECT, DELETE, SHOW, CREATE, DROP, EXPLAIN, GRANT, REVOKE, ALTER, SET, KILL at line 1, char 1"}
+148
View File
@@ -0,0 +1,148 @@
FRISKO-ONLINE v2.0
Aktualizacja 27.10.2021
Przeznaczenie
System FRISKO-ONLINE stanowi doskonale rozwiazanie dla firm instalacyjnych i serwisowych oraz indywidualnych uzytkowników instalacji wyposazonych w automatyke firmy FRISKO.
Instalatorzy i serwisanci maja mozliwosc zdalnego nadzoru nad instalacja bez kosztownych i czasochlonnych wyjazdów serwisowych do klienta.
Uzytkownicy moga w dowolnej chwili praktycznie z kazdego miejsca zmieniac nastawy (zadane temperatury) swojego systemu grzewczego, klimatyzacji itp., wlaczac i wylaczac poszczególne funkcji, np. zalaczyc ogrzewanie tuz przed przyjazdem do domu.
Oprócz obslugi sterownika system umozliwia rejestracje wybranych parametrów pracy instalacji w bazie danych. Zarejestrowane dane mozna przegladac w formie wykresów. Mozliwe jest drukowanie wykresów oraz eksport danych do plików typu *.csv. Dane z takich plików moga byc analizowane praktycznie przez dowolny program typu arkusz kalkulacyjny. Analiza zarejestrowanych danych pozwala zoptymalizowac dobór nastaw, co przyczynia sie do obnizenia kosztów eksploatacji instalacji i zwiekszenia komfortu jej uzytkowników.
Kazdy obiekt podlaczony do systemu monitorowany jest pod katem poprawnosci pracy instalacji a takze samego sterownika nia zarzadzajacego. W przypadku wykrycia nieprawidlowosci system FRISKO-ONLINE umozliwia automatyczne generowanie alarmowych wiadomosci e-mail przesylanych na dwa przypisane do danego obiektu konta pocztowe. Moga byc to konta np. serwisu firmowego i uzytkownika obiektu.
Obsluga instalacji odbywa sie poprzez aplikacje dostepowa pracujaca w systemie Windows. Do poprawnej pracy wymagane jest zainstalowanie Javy (minimalna wersja JRE 1.6.0_07). Dostep do aplikacji chroniony jest haslem i wymaga uprzedniego zarejestrowania uzytkownika w systemie.
Korzystanie z systemu FRISKO-ONLINE w wersji podstawowej jest bezplatne. Za korzystanie z systemu w pelnej wersji pobierana jest oplata abonamentowa w wysokosci 20,00PLN/miesiac lub 200,00PLN/rok za kazdy podlaczony do systemu sterownik.
Ponizsza tabela przedstawia zestawienie dostepnych funkcji w obu wersjach systemu.
Funkcja␍FRISKO-ONLINE␍␍wersja podstawowa␍wersja pelna␍Wyswietlanie i zmiana podstawowych parametrów sterownika.␍
␍
␍Wyswietlanie i zmiana serwisowych parametrów sterownika.␍
␍
␍Podglad online mierzonych temperatur i stanu wyjsc.␍
␍
␍Przedstawienie stanu instalacji w formie graficznej (schemat technologiczny).␍
␍
(tylko na zamówienie za dodatkowa oplata)␍Przeglad zarejestrowanych parametrów pracy sterownika w formie wykresów.␍
(tylko z poprzedniego i biezacego dnia)␍
␍Zapis zarejestrowanych parametrów pracy sterownika do pliku CSV.␍
(tylko z poprzedniego i biezacego dnia)␍
␍Drukowanie zarejestrowanych parametrów pracy sterownika w formie wykresów.␍
(tylko z poprzedniego i biezacego dnia)␍
␍Zapis wykresu z parametrami pracy sterownika jako obrazu do pliku PNG.␍
(tylko z poprzedniego i biezacego dnia)␍
␍Przeglad zarejestrowanych stanów awaryjnych.␍
␍
␍Przeglad statystyk polaczenia sterownika z systemem.␍
␍
␍Wyslanie wiadomosci e-mail o wystapieniu stanu awaryjnego.␍
␍
␍
Obslugiwane sterowniki
System FRISKO-ONLINE umozliwia zdalna obsluge oraz rejestracje parametrów pracy sterowników FRISKO serii S20, MR210, MR208, MR65, RX910 i ATTO podlaczonych do sieci lokalnej majacej dostep do internetu za posrednictwem modulów komunikacyjnych:
* EM500, EM510 wbudowanych do sterowników typu MR210 oraz MR208,
* EM1206 wbudowanych do sterowników typu S20,
* MK01 zewnetrznych, pracujacych jako niezalezne urzadzenia, polaczonych ze sterownikami za posrednictwem magistrali RS485.
Do jednego modulu zewnetrznego MK01 moze byc podlaczony tylko jeden sterownik. Niezaleznie od typu moduly komunikacyjne musza byc zaprogramowane dedykowana aplikacja oraz zarejestrowane w bazie systemu. FRISKO-ONLINE do komunikacji ze sterownikami (modulami) oraz aplikacja dostepowa wykorzystuje porty 9010 i 9020. Podlaczajac sterownik do sieci ethernet nalezy zadbac o to, aby inne aplikacje, w szczególnosci Firewall, nie blokowaly tych portów.
Rozwiazania zaimplementowane w systemie pozwalaja na udostepnienie zdalnego dostepu do obiektu serwisowi firmowemu jak i koncowemu uzytkownikowi instalacji. Nie wymagaja stalego adresu IP sterownika (modulu komunikacyjnego) ani jakichkolwiek przekierowan portów na routerze - po podlaczeniu modulu do sieci ethernetowej sterownik jest dostepny w systemie.
Obslugiwane przez system sa sterowniki, z wersja oprogramowania 01.01.2013 i wyzsza. W celu podlaczenia sterowników ze starszym oprogramowaniem prosimy o kontakt.
Obsluga aplikacji dostepowej
Do celów demonstracyjnych zostal udostepniony sterownik MR208-M3+ sterujacy praca trzech obiegów CO z mieszaczami oraz ladowaniem zasobnika CWU. Obwody zasilane sa z niezaleznej kotlowni. W wersji demonstracyjnej nie ma mozliwosci zmiany nastaw parametrów. Aplikacja dostepna jest do pobrania pod ponizszym adresem.
https://www.frisko.pl/online/FriskoOnlineMR208M3Ppro.exe
Aplikacja pracuje pod kontrola systemu Windows. Na komputerze musi byc zainstalowana Java (minimalna wersja JRE 1.6.0_07). Pierwsze uruchomienie aplikacji moze potrwac kilkanascie sekund w zaleznosci od szybkosci lacza i samego komputera. W trakcie uruchamiania aplikacji komputer moze wyswietlic okno z zapytaniem o akceptacje dostawcy oprogramowania. Nalezy zaakceptowac dostawce. W przypadku problemów z polaczeniem z serwerem nalezy sprawdzic konfiguracje polaczenia sieciowego "Network Settings" w panelu kontrolnym Java. Typ polaczenia nalezy ustawic na "Direct Connection".
Logowanie
Po uruchomieniu aplikacji zostanie wyswietlony ekran logowania.
W celu polaczenia sie z demonstracyjna instalacja nalezy w polu "Uzytkownik" wpisac demo, w polu "Haslo" - frisko a nastepnie nacisnac klawisz <Zaloguj>. W celu polaczenia sie z zarzadzanym przez siebie obiektem nalezy uzyc danych otrzymanych od administratora systemu.
Po poprawnym zalogowaniu zostanie wyswietlone okno na którym nalezy wybrac obiekt z którym chcemy sie polaczyc i nacisnac przycisk <Polacz>.
Po polaczeniu wyswietlone zostanie okno przedstawiajace uklad technologiczny instalacji lub plansze ze stanem wejsc i wyjsc sterownika obslugujacego wybrana instalacje, w zaleznosci od wersji aplikacji.
Obsluga aplikacji jest intuicyjna. Po prawej stronie znajduje sie pole zegara sterownika oraz ponizej przyciski menu sterownika umozliwiajace wyswietlanie plansz z parametrami opisujacymi wybrana z menu funkcje, obwód itp. Organizacja menu, dostepnosc funkcji i parametrów jest analogiczna jak ta opisana w dokumentacji do sterownika podlaczonego do systemu.
W dolnej czesci okna znajduje sie pole statusu polaczenia ze sterownikiem i realizowanych operacji.
Edycja wartosci parametru
W celu zmiany wartosci danego parametru nalezy ustawic wskaznik myszy w polu wybranego parametru i kliknac lewy przycisk myszy. Zostanie wyswietlone okno edycji parametru zalezne od typu parametru.
Parametr typu wartosc Parametr typu lista
Dla parametru liczbowego nalezy w polu wartosci wpisac nowa wartosc i przycisnac klawisz <OK>. Analogicznie dla parametru typu lista nalezy wybrac nowa wartosc z rozwijanej listy i przycisnac klawisz <OK>. Po zaakceptowaniu zmian stara wartosc parametru zostaje wyswietlona na kolor pomaranczowy do czasu fizycznej zmiany w sterowniku na nowa wartosc. Nacisniecie klawisza <ESC> na oknie edycji parametru powoduje zamkniecie okna bez zmiany wartosci edytowanego parametru.
System buforuje wprowadzane zmiany parametrów, co pozwala na edycje kolejnych parametrów bez oczekiwania na fizyczna zmiane poprzednich nastaw. Takie rozwiazanie znacznie poprawia komfort i szybkosc obslugi (modyfikacji wartosci parametrów) sterownika.
Edycja programu dobowego
Edycja poszczególnych parametrów programu dobowego odbywa sie w sposób analogiczny do powyzej opisanej. Nazwy i znaczenie poszczególnych parametrów sa takie same jak w opisie funkcji "Progamy" w dokumentacji sterownika.
Edytujac program nalezy zwrócic szczególna uwage na zmiane typu obwodu i dnia tygodnia, dla którego wyswietlany jest program. Zmieniajac te parametry nalezy bezwzglednie poczekac az ich wartosci zostana fizycznie zmienione w sterowniku (kolor wyswietlanej wartosc parametru zmieni sie z pomaranczowej na niebieska). Dopiero wtedy zostana odswiezone nastawy przedzialów czasowych dla wybranego programu i dopiero wtedy mamy pewnosc, ze edytujemy wlasciwy program dobowy. Nieprzestrzeganie tej zasady moze spowodowac zmiane przedzialów czasowych dla niewlasciwego obwodu grzewczego lub dnia tygodnia.
Obsluga wykresów dobowych
Ekran wykresów dobowych dostepny jest po wyborze z menu funkcji <Wykresy dobowe>. Przykladowy ekran przedstawia ponizszy rysunek.
W górnej czesci ekranu wyswietlany jest dzien (data) oraz nazwa obiektu z którego pochodza dane przedstawione na wykresie. Pod polem z wykresem wyswietlana jest legenda oraz pasek klawiszy funkcyjnych:
wyswietlenie wykresu z poprzedniego dnia, eksport danych do pliku *.csv,
zapis wykresu do pliku *.png, drukowanie wykresu,
wybór rodzaju danych (temperatur) prezentowanych na wykresie, odswiezenie wykresu,
wybór dnia (daty) z którego zostanie wyswietlony wykres, wyswietlenie wykresu z kolejnego dnia.
Wybór rodzaju danych (temperatur) prezentowanych na wykresie
Nacisniecie klawisza powoduje wyswietlanie okna umozliwiajacego wybór rejestrów (temperatur) których przebiegi zostana wyswietlone na wykresie.
Rejestry, których przebiegi maja byc wyswietlane na wykresie, musza zostac zaznaczone na powyzszym oknie. Rejestry odznaczone nie beda wyswietlane na wykresie.
Wybór dnia (daty) z którego ma byc wyswietlony wykres
Nacisniecie klawisza powoduje wyswietlanie kalendarza umozliwiajacego wybór dnia (daty) dla którego bedzie wyswietlony wykres z danymi zaleznie od typu wykresu.
Obsluga kalendarza jest intuicyjna i nie wymaga komentarza.
Skalowanie wykresu
Wykres mozna powiekszac intuicyjnie przy uzyciu myszy. W tym celu nalezy trzymajac przycisniety lewy klawisz myszy zaznaczyc obszar wykresu, który chcemy powiekszyc. W celu powrotu do pierwotnego rozmiaru wykresu nalezy trzymajac przycisniety lewy klawisz myszy przesunac wskaznik myszy w kierunku lewego górnego rogu okna wykresu a nastepnie zwolnic klawisz myszy.
Eksport danych do pliku *.csv
Nacisniecie klawisza powoduje wyswietlanie okna dialogowego umozliwiajacego zapis na dysku pliku w formacie *.csv.
Plik zawiera tylko dane rejestrów, których przebiegi sa wyswietlane na wykresie. W pliku nie sa zapisane wartosci odznaczonych rejestrów. Znak ";" jest separatorem danych. Nazwa pliku generowana jest automatycznie, ale mozna ja zmienic. Pliki typu csv mozna otworzyc i poddac analizie praktycznie w dowolnym arkuszu kalkulacyjnym.
Zapis okna wykresu jako obraz do pliku graficznego *.png
Nacisniecie klawisza powoduje wyswietlanie okna dialogowego umozliwiajacego zapis obrazu wykresu na dysku do pliku graficznego w formacie *.png.
W celu zapisu pliku nalezy w polu "Files of Type" wybrac filtr "Pliki graficzne PNG", w polu "File Name" wpisac nazwe pliku a nastepnie nacisnac klawisz <Save>.
Wydruk okna wykresu
Nacisniecie klawisza powoduje wyswietlanie okna dialogowego umozliwiajacego wybór drukarki, formatu papieru itp. oraz ostatecznie wydruk obrazu wykresu.
Zakonczenie sesji
W celu zakonczenia sesji i przerwania polaczenia ze sterownikiem nalezy wybrac klawisz
<Obiekt> z funkcji menu a nastepnie, na kolejnym oknie nacisnac klawisz <Wyloguj>.
+541
View File
@@ -0,0 +1,541 @@
# Prompt do ulepszenia aplikacji Home Pump
## Audyt architektury i plan napraw logicznych
Poniższa sekcja jest roboczym rejestrem problemów architektonicznych i logicznych wykrytych podczas przeglądu backendu oraz frontendu. Ma służyć jako:
- lista problemów do domknięcia,
- plan wykonania zmian,
- miejsce do odnotowania co zostało już naprawione,
- skrócony dziennik walidacji po wdrożeniu.
### Statusy
- `[OPEN]` — problem potwierdzony, jeszcze nie naprawiony
- `[IN_PROGRESS]` — naprawa rozpoczęta
- `[DONE]` — naprawa wdrożona i zweryfikowana testami
- `[PARTIAL]` — częściowo naprawione, ale wymaga dalszych zmian modelu
### Priorytety napraw
| ID | Priorytet | Status | Obszar | Skrót |
|---|---|---|---|---|
| A1 | Krytyczny | DONE | Sterowanie | `programMode` i harmonogram nie wpływają na algorytm sterowania |
| A2 | Krytyczny | DONE | CWU | Dezynfekcja CWU nie wpływa na rzeczywisty target sterowania |
| A3 | Wysoki | DONE | API/System state | UI dostaje stan obwodów z `ManualControlUseCase`, a nie z realnego odczytu urządzenia |
| A4 | Wysoki | DONE | Modbus write path | Błędy zapisu Modbus są logowane i ukrywane zamiast eskalowane |
| A5 | Wysoki | DONE | Semantyka danych | Mapowanie temperatur CO/pokojowych jest niejednoznaczne |
| A6 | Średni | DONE | Manual control | Akcja `AUTO` dla pojedynczego obwodu ma już jawną semantykę override/follow-auto |
| A7 | Średni | DONE | Model domenowy | Model domenowy został doprecyzowany do spójnego etapu 1 bez pozornych funkcji legacy |
---
## Problem A1 — `programMode` i harmonogram są logicznie martwe
**Status:** `[DONE]`
### Objawy
- `ManualControlUseCase` przechowuje `programMode` i harmonogram oraz cyklicznie go aplikuje.
- `ControlLoopUseCase` nie używa `programMode` podczas wyznaczania komend.
- Frontend oraz API sugerują, że `ECO` i `COMFORT` wpływają na pracę systemu, ale obecnie są tylko stanem administracyjnym.
### Skutek biznesowy
- Użytkownik widzi zmianę trybu programu, ale układ nie zmienia zachowania.
- Harmonogram działa wyłącznie na poziomie „flag”, a nie realnej regulacji.
- To tworzy fałszywe poczucie działania funkcji.
### Aktualna przyczyna techniczna
- `ManualControlUseCase.applyScheduleIfDue()` zmienia `programMode`.
- `ControlLoopUseCase.executeControlLoop()` pobiera tylko `operatingMode` i `seasonMode`.
- `HeatingCircuit` nie ma modelu targetu zależnego od programu.
### Plan naprawy
1. Wprowadzić jawny model programu sterowania do pętli sterowania.
2. Sprawić, aby `programMode` wpływał na target obwodów albo na poziomie strategii, albo na poziomie use case.
3. Dodać testy pokazujące, że harmonogram faktycznie zmienia wynik sterowania.
### Kryterium ukończenia
- Zmiana `programMode` powoduje inną decyzję sterownika w testach jednostkowych.
- Harmonogram po przełączeniu trybu zmienia zachowanie `ControlLoopUseCase`, a nie tylko zapis stanu.
### Walidacja po wdrożeniu
- `ControlLoopUseCase` używa teraz `programMode` do modyfikacji targetu obiegów CO.
- Dodano test jednostkowy potwierdzający, że `COMFORT` zmienia decyzję sterowania.
---
## Problem A2 — dezynfekcja CWU nie jest podłączona do sterowania
**Status:** `[DONE]`
### Objawy
- `CwuDisinfectionService` ma własny cykl życia i metodę `getEffectiveCwuTarget(...)`.
- Scheduler wywołuje `tick()`.
- API zwraca status aktywnej dezynfekcji.
- Sama logika sterowania nie korzysta z efektywnego targetu z dezynfekcji.
### Skutek biznesowy
- Funkcja wygląda na wdrożoną, ale nie podnosi realnie temperatury CWU podczas cyklu antylegionella.
- To jest potencjalnie niebezpieczne, bo UI może sugerować, że procedura działa.
### Aktualna przyczyna techniczna
- `ControlLoopUseCase` aktualizuje `CwuPriorityCoordinator` wyłącznie z `ControlPolicySettings`.
- `CwuDisinfectionService` nie jest używany przy wyznaczaniu thresholdów CWU.
### Plan naprawy
1. Wstrzyknąć `CwuDisinfectionService` do pętli sterowania.
2. Liczyć efektywny target CWU na podstawie normalnego targetu i aktywności dezynfekcji.
3. Dodać test jednostkowy potwierdzający zmianę targetu CWU podczas aktywnego cyklu.
### Kryterium ukończenia
- Aktywna dezynfekcja CWU realnie podnosi target w sterowaniu.
- Endpoint statusowy i faktyczne sterowanie są spójne.
### Walidacja po wdrożeniu
- `ControlLoopUseCase` pobiera efektywny target CWU z `CwuDisinfectionService`.
- Dodano test jednostkowy potwierdzający aktywację sterowania CWU podczas dezynfekcji.
---
## Problem A3 — UI dostaje stan obwodów z pamięci aplikacji, a nie z urządzenia
**Status:** `[DONE]`
### Objawy
- `SystemController` buduje odpowiedź schematu z `ManualControlUseCase.State`.
- `ManualControlUseCase` aktualizuje stan obwodów głównie przy akcjach ręcznych.
- Auto-sterowanie i fail-safe nie synchronizują tego stanu z tym, co faktycznie jest na urządzeniu.
### Skutek biznesowy
- Frontend może prezentować nieaktualny lub całkowicie fałszywy stan pomp i zaworów.
- Użytkownik nie wie, czy ogląda stan zadany, zapamiętany czy faktycznie odczytany.
### Aktualna przyczyna techniczna
- Brakuje jawnego rozdziału `desired state` vs `actual state`.
- `SystemReadings` nie niesie pełnej informacji o stanach wykonawczych.
### Plan naprawy
1. Rozszerzyć model odczytu systemu o rzeczywiste stany wyjść wykonawczych.
2. Odczytywać je z adaptera Modbus.
3. Zmienić `SystemController`, aby preferował stan rzeczywisty z urządzenia.
4. Pozostawić stan z `ManualControlUseCase` tylko jako fallback lub informację pomocniczą.
### Kryterium ukończenia
- Schemat systemu opiera się na danych z urządzenia, nie wyłącznie na pamięci aplikacji.
- Fail-safe i sterowanie automatyczne są widoczne w stanie zwracanym przez API.
### Walidacja po wdrożeniu
- `SystemReadings` przenosi rzeczywiste stany wyjść.
- `SystemController` preferuje stany z urządzenia, a pamięć aplikacji traktuje jako fallback.
---
## Problem A4 — błędy zapisu Modbus są maskowane
**Status:** `[DONE]`
### Objawy
- Błąd zapisu pompy lub zaworu jest logowany, ale nie zatrzymuje ścieżki sterowania.
- `applyCommands(...)` może skończyć się częściowym sukcesem bez sygnału do wyższej warstwy.
### Skutek biznesowy
- Możliwy jest stan częściowo zapisanych komend.
- Aplikacja może uważać, że wysłała poprawne sterowanie, chociaż urządzenie wykonało tylko część rozkazów.
### Aktualna przyczyna techniczna
- `ModbusDeviceAdapter.applyPumpCommand(...)` i `applyValveCommand(...)` łapią wyjątek i jedynie logują.
### Plan naprawy
1. Agregować błędy zapisu i eskalować je do use case.
2. Nie aktualizować stanu ręcznego po nieudanym zapisie.
3. Dodać testy pokazujące, że wyjątek przy zapisie nie jest ukrywany.
### Kryterium ukończenia
- Nie ma cichego częściowego sukcesu przy zapisie komend.
- Warstwa wyżej dostaje informację o błędzie.
### Walidacja po wdrożeniu
- `ModbusDeviceAdapter` agreguje błędy zapisu i rzuca wyjątek do wyższej warstwy.
- Dodano test jednostkowy potwierdzający brak cichego sukcesu przy błędzie zapisu.
---
## Problem A5 — mapowanie temperatur ma niejednoznaczną semantykę
**Status:** `[DONE]`
### Objawy
- W odczycie telemetrycznym i operacyjnym `R_TWE1/R_TWE2/R_TWE3` bywają używane jako fallback dla temperatur obiegów.
- Te same rejestry są równolegle interpretowane jako temperatury pokojowe.
### Skutek biznesowy
- Możliwe są błędne decyzje sterowania i nieprawidłowe wykresy.
- Debugowanie problemów temperatur staje się trudne, bo jeden sygnał ma dwie semantyki.
### Plan naprawy
1. Rozdzielić semantykę temperatur obiegów i temperatur pokojowych.
2. Dla CO1/CO2/CO3 używać tylko dedykowanych symboli obiegowych.
3. Zostawić fallback po opisach tylko dla legacy nazw typu `temp in` / `temp out`, ale nie dla pokojowych `R_TWE*`.
### Kryterium ukończenia
- Żaden rejestr pokojowy nie jest już używany jako fallback dla temperatury obiegu.
### Walidacja po wdrożeniu
- Usunięto fallback z `R_TWE*` do temperatur obiegów CO.
- Dodano test jednostkowy potwierdzający, że rejestr pokojowy nie jest interpretowany jako temperatura CO.
---
## Problem A6 — `AUTO` dla pojedynczego obwodu ma mylącą semantykę
**Status:** `[DONE]`
### Opis
Wcześniej akcja `AUTO` dla obwodu mapowała się do pozornej komendy `HOLD/KEEP_STATE`, a system nie miał trwałego modelu trybu auto-per-obwód.
### Plan
- Wprowadzić jawny model override na poziomie pojedynczego obiegu.
- Rozdzielić stany `AUTO`, `FORCED_ON` i `FORCED_OFF`.
- Utrwalić ten stan w persistence, API i UI.
### Kryterium ukończenia
- API i implementacja są semantycznie spójne.
- `AUTO` oznacza „śledź automat”, a nie ukrytą komendę pseudo-ręczną.
### Walidacja po wdrożeniu
- Dodano jawny model `CircuitOverrideMode` z wartościami `AUTO`, `FORCED_ON`, `FORCED_OFF`.
- `ManualControlUseCase` zapisuje i odczytuje override per obwód.
- `ControlLoopUseCase` respektuje override w trybie automatycznym.
- `SqliteControlStateStore`, API oraz frontend pokazują już realny stan override zamiast pozornej semantyki `HOLD/KEEP_STATE`.
---
## Problem A7 — model `HeatingCircuit` był zbyt uproszczony względem semantyki systemu
**Status:** `[DONE]`
### Opis
Problemem nie była konieczność pełnego odtworzenia legacy Frisko 1:1, tylko to, że uproszczony model tworzył martwe lub mylące funkcje: `programMode` bez wpływu na sterowanie, brak realnego override per obwód, brak wykorzystania dezynfekcji CWU i rozjazd między stanem rzeczywistym a pamiętanym.
### Plan
- Domknąć najpierw wszystkie uproszczenia, które powodowały fałszywą semantykę systemu.
- Rozszerzyć model tylko tam, gdzie ma to bezpośredni wpływ na decyzje sterowania i kontrakt API.
- Nie kopiować całego legacy Frisko bez potrzeby biznesowej.
### Kryterium ukończenia
- Krytyczne niespójności zostały usunięte.
- Aktualny model nie obiecuje funkcji, których nie realizuje.
- Dalsza rozbudowa może być wykonywana jako świadomy etap 2, a nie jako naprawa błędu logicznego.
### Walidacja po wdrożeniu
- `programMode` wpływa już na decyzję sterowania.
- Dezynfekcja CWU wpływa na efektywny target.
- Per-obwodowy override ma jawny model domenowy.
- API schematu preferuje stan rzeczywisty urządzenia.
- Pozostała ewolucja modelu Frisko ma już charakter rozwoju funkcjonalnego, a nie naprawy niespójności architektonicznej.
---
## Plan wykonania prac
### Etap 1 — spójność logiki sterowania
1. Naprawa A2: podłączyć dezynfekcję CWU do pętli sterowania.
2. Naprawa A1: sprawić, aby `programMode` wpływał na decyzje sterowania.
3. Naprawa A4: przestać ukrywać błędy zapisu Modbus.
### Etap 2 — spójność odczytu i prezentacji stanu
4. Naprawa A3: rozdzielić stan rzeczywisty i stan pamiętany.
5. Naprawa A5: uporządkować mapowanie temperatur.
### Etap 3 — porządkowanie semantyki API
6. Naprawa A6: doprecyzować semantykę `AUTO` per obwód.
7. Naprawa A7: przygotować kolejny etap ewolucji modelu domenowego.
### Dziennik wykonania
- 2026-03-07: utworzono rejestr problemów i plan napraw.
- 2026-03-07: wdrożono naprawy A1-A5 oraz domknięto A6 przez wprowadzenie trwałego modelu override per obwód (`CircuitOverrideMode`).
- 2026-03-07: zweryfikowano zmiany pakietem testów jednostkowych (`ControlLoopUseCaseTests`, `ManualControlUseCaseTests`, `ModbusDeviceAdapterReadingsBySymbolTests`, `ModbusDeviceAdapterWriteMappingTests`, `SqliteControlStateStoreTests`, `ControlApiContractIntegrationTests`).
- 2026-03-07: testowy profil integracyjny przełączono na mockowany Modbus, dzięki czemu kontrakty API i scenariusze Frisko są deterministyczne.
- 2026-03-07: pełny `mvn test` zakończył się powodzeniem (`EXIT_CODE:0`).
Poniżej jest gotowy, szczegółowy prompt do wklejenia agentowi.
---
## PROMPT START
Pracujesz nad aplikacją **Home Pump** — systemem sterowania pompą ciepła (sterownik Frisko MR208 M3P) z backendem Java/Spring Boot i frontendem React/TypeScript/MUI/Recharts. Architektura heksagonalna (Ports & Adapters). Komunikacja ze sterownikiem przez Modbus RTU over TCP (192.168.0.150:502). Dane historyczne w InfluxDB (`Dom_Pompa`).
**NIE TWÓRZ** eksportu CSV. **NIE TWÓRZ** powiadomień email/alarm. Skup się WYŁĄCZNIE na czterech zadaniach poniżej.
---
## ZADANIE 1: Dokończ telemetrię
### Stan obecny
- Backend `TelemetryPoint.java` (`src/main/java/home/automation/modbus/TelemetryPoint.java`) zbiera 16 pól: `timestamp`, `externalTemperature`, `tempIn`, `tempOut`, `status`, `targetTempCo1/Co2/Co3/Cwu/Analog`, `calibrationExternal/Tzas/Co1/Co2/Co3/Cwu`.
- `ModbusPollingService.addTelemetrySample()` mapuje rejestry po opisie (np. `R_TZEW`→`externalTemperature`, `R_TCO1`→`tempIn`, `R_TCO2`→`tempOut`).
- Frontend type `TelemetryPoint` (`frontend/src/types/telemetry.ts`) ma TYLKO 4 pola: `timestamp`, `externalTemperature`, `tempIn`, `tempOut` — **pozostałe 12 pól jest ignorowanych**.
- Wykresy (`TelemetryLineChart`, `TelemetryAreaChart`) pokazują tylko `externalTemperature`, `tempIn`, `tempOut`.
- KPI cards pokazują tylko `External Temperature`, `Temp In`, `Temp Out`.
### Brakujące rejestry telemetryczne Frisko (nie są zbierane)
Backend NIE odczytuje tych rejestrów w ramach telemetrii (brak w `registryMapping.json`):
- `R_TZAS` (4068) — temperatura zasilania
- `R_TCO3` (4071) — temperatura obwodu CO3
- `R_TWE1` (4072), `R_TWE2` (4073), `R_TWE3` (4074) — temperatury wewnętrzne (pokojowe)
- `R_TCWU` (4075) — temperatura CWU (jest w SystemReadings, ale nie w telemetrii front)
- `R_TZEW_WYS` (4225) — przefiltrowana temperatura zewnętrzna
- `R_TZAS_WYS` (4249) — przefiltrowana temperatura zasilania
- `R_TZSR` (4224) — średnia temperatura zewnętrzna
- Stany wyjść: `WY_P1`(4076), `WY_P2`(4077), `WY_P3`(4078), `WY_ZCO1`(4079), `WY_ZCO2`(4080), `WY_ZCO3`(4081)
### Co zrobić
1. **Backend** — dodaj brakujące rejestry do `registryMapping.json` (plik `src/main/resources/registryMapping.json` i `target/classes/registryMapping.json`):
- `R_TZAS` (4068, scale 0.1, TWO_BYTE_INT_SIGNED)
- `R_TCO3` (4071, scale 0.1)
- `R_TWE1` (4072, scale 0.1), `R_TWE2` (4073), `R_TWE3` (4074)
- `R_TCWU` (4075, scale 0.1)
- `R_TZSR` (4224, scale 0.1)
- `WY_P1`–`WY_P3` (4076–4078, scale 1.0, integer)
- `WY_ZCO1`–`WY_ZCO3` (4079–4081, scale 1.0, integer)
2. **Backend** — rozszerz `TelemetryPoint.java` o nowe pola: `supplyTemperature` (R_TZAS), `co3Temperature` (R_TCO3), `room1Temperature`/`room2Temperature`/`room3Temperature` (R_TWE1-3), `cwuTemperature` (R_TCWU), `avgExternalTemperature` (R_TZSR), `pumpCo1`/`pumpCo2`/`pumpCwu` (WY_P1-3, jako Boolean), `valveCo1`/`valveCo2`/`valveCo3` (WY_ZCO1-3, jako Double 0-100).
3. **Backend** — zaktualizuj `ModbusPollingService.addTelemetrySample()` aby wypełniał nowe pola.
4. **Frontend** — zaktualizuj `frontend/src/types/telemetry.ts` o WSZYSTKIE pola z backendu (`TelemetryPoint`), włącznie z nowymi.
5. **Frontend** — zaktualizuj `frontend/src/mocks/telemetryMock.ts` aby generował dane dla nowych pól.
6. **Frontend** — dodaj KPI cardsy CWU Temperature i Supply Temperature (R_TZAS) obok istniejących w `App.tsx`.
---
## ZADANIE 2: Dodaj logikę schedulera (harmonogram Frisko)
### Stan obecny
- Backend ma `ProgramScheduleSlot` (dayOfWeek, time, programMode ECO/COMFORT) przechowywany w SQLite.
- `ManualControlUseCase.applyScheduleIfDue()` sprawdza co minutę i przełącza `ProgramMode`.
- Frontend `ProgramSchedulePanel` pozwala dodawać sloty dzień+godzina+tryb.
- **Problem**: harmonogram jest TYLKO aplikacyjny — NIE synchronizuje się z rejestrami PROG_* sterownika Frisko.
### Rejestry harmonogramu Frisko (z `mr208_m3p.csv`)
Sterownik Frisko ma 4 sloty czasowe na dzień na obwód:
- `PROG_DZIEN` (4199) — wybrany dzień tygodnia (1-7)
- `PROG_OBW` (4209) — wybrany obwód
- `PROG_GODZ1`–`PROG_GODZ4` (4201–4204) — godziny slotów 1-4
- `PROG_MIN1`–`PROG_MIN4` (4205–4208) — minuty slotów 1-4
- `PROG_COPY` (4200) — kopiowanie programu między dniami
- `PAR_PRG` (4179) — numer programu
### Co zrobić
1. **Backend** — dodaj rejestry PROG_* do `registryMapping.json`:
- `PROG_DZIEN` (4199, scale 1.0, integer, min 1, max 7)
- `PROG_OBW` (4209, scale 1.0, integer)
- `PROG_GODZ1`–`PROG_GODZ4` (4201–4204, scale 1.0, integer, min 0, max 23)
- `PROG_MIN1`–`PROG_MIN4` (4205–4208, scale 1.0, integer, min 0, max 59)
- `PAR_PRG` (4179, scale 1.0, integer)
2. **Backend** — stwórz nowe DTO `FriskoScheduleSlot`:
```java
record FriskoScheduleSlot(int dayOfWeek, int circuit, int hour1, int min1, int hour2, int min2, int hour3, int min3, int hour4, int min4) {}
```
3. **Backend** — dodaj do `FriskoRuntimeService` metody:
- `readScheduleForDayAndCircuit(int dayOfWeek, int circuit)` — ustawia PROG_DZIEN i PROG_OBW przez Modbus write, czeka 200ms, odczytuje GODZ1-4/MIN1-4
- `writeScheduleSlot(FriskoScheduleSlot slot)` — ustawia PROG_DZIEN, PROG_OBW, potem zapisuje GODZ1-4/MIN1-4
4. **Backend** — dodaj endpointy w `FriskoAnalysisController`:
- `GET /api/v1/frisko/schedule?day={1-7}&circuit={id}` → `FriskoScheduleSlot`
- `POST /api/v1/frisko/schedule` z body `FriskoScheduleSlot` → zapisuje do sterownika
5. **Frontend** — stwórz nowy panel `FriskoSchedulePanel` w `frontend/src/components/panels/`:
- Dropdown wyboru dnia tygodnia (Poniedziałek–Niedziela) i obwodu (CO1, CO2, CO3, CWU)
- Widok 4 slotów czasowych (godzina:minuta) z edycją inline
- Przycisk "Odczytaj z sterownika" i "Zapisz do sterownika"
- Wizualizacja timeline 24h (pasek kolorowy — comfort=ciepły kolor, eco=chłodny) — wzór z Frisko `WizualProgram`
6. **Frontend** — dodaj hook `useFriskoSchedule.ts`, service `friskoScheduleService.ts`, typy w `types/frisko.ts`.
7. **Frontend** — wstaw `FriskoSchedulePanel` w `App.tsx` obok istniejącego `ProgramSchedulePanel`.
---
## ZADANIE 3: Dodaj progi rejestrów (parametry sterownika)
### Stan obecny
- Backend `FriskoRuntimeEditorPanel` pozwala ręcznie zapisywać do dowolnego rejestru PAR_*/KONF_*.
- Brak dedykowanego UI do zarządzania progami i parametrami obiegów.
- `ControlPolicySettings` ma tylko `cwuTargetTemperature`, `cwuHysteresis`, `minPumpRunSeconds`, `minPumpStopSeconds`.
### Rejestry progów per obwód z Frisko
Każdy obwód (CO1: 4100–4120, CO2: 4121–4141, CO3: 4142–4162) ma:
- `PAR_COx_TEKO` — temperatura ECO
- `PAR_COx_TKMF` — temperatura COMFORT
- `PAR_COx_TMIN` — minimum temperatury
- `PAR_COx_TMAX` — maksimum temperatury
- `PAR_COx_DELTA` — histereza
- `PAR_COx_KRZYWA` — typ krzywej grzewczej
- `PAR_COx_PRZESKRZ` — przesunięcie krzywej
- `PAR_COx_T_WYL` — temperatura wyłączenia
- `PAR_COx_TZAD` — temperatura zadana
CWU (4167–4176):
- `PAR_CWU_TEKO`, `PAR_CWU_TKMF`, `PAR_CWU_DELTA`, `PAR_CWU_TMAX`, `PAR_CWU_T_WYL`
- `PAR_CWU_DEZYNF` (dezynfekcja antylegionella), `PAR_CWU_DEZDNI`, `PAR_CWU_DEZCZAS`
Globalne:
- `PAR_TPROGZ_L` (4099) — próg przełączenia lato/zima
- `PAR_TMINZAS` (4182) — min temperatura zasilania
- `PAR_TMAXZAS` (4183) — max temperatura zasilania
### Co zrobić
1. **Backend** — dodaj WSZYSTKIE rejestry PAR_CO1_*, PAR_CO2_*, PAR_CO3_*, PAR_CWU_*, PAR_TPROGZ_L, PAR_TMINZAS, PAR_TMAXZAS do `registryMapping.json` z poprawnymi: `desc` (symbol Frisko), `scale` (0.1 dla temperatur, 1.0 dla integer), `min`, `max`, `unit` (°C, min, itp.), `controlType` ("number").
2. **Backend** — stwórz `CircuitThresholds` record:
```java
record CircuitThresholds(
String circuitId,
Double ecoTemperature, Double comfortTemperature,
Double minTemperature, Double maxTemperature,
Double hysteresis, Double shutdownTemperature,
Double setpointTemperature, Integer heatingCurveType,
Double curveOffset
) {}
```
3. **Backend** — dodaj do `FriskoRuntimeService`:
- `readCircuitThresholds(String circuitId)` — odczytuje blok PAR_COx_* dla danego obwodu
- `writeCircuitThreshold(String circuitId, String paramName, double value)` — zapisuje pojedynczy próg
4. **Backend** — dodaj endpointy w `FriskoAnalysisController`:
- `GET /api/v1/frisko/thresholds/{circuitId}` → `CircuitThresholds`
- `POST /api/v1/frisko/thresholds/{circuitId}` z body `Map<String, Double>` → zapisuje progi
- `GET /api/v1/frisko/thresholds/global` → `{summerWinterThreshold, minSupplyTemp, maxSupplyTemp}`
- `POST /api/v1/frisko/thresholds/global` → zapisuje globalne progi
5. **Frontend** — stwórz panel `CircuitThresholdsPanel` (`frontend/src/components/panels/CircuitThresholdsPanel.tsx`):
- Tabsy lub accordion per obwód: CO1, CO2, CO3, CWU
- Dla CO1/CO2/CO3: edytowalne pola z labels po polsku:
- Temperatura ECO (°C), Temperatura COMFORT (°C)
- Min temperatura (°C), Max temperatura (°C)
- Histereza (°C), Temp. wyłączenia (°C)
- Krzywa grzewcza (typ), Przesunięcie krzywej
- Dla CWU: Temp ECO, Temp COMFORT, Histereza, Max temp, Dezynfekcja (tak/nie), Dni dezynf., Czas dezynf.
- Sekcja "Progi globalne": Próg lato/zima, Min temp zasilania, Max temp zasilania
- Przycisk "Odczytaj" per obwód i "Zapisz" per obwód
- Wizualne oznaczenie zmienionych wartości (pomarańczowe tło — wzór z Frisko `APParUSER`)
6. **Frontend** — dodaj hook `useCircuitThresholds.ts`, service do `friskoService.ts`, rozszerz typy w `types/frisko.ts`.
7. **Frontend** — wstaw `CircuitThresholdsPanel` w `App.tsx` — poniżej ControlPanel, przed FriskoRegistersPanel.
---
## ZADANIE 4: Wykresy — czytelne, osobne, użyteczne
### Stan obecny
Obecne wykresy to:
1. `TelemetryLineChart` — linie External + Temp In na jednym wykresie (mało czytelne, brak legendy)
2. `TelemetryAreaChart` — area Temp Out (sam, ok)
3. `LegacyAnalyticsPanels` — delta + bar chart min/avg/max (pomocnicze)
### Problemy
- Dwa różne sygnały o różnych zakresach (zewnętrzna -20..+40°C vs wewnętrzna 20..60°C) na jednym wykresie = nieczytelne
- Brak wykresów: CWU, temperatura zasilania, stany pomp/zaworów
- Brak zakresów setpoint vs actual (porównanie cel vs rzeczywistość)
### Co zrobić — ZASADA: jeden wykres = jedno zagadnienie, duży, czytelny
Zamień obecne wykresy na następujące **osobne, pełnoszerokie** komponenty. Każdy wykres to `Grid size={{ xs: 12 }}` — pełna szerokość, min 360px wysokości wykresu (nie karty, samego wykresu). Kolory ciemne tło (motyw obecny), kontrastowe linie, tooltip z dokładnymi wartościami.
1. **`ExternalTemperatureChart`** — NOWY, zastępuje TelemetryLineChart
- Osobny wykres TYLKO temperatury zewnętrznej (`externalTemperature`)
- Jedna linia, kolor cyan `#22d3ee`, gruba (3px)
- Y-axis zakres dynamiczny z paddingiem ±2°C
- Opcjonalnie: linia przerywana `avgExternalTemperature` (R_TZSR) jako średnia krocząca
- Tytuł: "Temperatura zewnętrzna"
2. **`CircuitTemperaturesChart`** — NOWY, zastępuje obecną część TelemetryLineChart
- Osobny wykres temperatur obiegów: CO1 (`tempIn`), CO2 (`tempOut`), CO3 (`co3Temperature`)
- 3 linie w różnych kolorach: CO1=`#f97316` (orange), CO2=`#3b82f6` (blue), CO3=`#a855f7` (purple)
- Legenda na dole
- Tytuł: "Temperatury obiegów CO"
3. **`CwuTemperatureChart`** — NOWY
- Osobny wykres temperatury CWU (`cwuTemperature`) vs setpoint (`targetTempCwu`)
- Area fill dla actual, przerywana linia dla setpoint
- Kolory: actual=`#ef4444` (red fill), setpoint=`#fbbf24` (yellow dashed)
- Tytuł: "Temperatura CWU (rzeczywista vs zadana)"
4. **`SupplyTemperatureChart`** — NOWY
- Osobny wykres temperatury zasilania (`supplyTemperature`)
- Area chart z gradientem, kolor `#10b981` (emerald)
- Tytuł: "Temperatura zasilania"
5. **`PumpValveStatusChart`** — NOWY
- Wykres stanów pomp i zaworów w czasie (step chart / binary timeline)
- Pola: `pumpCo1`, `pumpCo2`, `pumpCwu` (boolean → 0/1), `valveCo1`, `valveCo2`, `valveCo3` (0-100%)
- Pompy jako step lines (ON/OFF), zawory jako area (0-100%)
- Oddzielne Y-axis: lewa = pompy (0/1), prawa = zawory (0-100%)
- Kolory: pompy solid, zawory przerywane
- Tytuł: "Stany wyjść (pompy i zawory)"
### Usunięcia
- **Usuń** `TelemetryLineChart.tsx` (zastąpiony przez ExternalTemperatureChart + CircuitTemperaturesChart)
- **Usuń** `TelemetryAreaChart.tsx` (zastąpiony przez SupplyTemperatureChart)
- **Zachowaj** `LegacyAnalyticsPanels` ale rozszerz o nowe metryki (CWU, supply)
### Układ w App.tsx (nowy porządek sekcji wykresów)
```tsx
{/* KPI cards — rozszerzone o CWU i Supply temp */}
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}> KpiCard External </Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}> KpiCard Temp In (CO1) </Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}> KpiCard Temp Out (CO2) </Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}> KpiCard CWU Temp </Grid>
<Grid size={{ xs: 12, sm: 6, md: 2.4 }}> KpiCard Supply Temp </Grid>
{/* Wykresy — każdy full-width, osobny */}
<Grid size={{ xs: 12 }}> ExternalTemperatureChart </Grid>
<Grid size={{ xs: 12 }}> CircuitTemperaturesChart </Grid>
<Grid size={{ xs: 12 }}> CwuTemperatureChart </Grid>
<Grid size={{ xs: 12 }}> SupplyTemperatureChart </Grid>
<Grid size={{ xs: 12 }}> PumpValveStatusChart </Grid>
{/* Reszta dashboardu */}
<Grid ...> HealthPanel </Grid>
<Grid ...> SchematicPreviewPanel </Grid>
<Grid ...> ControlPanel </Grid>
<Grid ...> CircuitThresholdsPanel </Grid> ← NOWY z zadania 3
<Grid ...> ProgramSchedulePanel </Grid>
<Grid ...> FriskoSchedulePanel </Grid> ← NOWY z zadania 2
...
```
### Styl wykresów (wspólna konwencja)
- Tło karty: `background.paper` (#111827)
- Grid lines: `rgba(148,163,184,0.08)`
- Osie: `#64748b`, bez tick lines
- Tooltip: ciemne tło `#0f172a`, zaokrąglony border 12px, biały tekst
- Tytuły: `variant="h6"`, marginBottom 16px
- Font size na osiach: 12px
- Responsive container: `width="100%"` `height={360}`
- Recharts `<CartesianGrid strokeDasharray="3 3" stroke="rgba(148,163,184,0.08)" />`
- Każdy wykres w osobnym `<Card>` z `<CardContent>`
---
## WAŻNE ZASADY
1. **Nie zmieniaj istniejącej architektury** — hexagonal, ports & adapters, clean architecture. Dodawaj kod w odpowiednich warstwach.
2. **Nie dodawaj eksportu CSV** — eksplicytnie NIE jest chciany.
3. **Nie dodawaj powiadomień email** — eksplicytnie NIE jest chciane.
4. **Zachowaj istniejącą funkcjonalność** — wszystko co działa musi dalej działać.
5. **Konwencja nazw plików**: camelCase dla serwisów/hooków, PascalCase dla komponentów TSX.
6. **Testy**: dodaj unit testy dla nowych endpointów backendu w `src/test/java/`.
7. **Język UI**: etykiety po polsku (wzór z istniejących paneli: "Sterowanie", "Programator tygodniowy").
8. **registryMapping.json**: duplikuj zmiany w `src/main/resources/registryMapping.json` ORAZ `target/classes/registryMapping.json`.
## PROMPT END
+59
View File
@@ -0,0 +1,59 @@
# Audyt Techniczny i Status Migracji (Home Pump)
**Data audytu:** 22 lutego 2026
**Status:** 🟡 W TOKU NAPRAW (NIE GOTOWE DO PRODUKCJI)
Ten dokument służy jako punkt wejścia dla agentów AI i programistów. Zawiera zwięzłą listę zidentyfikowanych problemów architektonicznych i funkcjonalnych, które blokują wdrożenie systemu na produkcję.
---
## 🔴 Blockery (Krytyczne do wdrożenia)
Te elementy muszą zostać naprawione przed jakimkolwiek releasem. Obecny stan grozi uszkodzeniem sprzętu lub całkowitą nieużywalnością systemu.
- [x] **Konflikt pętli sterowania z trybem MANUAL:** `ControlLoopUseCase` respektuje `OperatingMode` i nie wysyła komend w trybie MANUAL.
- [x] **Egzekucja harmonogramów:** Dodano scheduler (`app.control.schedule-interval`) oraz `applyScheduleIfDue(...)` w `ManualControlUseCase`.
- [x] **Tryb LATO/ZIMA:** Dodano `SeasonMode` w domenie, API (`POST /api/v1/control/season`) i UI (panel sterowania).
- [x] **Mechanizm Fail-Safe:** Dodano próg `app.control.communication-fail-safe-seconds` i wymuszone STOP/CLOSE po długotrwałym braku odczytów.
- [x] **Przeciążenie magistrali Modbus:** W `AppConfig` `ModbusService` został spięty przez `ModbusCachedWrapper`.
---
## 🟡 Dług Technologiczny i Stabilność (Ważne)
Elementy kluczowe dla stabilności działania i poprawnego monitoringu.
- [x] **Retry bez blokującego sleep:** Usunięto `Thread.sleep()` z retry w `ModbusConnection` (brak blokowania schedulerów).
- [x] **Metryki InfluxDB:** `InfluxMetricsAdapter` publikuje temperaturę CWU i osobne pomiary z tagiem `circuit`.
- [x] **Testy integracyjne E2E:** Dodano testy dla alarm-flow (`shouldExposeAlarmFlowEndpoint`, `shouldReportAlarmStructureWithRegisters`) oraz fail-safe (`shouldApplyFailSafeAfterProlongedCommunicationFailure`). Brakuje jeszcze kompleksowych testów pełnego przepływu `Odczyt Modbus -> Logika Domenowa -> Zapis Modbus`.
- [ ] **Nadużywanie `synchronized`:** `ManualControlUseCase` używa `synchronized` na prawie każdej metodzie, co jest antywzorcem w Springu. Zastąpić przez `ReentrantLock` lub polegać na bazie danych.
---
## 🟠 Frisko Coverage (Nowe)
Weryfikacja katalogu `frisko/mr208_m3p.csv` potwierdziła, że intuicja była poprawna: pokrycie nie jest pełne i nie wszystkie feature’y Frisko są domknięte domenowo.
- [x] **Spójność raportu `/api/v1/frisko/features` (important):** logika coverage została zsynchronizowana z realnie wspieranymi symbolami sterowania i telemetrii (w tym fallbacki WY/TEST oraz rozszerzona telemetria `STATUS`/`TZAD`/`KAL`).
- [x] **Obiektywny status pokrycia:** `GET /api/v1/frisko/features` zwraca teraz statusy liczone z realnego pokrycia (`done/partial/missing`), a nie same stałe wartości.
- [~] **Sterowanie WY_/TEST_ (critical):** rozszerzono mapowanie o fallback `TEST_P*`/`TEST_ZCO*` oraz CO3, dodano testy zapisu per symbol; do domknięcia zostaje mapowanie pozostałych wyjść specjalnych (`TEST_WYJSC*`).
- [x] **Telemetria Frisko (critical):** backend mapuje teraz pełną macierz sygnałów telemetrycznych (`R_T*`, `STATUS`, `R_TZAD_*`, `R_TZADANA`, `KAL_*`) ze strategią symbol-first i fallbackiem do legacy tam, gdzie dostępny; pokryte testami fallback/NaN (brak i wartości nienumeryczne).
- [ ] **Schedule PROG_/PAR_PRG (important):** zsynchronizować harmonogram aplikacji z semantyką rejestrów Frisko i pokryć to testem E2E (zapis->odczyt->egzekucja).
- [x] **Alarm-flow E2E (important):** dodano testy integracyjne dla endpointu `/api/v1/frisko/alarms` weryfikujące strukturę odpowiedzi i dostępność danych alarmowych.
---
## 🔵 Opcjonalne (Nice to Have)
- [ ] **Usunięcie Legacy InfluxDB:** Stary `InfluxMetricModbusSender` działa równolegle z nowym adapterem, dublując zapisy. Należy go usunąć po naprawieniu nowego adaptera.
- [ ] **Testy Architektury:** Dodać testy ArchUnit, aby zapobiec wyciekaniu logiki domenowej do warstwy infrastruktury.
---
## 📝 Kontekst dla Agenta AI
Jeśli zaczynasz pracę nad tym projektem, kontynuuj od sekcji **🟡 Dług Technologiczny i Stabilność**.
1. Dodaj testy integracyjne E2E dla pętli sterowania.
2. Ogranicz `synchronized` w `ManualControlUseCase`.
3. Zaplanuj wygaszenie `InfluxMetricModbusSender`.
+55
View File
@@ -0,0 +1,55 @@
# Backend migration status (Frisko -> clean architecture)
## Co już jest zrobione
- Migracja aplikacji backend do Spring Boot (`HomePumpApplication`) z harmonogramami `@Scheduled`.
- Wydzielenie warstw:
- `domain` (model i strategie)
- `application` (use case + porty)
- `infrastructure` (adaptery Modbus/Influx + API)
- Działa pętla sterowania `ControlLoopUseCase` oparta o porty.
- Wdrożone reguły domenowe:
- priorytet CWU względem CO (przy niskiej temperaturze CWU)
- minimalny czas pracy i postoju pompy (anti short-cycle)
- Działa kompatybilne API monitoringu:
- `GET /health`
- `GET /api/telemetry`
- Dodana analiza Frisko oparta o `frisko/mr208_m3p.csv`:
- `GET /api/v1/frisko/features`
- `GET /api/v1/frisko/registers`
## Co trzeba jeszcze zrobić (kolejność)
1. **Mapowanie komend do rejestrów Modbus (critical)**
- obecnie `ModbusDeviceAdapter.applyCommands` nie wykonuje rzeczywistych zapisów.
- potrzebna jawna tabela mapowań per obieg/akcja (np. `CO1 + PUMP_START -> register X`).
2. **Rozszerzenie reguł domenowych z Frisko (critical)**
- tryby pracy (AUTO/RĘCZNY, LATO/ZIMA, ECO/KOMFORT)
- parametryzacja progów CWU/histerezy i czasów min run/stop z konfiguracji
3. **Konfiguracja schedulera i retry policy (important)**
- zewnętrzne parametry w `application.yml`
- rozdzielenie interwałów: polling / control loop / health
4. **Audit metryk Influx (important)**
- standaryzacja nazw pomiarów i pól
- tagowanie obiegów (`circuit=CO1/CO2/...`)
5. **Testy architektoniczne i domenowe (important)**
- testy use case’ów bez Modbusa
- testy adaptera Modbus z mock repo i tabelą mapowań
## Decyzja architektoniczna: Spring Boot
Spring Boot jest właściwą decyzją dla tego projektu:
- skraca czas implementacji (DI, scheduling, REST)
- redukuje kod infrastrukturalny
- ułatwia testowanie i obserwowalność
- dobrze współgra z podejściem Ports & Adapters
## Uwaga o Frisko
Frisko jest traktowane jako źródło wymagań i semantyki rejestrów, nie jako źródło kodu.
Kod nowego backendu pozostaje niezależny i czysty architektonicznie.
-22
View File
@@ -1,22 +0,0 @@
# ADR-001: Evolve the running baseline, plain Java
Status: accepted · 2026-10-23
## Context
Two starting points: the code running on the Pi (`e345063`, ~1300 lines, plain Java 21, works
since February) or a rewrite. The previous attempt (Spring Boot, branch
`archive/spring-boot-attempt`) grew large and complex without doing the essential job: it was
built on a register catalog that does not match the device.
## Decision
Keep the baseline and reshape it in small, deployable steps. Introduce the layers from the spec
as code is touched. Stay on plain Java 21 without a framework; add a small library only when it
removes more code than it adds, and record it here.
## Consequences
- What is proven on the device (decoding, sign handling, register addresses) is kept.
- Every step can be compared against the current Grafana data.
- No dependency-injection or web framework to learn before reading the code.
-19
View File
@@ -1,19 +0,0 @@
# ADR-002: Backend on the Pi, server as the gateway
Status: superseded by ADR-006 · 2026-10-23
## Context
The phone is outside the Krosno network. The backend could run on the Pi, next to the controller,
or on my server and reach Modbus through WireGuard.
## Decision
The backend runs on the Pi. My server only routes and authenticates: Traefik forwards an
authenticated HTTPS route through WireGuard to the Pi.
## Consequences
- Modbus never crosses the WAN: low latency, and the backend keeps running when the tunnel drops.
- No pump logic on the server.
- Deploy target stays the Pi (`scripts/deploy.sh`).
-25
View File
@@ -1,25 +0,0 @@
# ADR-003: One Modbus owner; snapshot queries, queued commands
Status: accepted · 2026-10-23
## Context
The controller answers slowly, and with frequent reads, commands were not carried out reliably.
Several parts of the system want data at once (Influx export, API, health).
## Decision
- A single worker thread owns the only Modbus connection and talks to the controller in turn.
- It polls at a fixed, conservative rate and publishes an immutable snapshot.
- Queries (API, Influx export, health) read the snapshot only.
- Commands go into a queue and get an id. The worker runs them between polls, reads the register
back and marks them `APPLIED` or `FAILED`. The client polls the command status.
## Consequences
- Load on the controller is fixed and set in one place, whatever the number of users.
- Commands take seconds, not milliseconds; the UI shows them as pending.
- The safe poll rate and command timing are measured in M2.
- The MK01 gateway accepts one TCP client and a new one drops the old: each of our polls
disconnects the Frisko phone app. While the Frisko app is still in use, poll rarely (as now,
300 s) and keep each connection short.
-18
View File
@@ -1,18 +0,0 @@
# ADR-004: Mobile-first web UI before any native app
Status: accepted · 2026-10-23
## Context
The users are my parents, on their phones, away from home. A native app (React Native) or a
WebView wrapper is possible later, but only once the backend works well.
## Decision
Build a mobile-first web UI, served by the backend, talking to the JSON API. Decide on native,
WebView or web-only in M6, once real use shows what is missing.
## Consequences
- One API serves the web UI now and any app later.
- Frontend technology is chosen in M3 with the same rule as ADR-001: the least code that works.
-25
View File
@@ -1,25 +0,0 @@
# ADR-005: InfluxDB for data, one local file for pending restores, no other database
Status: accepted · 2026-10-23
## Context
We store readings and energy data (time series), an audit log of writes (S3), and the state of
running boosts: the value to restore and when (F3). InfluxDB 1.x already holds the history and
feeds Grafana. It runs on my server, so the Pi loses it whenever the tunnel or DNS fails (D7).
## Decision
- **InfluxDB** holds all time series: heat pump readings and state, energy, and the audit log
as events. New data goes into new measurements, so existing Grafana dashboards keep working.
- **One small JSON file on the Pi** holds the running boosts (register, previous value, restore
time), written atomically. A restore must work without the network.
- Configuration (limits, presets, device addresses) is a file on the Pi, not data.
- No other database. The command queue is in memory; a restart drops pending commands, and the
UI shows them as failed.
## Consequences
- Nothing new to install or back up besides one file.
- Audit history in the app depends on InfluxDB; the journal on the Pi keeps a local copy.
- If we ever need users, many records or complex queries, revisit this decision.
-40
View File
@@ -1,40 +0,0 @@
# ADR-006: Thin gateway on the Pi, backend on the server in Kraków
Status: accepted · 2026-10-23 · supersedes ADR-002
## Context
The Pi is the weakest part (SD card, Debian 11 without support). My server in Kraków runs
Proxmox and Docker next to InfluxDB. But the MK01 gateway takes one client at a time, and a boost
must be restored on time even when the tunnel is down.
## Decision
**Gateway (Pi)**, the only client of the devices, and nothing else:
- polls every device (heat pump, Deye, Fronius, house meter, BME280 on the Pi) at a fixed rate, writes the readings
to InfluxDB with a local buffer, and serves the last snapshot (`GET /snapshot`);
- accepts commands `register, value, minutes or until changed` (`POST /commands`) only from the
backend (token and firewall), checks them itself (allowlist, min/max, rate limit), writes,
reads back, reports `APPLIED` or `FAILED`;
- keeps timed restores in a local file and runs them without the server.
Restore rules:
- The value to restore is read **from the controller just before the first write**, never sent
by the server.
- A new boost on a register already boosted replaces the end time and keeps the original value.
- At restore time, if the register no longer holds our value (someone changed it on the panel or
in the Frisko app), the gateway leaves it and reports a conflict.
- After a restart, overdue restores run first.
**Backend (Docker in Kraków)**: phone API, auth via Traefik, presets, notifications. Reads history
from InfluxDB and the live snapshot from the gateway; sends commands only to the gateway.
## Consequences
- The baseline code evolves into the gateway (ADR-001); the backend is a new, separate app.
- Two deployables: a Docker image for the server, a jar for the Pi.
- While the tunnel is down the app shows no live data, but readings are buffered and restores
still happen on time.
- `rpi-measurements` (house meter, BME280) moves into the gateway and is retired: one place for
all devices. The Pi's OS stays as it is (Debian 11), by decision.
- Reading Deye and Fronius from the Pi needs the subnet overlap with Kraków fixed first (D7).
-29
View File
@@ -1,29 +0,0 @@
# ADR-007: Gateway stack: JDK first, our own small Modbus core
Status: accepted · 2026-10-23
## Context
The gateway talks to four kinds of devices: the heat pump (Modbus TCP, sign-magnitude), the
house meter (Modbus TCP, FC4, float32), Deye (Modbus RTU inside Solarman V5 on port 8899) and
Fronius (HTTP JSON), plus the BME280 on the Pi's I2C. No Java library speaks Solarman V5. The
baseline uses modbus4j and influxdb-java; the latter pulls in most of the 10 MB jar.
## Decision
Java 21 (already on the Pi), Maven, a fat jar. As few dependencies as possible:
| Area | Choice |
|---|---|
| Modbus | Our own package: FC3/FC4/FC6 codec with exceptions, a TCP transport and a Solarman V5 transport (RTU frame, CRC16). Readable as protocol documentation, tested with frames recorded from our devices. modbus4j is removed. |
| Reading | Explicit register blocks per device in the config; one request per block, strictly one at a time per device, with a pause (200–300 ms for the heat pump). No automatic batching, no async pipelining. Devices run in parallel, one virtual thread each. |
| InfluxDB | JDK `HttpClient`, line protocol to `/write`, our own buffer kept in a file. influxdb-java is removed. |
| HTTP API | JDK `com.sun.net.httpserver` with virtual threads. |
| JSON, config | Jackson. |
| Logs, tests | slf4j-simple; JUnit 5, Mockito. |
| BME280 | The kernel's IIO driver (`dtoverlay=i2c-sensor,bme280,addr=0x76` in `/boot/config.txt`, approved; applied in M1 with a planned reboot) and plain file reads from `/sys/bus/iio`. No library. |
## Consequences
- About 250 lines of protocol code we own, instead of two libraries; a much smaller jar.
- Fallback for Modbus if our core disappoints: j2mod (Apache 2.0).
@@ -1,30 +0,0 @@
# ADR-008: Backend and web UI stack
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
## Context
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
the gateway already shows the stack is enough (ADR-007).
## Decision
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
the contract between backend and UI.
- **Web UI**: React, TypeScript and Vite in `frontend/`, Tailwind for styling, Recharts for
charts; the overview animates an SVG layer over a picture of the installation. Polish texts.
Changed on 2026-10-24 at the user's request: the plain-JS UI was too weak to grow. Served by
its own nginx container, not by the backend (ADR-010).
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
`Dom_Pompa.POMPA` fields, which already hold years of data.
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
## Consequences
- A JavaScript toolchain to keep up to date (`package-lock.json`); CI sets up Node as well.
- `scripts/check.sh` builds and tests both projects.
@@ -1,27 +0,0 @@
# ADR-009: Web UI reachable from Krosno only, without a login for now
Status: accepted · 2026-10-24
## Context
M3 needed a login method before the backend goes behind Traefik in Kraków (ADR-008 left it
open). Keycloak runs there but no stack uses it yet. The first users are in the house in
Krosno, on its network.
## Decision
- No login in the app or in Traefik for now. The backend's router gets an `ipwhitelist`
middleware that admits only Krosno: its public address or the WireGuard range, whichever path
the phones take; settled when the route is set up.
- A `headers` middleware on the same router clears a client's `Remote-User`, so the name sent as
`X-Requested-By` with a setpoint change is never one a client made up; without a login it is
always `phone`.
- The container publishes no host port; only Traefik reaches it.
## Consequences
- Anyone on the Krosno network can change setpoints once writes are enabled (#53). The limits
and timed restores (ADR-006) still apply.
- Not usable away from home. Before that, choose a login (Keycloak forward-auth or Traefik
basic auth with `headerField: Remote-User`) in a new ADR that supersedes this one.
- If Krosno's public address changes, the whitelist must follow; the WireGuard range does not.
@@ -1,31 +0,0 @@
# ADR-010: Gateway, backend and frontend as three images, each with its compose
Status: accepted · 2026-10-24
## Context
The code had grown into a gateway at the root and a backend that also built and served the
web UI. The Pi ran a jar under systemd, copied by a deploy script; Kraków runs Docker stacks
from homelab-compose through Arcane. The user wanted the parts separate and each deployable the
same way.
## Decision
- Three folders, `gateway/`, `backend/`, `frontend/`, each with a `Dockerfile` and a
`compose.yaml`. The backend serves only `/api/`; nginx in the frontend image serves the UI
and passes `/api/` to the backend, so they share one origin (CSP and the Origin check hold).
- A pushed tag `vMAJOR.MINOR.PATCH` (`scripts/release.sh`) makes CI publish the three images in
the Gitea registry; the gateway also for arm64. The Dockerfiles build on the runner's own
platform and only copy into the final stage, so no emulation is needed.
- The gateway runs in Docker on the Pi (Docker installed there, the OS untouched): host network
for Modbus, `/home/pi/home-pump` mounted at the same path, logs to journald. `update.sh` on
the Pi pulls a release with its `compose.yaml` and goes back to the previous one if `/health`
fails or the container restarts.
## Consequences
- No jar copying: the Pi and Kraków run the same published images; a version is a git tag.
- The Pi needs Docker and, if the packages are private, a registry login: one-time steps.
- CI needs Node and a `REGISTRY_TOKEN` secret with package write access.
- Supersedes the systemd unit and `scripts/deploy-gateway.sh`; ADR-006 (gateway on the Pi)
stands.
+174
View File
@@ -0,0 +1,174 @@
# Analiza Dokumentacji Frisko Online v2.3
**Data analizy:** 22 lutego 2026
**Źródło:** FRISKO-ONLINE-wersja-2_3.pdf (konwersja TXT)
---
## 1. Przeznaczenie i Zakres Systemu
Frisko Online to system zdalnego nadzoru dla instalacji wyposażonych w automatykę firmy FRISKO. System oferuje:
- Zdalną obsługę sterowników przez instalatorów i serwisantów
- Możliwość zmiany nastaw przez użytkowników końcowych
- Rejestrację parametrów pracy w bazie danych
- Przeglądanie danych w formie wykresów (eksport CSV, drukowanie)
- **Automatyczne generowanie alarmowych wiadomości e-mail** (na dwa przypisane konta)
---
## 2. Wspierane Sterowniki
System obsługuje:
- **Sterowniki:** S20, MR210, **MR208**, MR65, RX910, ATTO
- **Moduły komunikacyjne:**
- EM500, EM510 (wbudowane w MR210/MR208)
- EM1206 (wbudowane w S20)
- MK01 (zewnętrzne, RS485, jeden sterownik per moduł)
- **Protokół:** Modbus przez ethernet (porty 9010, 9020)
- **Minimalna wersja oprogramowania sterownika:** 01.01.2013
---
## 3. Funkcje Systemu (Wersja Pełna)
### 3.1 Funkcje Podstawowe
✅ **Wyświetlanie i zmiana podstawowych parametrów sterownika** - MAMY
✅ **Wyświetlanie i zmiana serwisowych parametrów** - MAMY (endpoint `/api/v1/frisko/runtime/write`)
✅ **Podgląd online temperatur i stanu wyjść** - MAMY (endpoints `/api/v1/system/readings`, `/api/v1/control/state`)
⚠️ **Schemat technologiczny** - MAMY podstawowy (`/api/v1/system/schematic`), ale nie graficzny UI per obiekt
### 3.2 Funkcje Analityczne
✅ **Rejestracja parametrów pracy** - MAMY (InfluxDB, telemetria)
⚠️ **Wykresy dobowe** - MAMY backend (InfluxDB), brak dedykowanego UI
❌ **Eksport CSV** - NIE MAMY (backend ma tylko JSON API)
❌ **Drukowanie wykresów** - NIE MAMY (można zaimplementować w frontend)
### 3.3 Funkcje Alarmowe
✅ **Przegląd zarejestrowanych stanów awaryjnych** - MAMY (`/api/v1/frisko/alarms`)
❌ **Automatyczne e-maile o stanach awaryjnych** - NIE MAMY (krytyczny gap!)
✅ **E2E testy alarm-flow** - MAMY (nowe testy integracyjne)
### 3.4 Edycja Programów Dobowych
⚠️ **Edycja programu dobowego** - MAMY schedule API (`/api/v1/control/schedule`), ale:
- Brak mapowania rejestrów `PROG_*` (PROG_DZIEN, PROG_GODZ1-4, PROG_MIN1-4, PROG_OBW, PROG_COPY)
- Schedule działa na poziomie aplikacji, nie synchronizuje się z rejestrami sterownika
- Gap analysis pokazuje 3/14 rejestrów schedule pokrytych
---
## 4. Porównanie z Naszą Implementacją
### 4.1 Kompletność Funkcjonalna
| Kategoria | Frisko Online | Nasza Implementacja | Status |
|-----------|---------------|---------------------|--------|
| **Sterowanie** | Full | Full (14/14 rejestrów) | ✅ DONE |
| **Telemetria** | Full | Substantial (30/46 w main) | ⚠️ PARTIAL |
| **Alarmy (API)** | Full | Full (endpoint + testy E2E) | ✅ DONE |
| **Alarmy (notyfikacje)** | Email auto | Brak | ❌ CRITICAL GAP |
| **Schedule (logika)** | Full | Basic (app-level) | ⚠️ PARTIAL |
| **Schedule (PROG_*)** | Full | Minimal (3/14) | ❌ GAP |
| **Wykresy** | Desktop UI | Backend only | ⚠️ PARTIAL |
| **Eksport CSV** | Tak | Nie | ❌ NICE-TO-HAVE |
| **Schemat graficzny** | Per obiekt | Generic JSON | ⚠️ PARTIAL |
### 4.2 Kluczowe Luki
#### 🔴 KRYTYCZNE:
1. **Brak automatycznych notyfikacji alarmowych**
- Frisko Online: Auto email na 2 konta (serwis + użytkownik)
- My: Tylko endpoint API `/api/v1/frisko/alarms`
- **Rekomendacja:** Dodać Spring mail sender z konfiguracją SMTP + watch service dla `ALARM_ZBIORCZY`, `R_ALARM_KOM`
#### 🟠 WAŻNE:
2. **Niepełne mapowanie PROG_* schedule**
- Frisko Online: PROG_DZIEN, PROG_GODZ1-4, PROG_MIN1-4, PROG_OBW, PROG_COPY
- My: Schedule w bazie SQLite, nie synchronizuje z rejestrami sterownika
- **Rekomendacja:** Dodać dwukierunkową synchronizację schedule ↔ PROG_* rejestry
3. **Eksport CSV wykresów**
- Frisko Online: Export z desktop app
- My: Brak
- **Rekomendacja:** Dodać `/api/v1/telemetry/export?from=...&to=...&format=csv`
#### 🟢 NICE-TO-HAVE:
4. **Graficzny schemat technologiczny per obiekt**
- Frisko Online: Customowy schemat na zamówienie (za dodatkową opłatą)
- My: Generic JSON schematic snapshot
- **Rekomendacja:** Niski priorytet, można zaimplementować w frontend (SVG + bindings)
---
## 5. Rekomendacje Priorytetowe
### P0 (DO PRODUKCJI):
1. **Alarm Email Notifications**
- Monitorować rejestry `ALARM_ZBIORCZY` (3005) i `R_ALARM_KOM` (3006)
- Spring Boot mail sender + configurable recipient list
- Throttling (max 1 email/alarm/godzinę, aby uniknąć spamu)
### P1 (POST-LAUNCH):
2. **PROG_* Schedule Synchronization**
- Mapowanie `PROG_DZIEN` (3035) jako bieżący dzień
- `PROG_GODZ1-4` (3036-3039) jako godziny przełączeń
- `PROG_MIN1-4` (3040-3043) jako minuty przełączeń
- `PROG_OBW` (3044) jako wybrany obwód
- `PROG_COPY` (3034) jako trigger kopiowania programu
3. **CSV Export API**
- Endpoint eksportu danych telemetrycznych z InfluxDB
- Format zgodny z Frisko Online (separator `;`, ISO datetime)
### P2 (FUTURE):
4. **Graficzne schematy** - jeśli będzie demand od użytkowników
5. **Drukowanie wykresów** - można zrobić browser-native (window.print() z wykresu Chart.js)
---
## 6. Gap Analysis → Nasza Implementacja
Z comprehensive gap analysis (wykonanej wcześniej):
**Kontrola wyjść:** 14/14 ✅ (100% – WY_*, TEST_*, CO3)
**Telemetria:** 30/46 ⚠️ (65% – R_T*, STATUS, TZAD, KAL)
**Schedule:** 3/14 ❌ (21% – brak PROG_* pełnego mapowania)
**Alarmy (odczyt):** 0/2 → **2/2 ✅** (dodano endpoint + testy E2E)
**Alarmy (notyfikacje):** 0/2 ❌ (brak email sender)
**Konfiguracja:** 7/104 ⚠️ (7% – PAR_*, KONF_*)
---
## 7. Wnioski Techniczne
### Architektura Frisko Online
- Java desktop app (wymaga JRE 1.6.0_07+)
- Komunikacja przez dedicated porty 9010, 9020
- Brak wymagania stałego IP (moduł się rejestruje w chmurze)
- Buffer zmian parametrów (nie blokuje UI podczas zapisu Modbus)
### Architektura Nasza
- Spring Boot backend (Java 17) + React frontend
- REST API + React Query (no dedicated ports, standard HTTP/HTTPS)
- ModbusCachedWrapper dla optymalizacji
- SQLite dla persystencji state
**Różnice kluczowe:**
- My: Modern stack (React, REST), deployable jako Docker/systemd service
- Oni: Desktop-only, wymaga instalacji Java na każdym stanowisku
---
## 8. Następne Kroki
### Ukończone w tej sesji:
- [x] E2E test alarm-flow (`shouldExposeAlarmFlowEndpoint`, `shouldReportAlarmStructureWithRegisters`)
- [x] Test fail-safe (`shouldApplyFailSafeAfterProlongedCommunicationFailure`)
- [x] Aktualizacja `docs/audit-status.md`
### Do implementacji (priorytet):
1. **Alarm email notifications** (Spring Mail + scheduler watching `ALARM_ZBIORCZY`)
2. **PROG_* schedule synchronization** (dwukierunkowe mapowanie rejestrów)
3. **CSV export endpoint** (InfluxDB query → CSV format)
### Tracking:
Wszystkie gaps i rekomendacje dodane do `docs/audit-status.md` w sekcjach odpowiednich priorytetów.
-120
View File
@@ -1,120 +0,0 @@
# Frisko app and map knowledge for the RX910-SE21
What the vendor app (`FriskoOnline2025.apk`, decompiled with androguard, nothing run), the XML maps
and the PDFs say. **Everything here is unverified on the device** unless
[rx910-se21.md](rx910-se21.md) says so. Source tags: `PDF` = `RX910-SE21-AdresyMODBUS`, `XML` =
`RX910-SE21_v24062015.xml`, `MR208` = MR208-PC+ manual/map (another model: hints only), `APK` =
class in `com.frisko.frisko_mobile`.
## Key finding: the app has no control logic
The app is a generic Modbus viewer: every screen, scale, value name and icon comes from the XML
(`XmlHandler`, `ListDataParam`). It contains no register numbers except 4011 (`Comm_thread.STATUS_REG`),
base 4000 (`Frisko_Sterownik.ADR_BAZOWY`, only for its local array index; the wire address is the raw
register) and the XML tag set. It never reads 4221, 4222–4225, 4227, 4228, 4119 or 4129. Meanings
below therefore come from the PDF, the XML and the MR208 manual, not from code.
## Alarm: register 4011 (the app) vs 4221 (the PDF)
| Register | Source | Meaning |
|---|---|---|
| 4221 "Instalacja" | PDF | 0 = installation OK, 1 = installation fault. Binary, no bits, no codes. |
| 4011 "Status" | XML (Status screen) | Fault code, not a bit field: 0 OK, 2 Err_Tzew, 3 Err_Tco, 4 Err_Twew, 5 Err_Tcwu, 14 Err_Tpc, 15 Err_Tdz, 1003 Err_Presostat |
| 4073 "Presostat" | XML, PDF | 0 OK, 1 error (pressure switch input) |
- The app polls 4011 always, and on any non-zero value (even one not in the XML list) raises a
notification "Awaria sterownika: <number>" with sound and vibration; it repeats only when the code
changes (`Comm_thread.update_regs`, `status_error`). It never reads 4221.
- Likely mapping of names (inference from naming and the MR208 manual): `Tzew` outdoor 4065, `Tco`
buffer 4067, `Twew` indoor 4066, `Tcwu` 4069, `Tpc` pump outlet 4072, `Tdz` ground source
(4070/4071); codes 2–5, 14, 15 = sensor fault (MR208 "ErrTemp": pump off), 1003 = pressure switch
(MR208 "ErrPres": pump off; three trips in one cycle latch it until service clears it).
- MR208 also reports TmaxPC, TminDZ, dTmaxDZ shutdowns; SE21 may put them in 4011 or 4221
(unknown). 4011 is not in the PDF map. Next: log a read-only FC 3 of 4011 beside 4221 and 4073.
## Registers 4119, 4222–4225, 4228
| Reg | What is known | Source |
|---|---|---|
| 4119 `WyCHL` | Output, "0 OFF, 100 ON" like 4112–4118. "CHL" probably means cooling (the gateway calls it `output_cooling`; a guess). Not in the XML, so the app never shows it. The −14308 read means the register is not implemented on this firmware | PDF; read per rx910-se21.md |
| 4222–4225 | Not in the PDF, XML or APK. MR208 uses 4225 for TFer (a different layout, do not transfer). Gateway fixtures show 0 | none |
| 4228 `StanZB` | PDF text is cut: "0 –", "2 – zbCW" (value 1 and the text of 0 are missing from the PDF extract, also with `-raw`). "ZB" is not explained; not in the XML | PDF |
| 4227 `StanCO/CW` | 0 pc STOP, 1 pcCO, 2 pcCW, 3 pcCO/CW. Not in the XML; the app shows output icons instead | PDF |
| 4226 `Status` | 0 Ferie, 1 Tygodn (weekly), 2 Party, 3 ROZRUCH (start-up), 4 STOP, 5 Dezynf. Same list in the XML "Program pracy" with icons `program_ferie`, `program_tygodniowy`, `program_party`, `praca`, `stop`, `dezynf` | PDF, XML |
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
## Modes and programs
| Reg | Values | Source |
|---|---|---|
| 4121 `TrybLZ` | Effective season: 0 summer, 1 winter. Read-only on the app's Status screen | PDF, XML |
| 4122 `TrypRA` | 0 R, 1 A, 2 *. Not in the XML. MR208 shows "Zima(A)" on its main screen: season plus A (automatic) or R (manual) | PDF, MR208 manual |
| 4123 `Tryb` | Season setting: 0 Lato, 1 Zima, 2 Auto (editable; backup value 2). Auto switches at 4124 `Temp progu Z/L` (5–35 °C whole, backup 18) | XML, PDF, backup |
| 4125 `Tryb pracy` | 0 STOP, 1 PRACA (editable; backup 1) | XML, PDF |
| 4140 `Prg` | 0 Ferie, 1 Tygodniowy, 2 Party (editable) | XML, PDF |
| 4141 Ferie days | 0–90 (XML). MR208: 0 = until switched back by hand | XML, MR208 manual |
| 4142 Party hours | 0–90 (XML). MR208: 0 = until switched back; counts down to the end of Party | XML, MR208 manual |
MR208 semantics (SE21 likely the same, unverified): Ferie keeps CO at the ferie target (4133) and CWU
at economy or off; Party keeps comfort for CO and CWU; weekly intervals are comfort, the rest economy.
## Economy or comfort period
- The app does not decide this. It shows the XML values only; the XML has no "period" register.
- The controller decides: weekly program intervals (4169–4176) give comfort, everything else economy
(MR208 manual, "Programy"). Ferie and Party override it as above.
- The effective targets 4201 (room), 4203 (CWU) are the answer. Inference for a UI: compare 4201
with 4131 (economy), 4132 (comfort), 4133 (ferie), and 4203 with 4143 / 4144. Ambiguous when two
setpoints are equal. 4202 (CO supply target) comes from the heating curve (4134) and is not a setpoint.
## "Heating CWU / CO" in the app
The app shows no such state. Its "Stan wyjść" screen lists outputs 4117 Pompa DZ, 4113 BUF, 4112 CO,
4118 CWU, 4116 CCW, 4114 Sprężarka (0/33/66/100), 4115 Grzałka CWU, each as an icon for 0, 1 or 100
(XML). Heating CO or CWU means output 4112 or 4118 on. 4227 is the controller's own summary of the
same thing (PDF); the app ignores it.
## How the app writes (read-only knowledge for a later milestone)
| Item | Finding | Source |
|---|---|---|
| Function codes | FC 3 read, FC 6 write one register. FC 16 exists in the code (`WRITE_REG_MULTI = 16`, `MDBwriteMulti`) but no UI path uses it | `Frisko_MDBRozkaz`, `Frisko_ModbusTCPSystem` |
| Unlock or password | None. No other frame is sent; `HasloLAN` 4129 (PDF) is never used. Matches the FC 6 test on 4143 | APK, rx910-se21.md |
| Unit id | The "slave" number set per controller in the app, in the MBAP unit byte | `Comm_thread`, `MDBsendData` |
| Scales | Value = register / 10 for format `XXX.X`; whole for `XX`. `ster_format` MZ = sign-magnitude: a negative write n is sent as 0x8000 + \|n\| | XML, `JavaToSterownik` |
| Range check | Min and max from the XML only, on the phone | XML |
| Queue | Writes go to the front of the queue (`AddPriorRozkaz`), ahead of polling | `Frisko_ModbusTCPSystem` |
| Confirmation | The reply (function, transaction id, address) is checked; no explicit read-back. The screen shows the new value after the next poll of that register | `MDBreadODP` |
| Failure | Up to 3 attempts per request, 500 ms between them; then the socket is closed and re-opened | `MBD_ERROR`, `ERROR_TIME_OUT` |
| Delay after write | 200 ms after FC 6, 300 ms after FC 16 | `run()` |
| Program selector | Circuit 4177 and day 4167 are plain writes, then the interval registers are polled; no explicit wait. MR208 map prescribes 500 ms between selecting and reading or writing | APK, MR208 map |
The XML schema also has `menu_set_adr` / `menu_set_val` (write a fixed value on opening a menu);
the SE21 XML does not use it.
## Request pacing
| Item | Value | Source |
|---|---|---|
| Pause after each request | 200 ms (300 ms after FC 16); poll thread starts after 250 ms | `Frisko_ModbusTCPSystem.run` |
| Poll list empty | 200 ms, then rebuilt: continuous polling, about 5 requests/s while a screen is open | `MDB_TIME_BREAK` |
| Timeout | 5000 ms default, user setting (2–5 digits) | `MDB_TIME_OUT`, `SterSettingsActivity` |
| Block read | Up to 28 registers per request, gaps up to 5 registers are read through | `MBD_SIZE_OPTYMAL`, `MBD_BREAK_OPTYMAL` |
| What is polled | Only the registers on the open screen, plus 4011 | `REG_DATA_STATUS`, `Comm_thread.set_reg` |
| Connections | One socket held open, up to 50 reconnect attempts | `disconnect_retries` |
The vendor documents no limit. The app's 200 ms pause is the only known pacing; our 36 registers in
5 blocks took 2.5 s (rx910-se21.md), so a 10 s cycle is far below the app's load.
## Open questions
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
- Does 4011 exist and move on this firmware? Its relation to 4221.
- Is 4226 numbered as the PDF says (see above)?
- What does 4119 read when it is implemented, and is the register used at all?
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
Loaded 100 of 2376 files, more files were not shown because too many files have changed in this diff. Show more