Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2eaf2d0ef | ||
|
|
dd739e16c0 |
No files matched your search
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -1,59 +0,0 @@
|
||||
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
|
||||
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariuszpacek.pl
|
||||
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
|
||||
- name: Docker CLI
|
||||
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
||||
# final stage, so the arm64 image needs no emulation.
|
||||
- name: Gateway
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
- name: Backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
- name: Frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
@@ -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"
|
||||
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM amazoncorretto:11.0.17
|
||||
COPY target/test*.jar app/home-pump.jar
|
||||
|
||||
CMD ["java", "-jar", "app/home-pump.jar"]
|
||||
@@ -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 +0,0 @@
|
||||
target
|
||||
@@ -1,22 +0,0 @@
|
||||
# Built on the build machine's own platform; the image only holds the jar and a JRE.
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /src
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q dependency:go-offline
|
||||
COPY src src
|
||||
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -1,51 +0,0 @@
|
||||
# Backend
|
||||
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
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. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -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
|
||||
@@ -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,53 +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.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
|
||||
/** 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 setpoints = new SetpointService(commands(settings), PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock));
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
static LeaseRenewer leaseRenewer(Settings settings) {
|
||||
return new LeaseRenewer(commands(settings));
|
||||
}
|
||||
|
||||
private static GatewayHttpCommands commands(Settings settings) {
|
||||
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
public final class Main {
|
||||
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
server = Backend.assemble(settings, Clock.systemUTC());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} else {
|
||||
var renewer = Backend.leaseRenewer(settings);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||
renewer.start();
|
||||
}
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package home.backend;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
url(env, "GATEWAY_URL"),
|
||||
url(env, "INFLUX_URL"),
|
||||
(int) number(env, "PORT", 8080, 0, 65535),
|
||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||
optional(env, "GATEWAY_TOKEN"),
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
private static String optional(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
}
|
||||
|
||||
private static URI url(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
|
||||
}
|
||||
URI uri = URI.create(value.strip());
|
||||
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
|
||||
}
|
||||
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
|
||||
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
|
||||
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
|
||||
+ value);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Timeouts are whole seconds. */
|
||||
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
long number = Long.parseLong(value.strip());
|
||||
if (number >= min && number <= max) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.Preset;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
|
||||
public final class PresetFile {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private PresetFile() {
|
||||
}
|
||||
|
||||
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
|
||||
public static List<Preset> load(Path file) {
|
||||
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
|
||||
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
|
||||
List<Preset> presets = JSON.readValue(in, PRESETS);
|
||||
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
|
||||
throw new IllegalArgumentException("preset ids must be unique");
|
||||
}
|
||||
return presets;
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream defaults() {
|
||||
return PresetFile.class.getResourceAsStream("/presets-default.json");
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record DeviceReadings(String name, List<Reading> readings) {
|
||||
}
|
||||
|
||||
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
|
||||
// Unknown properties are ignored so the gateway can add fields without breaking us.
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI baseUri;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
public GatewayHttpClient(URI baseUri, Duration timeout) {
|
||||
this.baseUri = baseUri;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewaySnapshot snapshot() throws IOException {
|
||||
var health = JSON.readValue(get("/health", true), Health.class);
|
||||
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
|
||||
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
|
||||
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
|
||||
var devices = health.devices().stream()
|
||||
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
return device.readings().stream()
|
||||
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** A gateway with a device down answers /health with 503 and a valid body. */
|
||||
private byte[] get(String path, boolean downIsValid) throws IOException {
|
||||
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
|
||||
try {
|
||||
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status != 200 && !(downIsValid && status == 503)) {
|
||||
throw new IOException("gateway " + path + " answered " + status);
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.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);
|
||||
|
||||
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes, Instant until) {
|
||||
}
|
||||
|
||||
private record RestoreRequest(String setpoint) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
return request(endpoint);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var 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,340 +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.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
|
||||
public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
values = Map.of();
|
||||
} else if (values.containsValue(null)) {
|
||||
throw new IllegalArgumentException("a value must be a number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
private static final int MAX_BODY_BYTES = 4096;
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
api("/api/pi-sensor", query -> piSensor.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(SETPOINTS_PATH, exchange -> {
|
||||
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||
if (restore.matches()) {
|
||||
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void context(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(STOP_GRACE_SECONDS);
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
|
||||
private interface Gateway {
|
||||
Object get(Map<String, String> query) throws IOException;
|
||||
}
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
send(exchange, 403, error("cross-site request"));
|
||||
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
|
||||
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
|
||||
} else if (!isJson(headers.getFirst("Content-Type"))) {
|
||||
send(exchange, 415, error("expected Content-Type: application/json"));
|
||||
} else {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
|
||||
} else {
|
||||
write.run(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isJson(String contentType) {
|
||||
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
|
||||
}
|
||||
|
||||
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, setpoints.current());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, SetpointRequest.class);
|
||||
if (request == null || request.setpoint() == null || request.value() == null) {
|
||||
throw new IllegalArgumentException("setpoint and value are required");
|
||||
}
|
||||
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
|
||||
send(exchange, httpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var result = setpoints.restore(setpoint, user(exchange));
|
||||
send(exchange, restoreHttpStatus(result.outcome()), result);
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "for setpoints of the preset"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED, PARTIAL -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, history.history(query.get("series"), query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("history unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private 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,157 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
|
||||
* catalog below is the only place that knows a field's label, unit and place; showing a new field
|
||||
* is one line here.
|
||||
*/
|
||||
public class EnergyDetailsService {
|
||||
|
||||
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
|
||||
private record Field(String key, String label, String unit, int digits, double scale) {
|
||||
}
|
||||
|
||||
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
|
||||
}
|
||||
|
||||
/** {@code measurement} is null for the device's own one. */
|
||||
private record GroupSpec(String title, String measurement, List<Field> fields) {
|
||||
}
|
||||
|
||||
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
|
||||
private static Field[] field(String key, String label, String unit, int digits) {
|
||||
return new Field[] {new Field(key, label, unit, digits, 1)};
|
||||
}
|
||||
|
||||
private static Field[] kwhFromWh(String key, String label, int digits) {
|
||||
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
|
||||
}
|
||||
|
||||
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
|
||||
private static Field[] phases(String key, String label, String unit, int digits) {
|
||||
return IntStream.rangeClosed(1, 3)
|
||||
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
|
||||
}
|
||||
|
||||
private static GroupSpec group(String title, Field[]... parts) {
|
||||
return groupOf(null, title, parts);
|
||||
}
|
||||
|
||||
/** A group whose fields come from another measurement of the same device. */
|
||||
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
|
||||
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
|
||||
}
|
||||
|
||||
private static final List<Spec> CATALOG = List.of(
|
||||
new Spec("deye", "Falownik Deye", "deye", List.of(
|
||||
group("Bateria",
|
||||
field("battery_soc_pct", "Naładowanie", "%", 0),
|
||||
field("battery_power_w", "Moc", "W", 0),
|
||||
field("battery_voltage_v", "Napięcie", "V", 1),
|
||||
field("battery_current_a", "Prąd", "A", 1),
|
||||
field("battery_temperature_c", "Temperatura", "°C", 1)),
|
||||
group("Sieć",
|
||||
field("grid_power_w", "Moc razem", "W", 0),
|
||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
||||
group("Odbiór domu",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
field("pv1_power_w", "PV1 moc", "W", 0),
|
||||
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
|
||||
field("pv1_current_a", "PV1 prąd", "A", 1),
|
||||
field("pv2_power_w", "PV2 moc", "W", 0),
|
||||
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
|
||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||
group("Dziś",
|
||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
||||
field("load_today_kwh", "Zużycie domu", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Zużycie domu", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
|
||||
group("Falownik",
|
||||
field("inverter_power_w", "Moc wyjściowa", "W", 0),
|
||||
field("inverter_temperature_c", "Temperatura", "°C", 1),
|
||||
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
|
||||
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
|
||||
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
|
||||
group("Produkcja",
|
||||
kwhFromWh("energy_day_wh", "Dziś", 1),
|
||||
kwhFromWh("energy_year_wh", "Ten rok", 0),
|
||||
kwhFromWh("energy_total_wh", "Od początku", 0)))),
|
||||
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
|
||||
group("Moc czynna",
|
||||
field("total_power_active", "Razem", "W", 0),
|
||||
phases("p%d_power_active", "Moc", "W", 0)),
|
||||
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
|
||||
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
|
||||
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
|
||||
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
|
||||
group("Energia od początku",
|
||||
field("import_energy_active", "Pobrana", "kWh", 0),
|
||||
field("export_energy_active", "Oddana", "kWh", 0)),
|
||||
groupOf("house_meter_phases", "Energia na fazę",
|
||||
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
|
||||
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyDetails current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
|
||||
}
|
||||
|
||||
private Device device(Spec spec, GatewaySnapshot snapshot) {
|
||||
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
|
||||
List<Group> groups = spec.groups().stream()
|
||||
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
|
||||
.filter(g -> !g.items().isEmpty()).toList();
|
||||
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
|
||||
}
|
||||
|
||||
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
|
||||
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
|
||||
}
|
||||
|
||||
private static Group group(GroupSpec spec, Map<String, Double> fields) {
|
||||
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
|
||||
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
|
||||
return new Group(spec.title(), items);
|
||||
}
|
||||
|
||||
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
|
||||
private static double scaled(double value, Field f) {
|
||||
double factor = Math.pow(10, f.digits());
|
||||
return Math.round(value * f.scale() * factor) / factor;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Sum of the values that exist; null when none does. */
|
||||
private static Double sum(Double... values) {
|
||||
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
|
||||
}
|
||||
|
||||
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
|
||||
private static Double round(Double kwh) {
|
||||
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The live state, read from the gateway on the Pi through the tunnel. */
|
||||
public interface GatewayClient {
|
||||
|
||||
GatewaySnapshot snapshot() throws IOException;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The only way the backend writes anything: through the gateway, which checks every command. */
|
||||
public interface GatewayCommands {
|
||||
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
|
||||
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||
int renewLeases() throws IOException;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
|
||||
/** F5: whether the gateway answers and which device links are up. */
|
||||
public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
private final String version;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of(), version, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static DeviceHealth health(DeviceSnapshot d) {
|
||||
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.Pumps;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static home.backend.application.SnapshotFields.on;
|
||||
import static home.backend.application.SnapshotFields.whole;
|
||||
|
||||
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
|
||||
public class HeatPumpService {
|
||||
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
private static final State[] STATES = State.values();
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public HeatPumpService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public HeatPumpView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()));
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
|
||||
return view(reading.map(Measurement::time).orElse(null), stale,
|
||||
reading.map(Measurement::fields).orElse(Map.of()));
|
||||
}
|
||||
|
||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp")),
|
||||
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
private static String alarmText(Integer code) {
|
||||
if (code == null || code == 0) {
|
||||
return null;
|
||||
}
|
||||
return switch (code) {
|
||||
case 2 -> "Usterka czujnika temperatury zewnętrznej";
|
||||
case 3 -> "Usterka czujnika temperatury CO";
|
||||
case 4 -> "Usterka czujnika temperatury wewnętrznej";
|
||||
case 5 -> "Usterka czujnika temperatury CWU";
|
||||
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
|
||||
case 15 -> "Usterka czujnika temperatury dolnego źródła";
|
||||
case 1003 -> "Zadziałał presostat (ciśnienie)";
|
||||
default -> "Kod usterki " + code;
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
|
||||
private static Double tenths(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : Math.round(value * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static State state(Map<String, Double> f) {
|
||||
Integer mode = whole(f, "pump_mode");
|
||||
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
|
||||
public interface HistoryRepository {
|
||||
|
||||
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** F2: one series over the last 24 hours or 7 days. */
|
||||
public class HistoryService {
|
||||
|
||||
static final int MAX_POINTS = 300;
|
||||
private static final String DEFAULT_RANGE = "24h";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
|
||||
|
||||
private final HistoryRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public HistoryService(HistoryRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h} or {@code 7d}; null means 24h
|
||||
* @throws IllegalArgumentException for an unknown series or range
|
||||
*/
|
||||
public HistoryView history(String seriesId, String range) throws IOException {
|
||||
Series series = Series.fromId(seriesId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
return new HistoryView(series.id(), series.unit(),
|
||||
repository.history(series, to.minus(length), to, MAX_POINTS));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
|
||||
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
|
||||
*/
|
||||
public class LeaseRenewer implements AutoCloseable {
|
||||
|
||||
/** A third of the gateway's lease, so two renewals in a row may fail. */
|
||||
public static final Duration PERIOD = Duration.ofMinutes(5);
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("lease-renewal").factory());
|
||||
|
||||
public LeaseRenewer(GatewayCommands gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
start(PERIOD);
|
||||
}
|
||||
|
||||
void start(Duration period) {
|
||||
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
|
||||
void renew() {
|
||||
try {
|
||||
int renewed = gateway.renewLeases();
|
||||
LOGGER.debug("renewed {} lease(s)", renewed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.PiSensorView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
|
||||
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
|
||||
public class PiSensorService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public PiSensorService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public PiSensorView current() throws IOException {
|
||||
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
|
||||
Map<String, Double> f = live.fields();
|
||||
return new PiSensorView(live.source().time(), live.source().stale(),
|
||||
f.get("temperature"), f.get("humidity"), f.get("pressure"));
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
|
||||
this.gateway = gateway;
|
||||
this.presets = List.copyOf(presets);
|
||||
}
|
||||
|
||||
public SetpointsView current() throws IOException {
|
||||
SetpointState state = gateway.setpoints();
|
||||
return new SetpointsView(state.setpoints(), state.restores(), presets);
|
||||
}
|
||||
|
||||
/** @param hours how long to keep the value, null until changed back */
|
||||
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||
return gateway.restore(setpoint, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (preset.get().isHeld() != (until != null)) {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||
}
|
||||
|
||||
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||
if (!own.containsAll(values.keySet())) {
|
||||
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
if (value != null && !limit.allows(value)) {
|
||||
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||
+ " in steps of " + limit.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||
try {
|
||||
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||
return ChangeResult.of(gateway.apply(command));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||
String problem = problem(change, held, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
}
|
||||
if (!known.get().allows(change.value())) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||
+ known.get().value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Duration duration(Double hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Math.round(hours * 60);
|
||||
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
|
||||
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
|
||||
}
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
|
||||
final class SnapshotFields {
|
||||
|
||||
private SnapshotFields() {
|
||||
}
|
||||
|
||||
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
|
||||
private static final Duration MAX_AGE = Duration.ofSeconds(600);
|
||||
|
||||
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
|
||||
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
|
||||
}
|
||||
|
||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
}
|
||||
|
||||
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
record Live(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
|
||||
Optional<Measurement> reading = find(snapshot, device, measurement);
|
||||
boolean stale = stale(device(snapshot, device), reading, now);
|
||||
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
.map(DeviceSnapshot::measurements)
|
||||
.flatMap(List::stream)
|
||||
.filter(m -> m.measurement().equals(measurement))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static Integer whole(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
static boolean on(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value != null && value != 0;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||
* while the backend renews the lease (ADR-011).
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** The gateway's verdict on a command, passed on to the phone unchanged. */
|
||||
public record CommandResult(Status status, String message, Instant restoreAt) {
|
||||
|
||||
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
|
||||
public enum Status { APPLIED, REJECTED, FAILED }
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Every value the energy devices report, grouped for a details panel (F7). A device without a
|
||||
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
|
||||
*/
|
||||
public record EnergyDetails(List<Device> devices) {
|
||||
|
||||
/** One device; {@code id} is the gateway's device name. */
|
||||
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
|
||||
}
|
||||
|
||||
public record Group(String title, List<Item> items) {
|
||||
}
|
||||
|
||||
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
|
||||
public record Item(String label, double value, String unit, int digits) {
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
|
||||
*/
|
||||
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
|
||||
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
|
||||
|
||||
/** One inverter; time is null and stale true when the gateway has no reading of it. */
|
||||
public record Source(String name, Instant time, boolean stale) {
|
||||
}
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it.
|
||||
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
|
||||
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
}
|
||||
|
||||
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
|
||||
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||
* The gateway's version is null when it is unreachable or too old to report it. */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
|
||||
String backendVersion, String gatewayVersion) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
|
||||
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
|
||||
*/
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
|
||||
Targets targets, Setpoints setpoints) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
|
||||
}
|
||||
|
||||
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
|
||||
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
|
||||
}
|
||||
|
||||
/** Targets the controller works to right now (economy or comfort period). */
|
||||
public record Targets(Double room, Double co, Double cwu) {
|
||||
}
|
||||
|
||||
/** The four setpoints F3 will change; whole °C. */
|
||||
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** One series over a time range, already averaged into chart-sized steps. */
|
||||
public record HistoryView(String series, String unit, List<Point> points) {
|
||||
|
||||
public record Point(Instant t, double v) {
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
|
||||
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
|
||||
* without any reading.
|
||||
*/
|
||||
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||
* then without renewals (ADR-012).
|
||||
*/
|
||||
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||
List<Change> changes) {
|
||||
|
||||
public static final String CHOSEN = "chosen";
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
|
||||
public Change {
|
||||
if (setpoint == null || setpoint.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset change needs a setpoint");
|
||||
}
|
||||
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
|
||||
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isHeld() {
|
||||
return CHOSEN.equals(until);
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
}
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
if (until != null && !until.equals(CHOSEN)) {
|
||||
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||
}
|
||||
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What happened to each change of a preset, one entry per change in preset order. Changes run in
|
||||
* order and stop at the first that is not applied; the rest are NOT_ATTEMPTED.
|
||||
*
|
||||
* <p>{@code status}: APPLIED when every change was; PARTIAL when some were and some were not;
|
||||
* otherwise FAILED if any change failed or went unanswered (the pump may have changed), else
|
||||
* REJECTED (nothing was written).
|
||||
*/
|
||||
public record PresetResult(Status status, List<ChangeResult> results) {
|
||||
|
||||
public enum Status { APPLIED, REJECTED, FAILED, PARTIAL }
|
||||
|
||||
/** UNKNOWN: the gateway did not answer, so the change may or may not have reached the pump. */
|
||||
public enum ChangeStatus { APPLIED, REJECTED, FAILED, NOT_ATTEMPTED, UNKNOWN }
|
||||
|
||||
public record ChangeResult(ChangeStatus status, String message, Instant restoreAt) {
|
||||
|
||||
public static ChangeResult of(CommandResult verdict) {
|
||||
return new ChangeResult(ChangeStatus.valueOf(verdict.status().name()), verdict.message(),
|
||||
verdict.restoreAt());
|
||||
}
|
||||
|
||||
public static ChangeResult notAttempted() {
|
||||
return new ChangeResult(ChangeStatus.NOT_ATTEMPTED, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static PresetResult of(List<ChangeResult> results) {
|
||||
boolean applied = results.stream().anyMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean all = results.stream().allMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean maybeWritten = results.stream()
|
||||
.anyMatch(r -> r.status() == ChangeStatus.FAILED || r.status() == ChangeStatus.UNKNOWN);
|
||||
Status status = all ? Status.APPLIED
|
||||
: applied ? Status.PARTIAL
|
||||
: maybeWritten ? Status.FAILED
|
||||
: Status.REJECTED;
|
||||
return new PresetResult(status, results);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
|
||||
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
|
||||
*/
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
|
||||
public record RestoreResult(Outcome outcome, String message) {
|
||||
|
||||
/**
|
||||
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
|
||||
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
|
||||
*/
|
||||
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
|
||||
public enum Series {
|
||||
OUTDOOR_TEMP("outdoor_temp", "°C"),
|
||||
INDOOR_TEMP("indoor_temp", "°C"),
|
||||
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
|
||||
CWU_TEMP("cwu_temp", "°C"),
|
||||
GROUND_IN_TEMP("ground_in_temp", "°C"),
|
||||
GROUND_OUT_TEMP("ground_out_temp", "°C"),
|
||||
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
|
||||
GRID_POWER("grid_power", "W"),
|
||||
PV_POWER("pv_power", "W"),
|
||||
LOAD_POWER("load_power", "W"),
|
||||
BATTERY_SOC("battery_soc", "%");
|
||||
|
||||
private final String id;
|
||||
private final String unit;
|
||||
|
||||
Series(String id, String unit) {
|
||||
this.id = id;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String unit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public static Optional<Series> fromId(String id) {
|
||||
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** One of the four writable setpoints as the gateway reports it; {@code value} is null until it has been read. */
|
||||
public record Setpoint(String name, int register, int min, int max, int step, Integer value) {
|
||||
|
||||
public boolean allows(int candidate) {
|
||||
return candidate >= min && candidate <= max && (candidate - min) % step == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the gateway knows about the setpoints: their limits, values and the pending restores. */
|
||||
public record SetpointState(List<Setpoint> setpoints, List<Restore> restores) {
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The setpoints screen: gateway state plus the presets from the backend config. */
|
||||
public record SetpointsView(List<Setpoint> setpoints, List<Restore> restores, List<Preset> presets) {
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,312 +0,0 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
|
||||
class BackendTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
private ApiServer backend;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
gateway = stub(Map.of(
|
||||
"/health", "src/test/resources/gateway/health-all-up.json",
|
||||
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private record Command(String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Command> commandsSeen = new ArrayList<>();
|
||||
|
||||
/** The gateway's /commands, answering with the gateway's own contract examples. */
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
}
|
||||
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
backend.stop();
|
||||
gateway.stop(0);
|
||||
influx.stop(0);
|
||||
}
|
||||
|
||||
private static HttpServer stub(Map<String, String> bodies) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
|
||||
byte[] body = Files.readAllBytes(Path.of(file));
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}));
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private static URI url(HttpServer server) {
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** The names of every field, nested ones as a.b; array elements are not descended into. */
|
||||
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
|
||||
node.fieldNames().forEachRemaining(name -> {
|
||||
names.add(prefix + name);
|
||||
fieldNames(node.get(name), prefix + name + ".", names);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
private void assertServesApiShapeOf(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
|
||||
assertEquals(fieldNames(expected, "", new TreeSet<>()),
|
||||
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
|
||||
}
|
||||
|
||||
/** The body equals the documented example, value by value. */
|
||||
private void assertServesExactly(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
|
||||
JSON.readTree(response.body()), path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHeatPumpFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHealthOfEveryGatewayDevice() throws Exception {
|
||||
assertServesApiShapeOf("/api/health", "health");
|
||||
JsonNode health = JSON.readTree(get("/api/health").body());
|
||||
|
||||
assertTrue(health.get("gatewayReachable").asBoolean());
|
||||
List<String> names = new ArrayList<>();
|
||||
health.get("devices").forEach(device -> {
|
||||
names.add(device.get("name").asText());
|
||||
assertTrue(device.get("up").asBoolean(), names.getLast());
|
||||
});
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHistoryFromInflux() throws Exception {
|
||||
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
|
||||
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
|
||||
assertFalse(points.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesSetpointsWithTheBuiltInPresets() throws Exception {
|
||||
JsonNode view = JSON.readTree(get("/api/setpoints").body());
|
||||
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
|
||||
var response = post("/api/setpoints",
|
||||
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
} finally {
|
||||
custom.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertTrue(commandsSeen.isEmpty());
|
||||
} finally {
|
||||
noToken.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsNeedBothUrls() {
|
||||
var e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
|
||||
assertTrue(e.getMessage().contains("GATEWAY_URL"));
|
||||
e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
|
||||
assertTrue(e.getMessage().contains("INFLUX_URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
||||
|
||||
assertEquals(8080, settings.port());
|
||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
|
||||
var set = Settings.fromEnvironment(env);
|
||||
env.put("GATEWAY_TOKEN", " ");
|
||||
env.put("PRESETS_FILE", "");
|
||||
|
||||
assertEquals(null, none.gatewayToken());
|
||||
assertEquals(null, none.presetsFile());
|
||||
assertEquals("s3cret-token", set.gatewayToken());
|
||||
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
|
||||
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectBadValues() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
for (Map<String, String> bad : List.of(
|
||||
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
|
||||
}
|
||||
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
|
||||
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
|
||||
var env = new HashMap<>(urls);
|
||||
env.put(name, value);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
|
||||
assertTrue(e.getMessage().contains(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PresetFileTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private Path file(String json) throws IOException {
|
||||
return Files.writeString(dir.resolve("presets.json"), json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsAreBoostAndAway() {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileReplacesTheDefaults() throws Exception {
|
||||
var presets = PresetFile.load(file("""
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableFilesAreRefusedWithTheirName() throws Exception {
|
||||
for (String json : List.of(
|
||||
"not json",
|
||||
"{}",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
|
||||
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileIsRefused() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GatewayHttpClientTest {
|
||||
|
||||
private HttpServer server;
|
||||
private GatewayHttpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.start();
|
||||
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private void serve(String path, int status, String body) {
|
||||
server.createContext(path, exchange -> {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
|
||||
serve("/health", healthStatus, resource(healthFile));
|
||||
serve("/snapshot", 200, resource(snapshotFile));
|
||||
}
|
||||
|
||||
private static String resource(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/gateway", name));
|
||||
}
|
||||
|
||||
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
|
||||
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsEveryDeviceWithItsMeasurements() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
|
||||
devices.stream().map(DeviceSnapshot::name).toList());
|
||||
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
|
||||
DeviceSnapshot pump = device(devices, "heat-pump");
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
|
||||
assertNull(pump.lastError());
|
||||
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
|
||||
var measurement = pump.measurements().get(1);
|
||||
assertEquals("Dom_Pompa", measurement.database());
|
||||
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
|
||||
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheGatewayVersion() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
assertEquals("v1.2.1", client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
assertNull(client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertTrue(device(devices, "heat-pump").up());
|
||||
DeviceSnapshot deye = device(devices, "deye");
|
||||
assertFalse(deye.up());
|
||||
assertEquals("Connect timed out", deye.lastError());
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
|
||||
// The last good reading stays available, with its own time.
|
||||
var last = deye.measurements().get(0);
|
||||
assertEquals(570.0, last.fields().get("load_power_w"));
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
|
||||
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
var pump = device(devices, "heat-pump");
|
||||
assertFalse(pump.up());
|
||||
assertNull(pump.lastSuccess());
|
||||
assertEquals("Connection refused", pump.lastError());
|
||||
assertTrue(pump.measurements().isEmpty());
|
||||
assertTrue(device(devices, "deye").up());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresPropertiesItDoesNotKnow() throws Exception {
|
||||
serve("/health", 200, """
|
||||
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
|
||||
serve("/snapshot", 200, """
|
||||
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
|
||||
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
|
||||
|
||||
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenSnapshotAnswers503() throws Exception {
|
||||
serve("/health", 200, resource("health-all-up.json"));
|
||||
serve("/snapshot", 503, "[]");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnServerError() {
|
||||
serve("/health", 500, "{\"error\":\"boom\"}");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnBadJson() {
|
||||
serve("/health", 200, "<html>not json</html>");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
|
||||
server.createContext("/health", exchange -> {
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException ignored) {
|
||||
// the client gave up; nothing to answer
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
assertThrows(HttpTimeoutException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsUnreachable() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
}
|
||||
@@ -1,266 +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 home.backend.domain.RestoreResult.Outcome;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
|
||||
class GatewayHttpCommandsTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
answerCommands();
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private int status;
|
||||
private String body = "{}";
|
||||
|
||||
private void serve(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
private static String contract(String name) throws IOException {
|
||||
return Files.readString(CONTRACT.resolve(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
|
||||
|
||||
assertEquals(Status.APPLIED, result.status());
|
||||
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
|
||||
assertEquals(1, seen.size());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||
|
||||
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
|
||||
|
||||
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionIsAVerdictNotAnError() throws Exception {
|
||||
serve(422, contract("response-rejected.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
|
||||
|
||||
assertEquals(Status.REJECTED, result.status());
|
||||
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
|
||||
assertNull(result.restoreAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIsAVerdict() throws Exception {
|
||||
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
|
||||
|
||||
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSetpointsWithRestores() throws Exception {
|
||||
serve(200, contract("setpoints.json"));
|
||||
|
||||
var state = commands("s3cret-token").setpoints();
|
||||
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(4, state.setpoints().size());
|
||||
assertEquals(48, state.setpoints().get(2).max());
|
||||
assertEquals(1, state.restores().size());
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDue() throws Exception {
|
||||
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
|
||||
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
|
||||
serve(200, contract("response-restored.json"));
|
||||
|
||||
var result = commands("s3cret-token").restore("cwu_economy", "anna");
|
||||
|
||||
assertEquals(Outcome.RESTORED, result.outcome());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/restore", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
|
||||
serve(409, contract("response-restore-conflict.json"));
|
||||
|
||||
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreFailsWithoutAVerdictOrToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
|
||||
serve(200, "{\"renewed\":2}");
|
||||
|
||||
assertEquals(2, commands("s3cret-token").renewLeases());
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/renew", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).renewLeases());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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,670 +0,0 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.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.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.PiSensorService;
|
||||
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 Outcome restoreOutcome;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
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;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (commandsDown || sent.size() >= answersBeforeDrop) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
sent.add(command);
|
||||
return switch (verdict) {
|
||||
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
|
||||
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
|
||||
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
|
||||
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock));
|
||||
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("v1.2.1", List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
|
||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
List.of(new Measurement("measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), 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 String awayRequest() throws IOException {
|
||||
return example("preset-away-request").toString();
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertMatchesExample(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertEquals(example(example), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyDetailsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/energy/details", "energy-details");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement bme = devices.getLast().measurements().get(0);
|
||||
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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", "/api/energy/details")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
HttpResponse<String> response = get("/api/health");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSeriesOrRangeIs400() throws Exception {
|
||||
assertEquals(400, get("/api/history?series=nope").statusCode());
|
||||
assertEquals(400, get("/api/history").statusCode());
|
||||
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedQueryIs400NotADroppedConnection() throws Exception {
|
||||
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
|
||||
assertEquals(400, rawGet(path), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableGatewayPayloadIs502() throws Exception {
|
||||
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
|
||||
new NullPointerException("status"))) {
|
||||
gatewayBug = bug;
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(502, response.statusCode(), path);
|
||||
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
|
||||
}
|
||||
|
||||
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
|
||||
|
||||
/** A POST as the web UI sends it; extra headers come after the defaults. */
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
|
||||
all.addAll(List.of(headers));
|
||||
return rawPost(path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
|
||||
|
||||
@Test
|
||||
void setpointsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/setpoints", "setpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
restoreOutcome = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/setpoints/cwu_economy/restore";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
|
||||
assertEquals(404, post(path, "{}").statusCode(), path);
|
||||
}
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(422, response.statusCode());
|
||||
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIs502() throws Exception {
|
||||
verdict = Status.FAILED;
|
||||
|
||||
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSetpointRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
|
||||
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
|
||||
assertEquals("APPLIED", body.get("status").asText());
|
||||
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||
post("/api/presets/away", awayRequest());
|
||||
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayWithOwnValuesSendsThem() throws Exception {
|
||||
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||
values);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||
|
||||
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||
}
|
||||
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayOnSetpointsIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutJsonContentTypeAre415() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
|
||||
.statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonContentTypeMayCarryACharset() throws Exception {
|
||||
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "application/json; charset=utf-8").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutTheCustomHeaderAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
|
||||
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromAnotherOriginAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
|
||||
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
|
||||
}
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromTheSameOriginAreAccepted() throws Exception {
|
||||
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setpointMethodsAreChecked() throws Exception {
|
||||
assertEquals(405, post("/api/history", "").statusCode());
|
||||
assertEquals(405, get("/api/presets/away").statusCode());
|
||||
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
|
||||
.PUT(HttpRequest.BodyPublishers.noBody()).build();
|
||||
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiAcceptsOnlyGet() throws Exception {
|
||||
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
|
||||
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyResponseCarriesTheSecurityHeaders() throws Exception {
|
||||
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
|
||||
for (String path : paths) {
|
||||
assertSecurityHeaders(get(path), path);
|
||||
}
|
||||
assertSecurityHeaders(post("/api/health", ""), "405");
|
||||
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
|
||||
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
|
||||
}
|
||||
|
||||
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
|
||||
var headers = response.headers();
|
||||
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
headers.firstValue("Content-Security-Policy").orElse(null), what);
|
||||
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
|
||||
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRoutesMatchTheExactPathOnly() throws Exception {
|
||||
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
|
||||
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
|
||||
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopClosesThePort() {
|
||||
server.stop();
|
||||
|
||||
assertThrows(IOException.class, () -> get("/api/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
|
||||
String padding = " ".repeat(4096 - BOOST.length());
|
||||
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
|
||||
sent.clear();
|
||||
|
||||
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
|
||||
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
|
||||
assertEquals(0, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void answersOnlyTheApi() throws Exception {
|
||||
// The web UI is the frontend container's (ADR-010); the backend serves no files.
|
||||
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
}
|
||||
|
||||
/** The JDK client normalises dot segments, so send the request line as is. */
|
||||
private int rawGet(String path) throws IOException {
|
||||
try (Socket socket = new Socket("localhost", server.port())) {
|
||||
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
|
||||
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
|
||||
.lines().findFirst().orElseThrow();
|
||||
return Integer.parseInt(statusLine.split(" ")[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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,92 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
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.assertTrue;
|
||||
|
||||
class LeaseRenewerTest {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final CountDownLatch threeCalls = new CountDownLatch(3);
|
||||
private Exception failure;
|
||||
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
calls.incrementAndGet();
|
||||
threeCalls.countDown();
|
||||
if (failure instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
if (failure instanceof RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void renewsAtOnceAndThenEveryPeriod() throws Exception {
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
|
||||
failure = new IllegalStateException("bad answer");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewDoesNotThrowOutOfTheScheduler() {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.renew();
|
||||
}
|
||||
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.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.PiSensorView;
|
||||
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("v1.2.1", 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 PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
|
||||
return new PiSensorService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
|
||||
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertFalse(view.stale());
|
||||
assertEquals(20.17, view.temperature());
|
||||
assertEquals(45.82, view.humidity());
|
||||
assertEquals(985.8, view.pressure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorShowsNoValues() throws IOException {
|
||||
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
|
||||
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.temperature());
|
||||
assertTrue(piSensor().stale());
|
||||
assertNull(piSensor().time());
|
||||
}
|
||||
|
||||
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 EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
|
||||
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
|
||||
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
|
||||
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
|
||||
|
||||
assertFalse(deye.stale());
|
||||
assertEquals(READ, deye.time());
|
||||
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
|
||||
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
|
||||
|
||||
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
|
||||
|
||||
assertTrue(deye.stale());
|
||||
assertEquals(old, deye.time());
|
||||
assertTrue(deye.groups().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
|
||||
EnergyDetails all = details();
|
||||
|
||||
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
|
||||
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
|
||||
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
|
||||
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
|
||||
|
||||
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
|
||||
fronius.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsReadTheHouseMeterPerPhase() throws IOException {
|
||||
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
|
||||
|
||||
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
|
||||
meter.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
|
||||
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
|
||||
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
|
||||
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
|
||||
|
||||
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
|
||||
|
||||
assertEquals("Energia na fazę", phases.title());
|
||||
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsFailWhenTheGatewayIsUnreachable() {
|
||||
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
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, "v1.3.0").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 healthReportsBothVersions() {
|
||||
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertEquals("v1.2.1", view.gatewayVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertNull(view.gatewayVersion());
|
||||
}
|
||||
|
||||
/** 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,298 +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.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
|
||||
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 final List<String> restored = 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 RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(Outcome.RESTORED, "restored");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@Test
|
||||
void restoreAsksTheGatewayInTheUsersName() throws Exception {
|
||||
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
|
||||
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIsAnIoError() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
|
||||
}
|
||||
|
||||
@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", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("not below the current 19"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, 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", null, Map.of(), "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", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "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", RETURN, Map.of(), "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", RETURN, Map.of(), "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", RETURN, Map.of(), "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", RETURN, Map.of(), "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", RETURN, Map.of(), "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", RETURN, Map.of(), "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,63 +0,0 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Bateria", "items": [
|
||||
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Dziś", "items": [
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Teraz", "items": [
|
||||
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Produkcja", "items": [
|
||||
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Prąd", "items": [
|
||||
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Współczynnik mocy", "items": [
|
||||
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Energia od początku", "items": [
|
||||
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,10 +0,0 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"backendVersion": "v1.3.0",
|
||||
"gatewayVersion": "v1.2.1",
|
||||
"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,7 +0,0 @@
|
||||
{
|
||||
"time": "2026-10-23T21:00:00Z",
|
||||
"stale": true,
|
||||
"temperature": null,
|
||||
"humidity": null,
|
||||
"pressure": null
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"temperature": 20.17,
|
||||
"humidity": 45.82,
|
||||
"pressure": 985.8
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00" }
|
||||
@@ -1 +0,0 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
|
||||
@@ -1 +0,0 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -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,27 +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",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"status":"UP","version":"v1.2.1","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"}
|
||||
@@ -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>.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
Loaded 100 of 2405 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user