Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a3582c45 | ||
|
|
649751a0d3 | ||
|
|
1e5c40b5d0 | ||
|
|
b2b946d4bc | ||
|
|
4afc1c9ed0 | ||
|
|
5d8d31c8e1 | ||
|
|
3d47764c2f | ||
|
|
36093eb8dc | ||
|
|
074bd7b78e | ||
|
|
d4a9f2fa25 | ||
|
|
49b5689b96 | ||
|
|
b17a84e3dd | ||
|
|
7402a1d6a2 | ||
|
|
ab4fdef6c8 | ||
|
|
f504df7e88 | ||
|
|
ce1c733b9f | ||
|
|
00755cc560 | ||
|
|
f7c36ab1f6 | ||
|
|
c4b4715699 | ||
|
|
bc47260e86 | ||
|
|
2d62ac1d87 | ||
|
|
fce624b822 | ||
|
|
af51ed0626 | ||
|
|
088de5ac1d | ||
|
|
6ee291b094 | ||
|
|
e95f9b89f1 | ||
|
|
43e9086689 | ||
|
|
d74fdbde34 | ||
|
|
e3c27475ba | ||
|
|
85600d70ef | ||
|
|
b1fce8ad31 | ||
|
|
39b682f2ff | ||
|
|
80c66461bf | ||
|
|
d7f27af997 | ||
|
|
7cbbc788ef | ||
|
|
e901d4c351 | ||
|
|
7263c9e3c3 | ||
|
|
5dacbfd45e | ||
|
|
b06dde4587 | ||
|
|
2c214c889d | ||
|
|
0c0ca68fbf | ||
|
|
17b65dc1c0 | ||
|
|
65f96d3f41 | ||
|
|
a1a0853d65 | ||
|
|
5f2dc81107 | ||
|
|
6d7704964a | ||
|
|
0f7939fa6e | ||
|
|
a36ef14f0b | ||
|
|
a336851cd7 | ||
|
|
b74ada8cb6 | ||
|
|
070b61c785 | ||
|
|
b58fe88ee0 | ||
|
|
f29728cb9f | ||
|
|
b7f62d95aa | ||
|
|
d010c51e2a | ||
|
|
5073e0a1b3 | ||
|
|
377349cf0e | ||
|
|
2b6cfd3496 | ||
|
|
79cf69eed3 | ||
|
|
af6f829da9 | ||
|
|
6f2f7cdfa0 | ||
|
|
daa8de3235 | ||
|
|
cfabd96bb2 | ||
|
|
5e0939b171 | ||
|
|
ff1dce8588 | ||
|
|
8829372f63 | ||
|
|
38dbcfe1db | ||
|
|
da2d2e30c6 | ||
|
|
70dabd6bce | ||
|
|
1c623d496c | ||
|
|
3603491617 | ||
|
|
09fb9ad6e4 | ||
|
|
ef3662df1b | ||
|
|
38c023c493 | ||
|
|
422258de72 | ||
|
|
404aa3c367 | ||
|
|
1027410383 | ||
|
|
6348726b82 | ||
|
|
cd4259b175 | ||
|
|
a7a6dd2caf | ||
|
|
e56c1cdad6 | ||
|
|
13770b44ce | ||
|
|
82ff6565ad | ||
|
|
a6e6f36e87 | ||
|
|
e835f1adfa | ||
|
|
098be6af19 | ||
|
|
2343fe410f | ||
|
|
e2f3b58777 | ||
|
|
322f4834a8 | ||
|
|
d8a9716192 | ||
|
|
1cc8a3b545 | ||
|
|
9ec0ae2425 | ||
|
|
f84bf5a953 | ||
|
|
d6531054ec | ||
|
|
01fb331e22 | ||
|
|
15a3fc953e | ||
|
|
e591c125cc | ||
|
|
5b75c3b181 | ||
|
|
08650bd0f7 | ||
|
|
4446499996 | ||
|
|
3813e010ae | ||
|
|
72960e74d0 | ||
|
|
ceae74efd1 | ||
|
|
dd02947bb2 | ||
|
|
fb72010a12 | ||
|
|
860aaa00e0 | ||
|
|
a22788ef48 | ||
|
|
1c74044ad0 | ||
|
|
1f114367b6 | ||
|
|
cf75f55c6b | ||
|
|
7cf4576e49 | ||
|
|
a1e749e4ad | ||
|
|
1a8e8e8585 | ||
|
|
eb0b23637b | ||
|
|
0db35d06f8 | ||
|
|
5be8ce49ef | ||
|
|
f0cbcb00f5 | ||
|
|
e8aa40f93f | ||
|
|
cac625e0e9 | ||
|
|
a025bf8298 | ||
|
|
ae5f2536c8 | ||
|
|
f71d112df7 | ||
|
|
a713147a30 | ||
|
|
1f9e245f2e | ||
|
|
da461bdf27 | ||
|
|
e90f28dbc3 | ||
|
|
d5328e40fc | ||
|
|
df96cb73d1 | ||
|
|
58ffc2e056 | ||
|
|
649d2e9cdd | ||
|
|
ae2e0ae896 | ||
|
|
4c4b2143ac | ||
|
|
996b6805a4 | ||
|
|
6c6f071a80 | ||
|
|
722b67b252 | ||
|
|
98fe849254 | ||
|
|
08f7bb8a8f | ||
|
|
3705a57155 | ||
|
|
3b15451874 | ||
|
|
4db8520303 | ||
|
|
e9a3c45d89 | ||
|
|
51a68f0d44 | ||
|
|
15c5b7a7af | ||
|
|
d2873efc7c | ||
|
|
3be02ed3a7 | ||
|
|
6a10fd5ccb | ||
|
|
4e92d16f41 | ||
|
|
5d3201f7e3 | ||
|
|
821f818b1e | ||
|
|
b9c401f077 | ||
|
|
f6f07c4225 | ||
|
|
2d61ce19f4 | ||
|
|
80873d748c | ||
|
|
df2d588e33 | ||
|
|
04a17041ec | ||
|
|
45ab01cdd0 | ||
|
|
797920bb82 | ||
|
|
77e3e0cb2a | ||
|
|
162e557334 | ||
|
|
2b06a5f344 | ||
|
|
ce95b7786a | ||
|
|
4669f430ec | ||
|
|
b328848b87 | ||
|
|
b99414d829 | ||
|
|
79c67f334c | ||
|
|
60f35b01a6 | ||
|
|
b0c4961cda | ||
|
|
7f2e98a781 | ||
|
|
1e43d9d7fb |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ship
|
||||
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally deploy the gateway to the Pi. Use when a change is ready to merge or release.
|
||||
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
|
||||
@@ -23,8 +23,9 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
||||
`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. Deploy only with the user's explicit approval for this deploy, and only for changes that
|
||||
touch the gateway: `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor
|
||||
for features; `PI=<ssh host>` while D7 is open). Then watch
|
||||
`ssh <host> journalctl -u home-pump.service -f`
|
||||
and Grafana for one polling cycle (300 s) and report what you saw.
|
||||
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).
|
||||
@@ -6,20 +6,14 @@ on:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests (Maven / Java 21)
|
||||
check:
|
||||
name: Check (gateway, backend, frontend)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Set up Java 21 + Maven
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -28,5 +22,9 @@ jobs:
|
||||
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
|
||||
@@ -0,0 +1,56 @@
|
||||
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
|
||||
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariuszpacek.pl
|
||||
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
|
||||
- name: Docker CLI
|
||||
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
||||
# final stage, so the arm64 image needs no emulation.
|
||||
- name: Gateway
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
- name: Backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
- name: Frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
+4
-2
@@ -18,7 +18,7 @@ target/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.java-version
|
||||
/dependency-reduced-pom.xml
|
||||
dependency-reduced-pom.xml
|
||||
/workspace.code-workspace
|
||||
/target/
|
||||
|
||||
@@ -26,7 +26,6 @@ target/
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.env*
|
||||
/dist/
|
||||
|
||||
# Vite
|
||||
@@ -56,3 +55,6 @@ coverage/
|
||||
/apk/
|
||||
/old/
|
||||
/old_backup/
|
||||
|
||||
# Agent worktrees
|
||||
/.claude/worktrees/
|
||||
@@ -6,8 +6,9 @@ Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP)
|
||||
|
||||
- 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`, service `home-pump.service`. Read-only inspection is fine;
|
||||
deploy, restart or file changes on the Pi need explicit approval each time.
|
||||
- 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.
|
||||
|
||||
+7
-5
@@ -19,7 +19,8 @@ Reasons: [docs/decisions/](docs/decisions/).
|
||||
|
||||
## Code
|
||||
|
||||
- Java 21, Maven, no framework (ADR-001). `mvn clean package`, `mvn test`.
|
||||
- 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.
|
||||
@@ -54,7 +55,8 @@ Docs are written so an agent needs little context: read only what the task touch
|
||||
|
||||
## Configuration, secrets, releases
|
||||
|
||||
- No secrets in git. Production config lives on the Pi, outside the jar.
|
||||
- Deploy only from `master` with `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH`; it tags the
|
||||
deploy and rolls back if the service does not come up healthy.
|
||||
- After a deploy, watch the journal and Grafana for at least one polling cycle.
|
||||
- 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.
|
||||
@@ -1,168 +1,25 @@
|
||||
# Home Pump Monitor
|
||||
# home-pump
|
||||
|
||||
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
|
||||
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
|
||||
|
||||
## Features
|
||||
- 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)
|
||||
|
||||
- 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
|
||||
| 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) |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
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.
|
||||
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
|
||||
backend and frontend images.
|
||||
@@ -0,0 +1 @@
|
||||
target
|
||||
@@ -0,0 +1,19 @@
|
||||
# Built on the build machine's own platform; the image only holds the jar and a JRE.
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /src
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q dependency:go-offline
|
||||
COPY src src
|
||||
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
EXPOSE 8080
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Backend
|
||||
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
|
||||
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
|
||||
| `PORT` | `8080` | listening port |
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.home</groupId>
|
||||
<artifactId>home-pump-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<jackson.version>2.20.1</jackson.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.32</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>home.backend.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,41 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.SetpointService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
||||
*/
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
|
||||
settings.gatewayTimeout());
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock), setpoints);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
public final class Main {
|
||||
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
server = Backend.assemble(settings, Clock.systemUTC());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record DeviceReadings(String name, List<Reading> readings) {
|
||||
}
|
||||
|
||||
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
|
||||
// Unknown properties are ignored so the gateway can add fields without breaking us.
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI baseUri;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
public GatewayHttpClient(URI baseUri, Duration timeout) {
|
||||
this.baseUri = baseUri;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewaySnapshot snapshot() throws IOException {
|
||||
var health = JSON.readValue(get("/health", true), Health.class);
|
||||
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
|
||||
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
|
||||
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
|
||||
var devices = health.devices().stream()
|
||||
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
return device.readings().stream()
|
||||
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** A gateway with a device down answers /health with 503 and a valid body. */
|
||||
private byte[] get(String path, boolean downIsValid) throws IOException {
|
||||
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
|
||||
try {
|
||||
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status != 200 && !(downIsValid && status == 503)) {
|
||||
throw new IOException("gateway " + path + " answered " + status);
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway /commands", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
|
||||
public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
private static final int MAX_BODY_BYTES = 4096;
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void context(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(STOP_GRACE_SECONDS);
|
||||
executor.shutdown();
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
|
||||
private interface Gateway {
|
||||
Object get(Map<String, String> query) throws IOException;
|
||||
}
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
send(exchange, 403, error("cross-site request"));
|
||||
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
|
||||
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
|
||||
} else if (!isJson(headers.getFirst("Content-Type"))) {
|
||||
send(exchange, 415, error("expected Content-Type: application/json"));
|
||||
} else {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
|
||||
} else {
|
||||
write.run(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isJson(String contentType) {
|
||||
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
|
||||
}
|
||||
|
||||
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, setpoints.current());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, SetpointRequest.class);
|
||||
if (request == null || request.setpoint() == null || request.value() == null) {
|
||||
throw new IllegalArgumentException("setpoint and value are required");
|
||||
}
|
||||
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
|
||||
send(exchange, httpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var result = setpoints.applyPreset(id, user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED, PARTIAL -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, history.history(query.get("series"), query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("history unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
try (exchange) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
|
||||
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
|
||||
if (exchange.getResponseCode() == -1) {
|
||||
send(exchange, 502, error("bad gateway response"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
|
||||
private static Map<String, String> error(String message) {
|
||||
return Map.of("error", message);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseQuery(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
if (raw == null) {
|
||||
return query;
|
||||
}
|
||||
for (String pair : raw.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static String decode(String s) {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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("\"", "\\\"") + '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
private record Inverter(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Inverter deye = inverter(snapshot, "deye");
|
||||
Inverter fronius = inverter(snapshot, "fronius");
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private Inverter inverter(GatewaySnapshot snapshot, String name) {
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
|
||||
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Inverter(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
}
|
||||
|
||||
/** Sum of the values that exist; null when none does. */
|
||||
private static Double sum(Double... values) {
|
||||
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
|
||||
}
|
||||
|
||||
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
|
||||
private static Double round(Double kwh) {
|
||||
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The live state, read from the gateway on the Pi through the tunnel. */
|
||||
public interface GatewayClient {
|
||||
|
||||
GatewaySnapshot snapshot() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The only way the backend writes anything: through the gateway, which checks every command. */
|
||||
public interface GatewayCommands {
|
||||
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
|
||||
/** F5: whether the gateway answers and which device links are up. */
|
||||
public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private static DeviceHealth health(DeviceSnapshot d) {
|
||||
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
|
||||
public interface HistoryRepository {
|
||||
|
||||
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
|
||||
this.gateway = gateway;
|
||||
this.presets = List.copyOf(presets);
|
||||
}
|
||||
|
||||
public SetpointsView current() throws IOException {
|
||||
SetpointState state = gateway.setpoints();
|
||||
return new SetpointsView(state.setpoints(), state.restores(), presets);
|
||||
}
|
||||
|
||||
/** @param hours how long to keep the value, null until changed back */
|
||||
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, user)));
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, String user) {
|
||||
try {
|
||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
||||
String problem = problem(change, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
}
|
||||
if (!known.get().allows(change.value())) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Duration duration(Double hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Math.round(hours * 60);
|
||||
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
|
||||
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
|
||||
}
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
|
||||
final class SnapshotFields {
|
||||
|
||||
private SnapshotFields() {
|
||||
}
|
||||
|
||||
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
|
||||
private static final Duration MAX_AGE = Duration.ofSeconds(600);
|
||||
|
||||
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
|
||||
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
|
||||
}
|
||||
|
||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
.map(DeviceSnapshot::measurements)
|
||||
.flatMap(List::stream)
|
||||
.filter(m -> m.measurement().equals(measurement))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static Integer whole(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
static boolean on(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value != null && value != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
}
|
||||
|
||||
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
|
||||
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** One series over a time range, already averaged into chart-sized steps. */
|
||||
public record HistoryView(String series, String unit, List<Point> points) {
|
||||
|
||||
public record Point(Instant t, double v) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
||||
public record Preset(String id, String name, List<Change> changes) {
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
|
||||
public Change {
|
||||
if (setpoint == null || setpoint.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset change needs a setpoint");
|
||||
}
|
||||
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
|
||||
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
}
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
|
||||
public enum Series {
|
||||
OUTDOOR_TEMP("outdoor_temp", "°C"),
|
||||
INDOOR_TEMP("indoor_temp", "°C"),
|
||||
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
|
||||
CWU_TEMP("cwu_temp", "°C"),
|
||||
GROUND_IN_TEMP("ground_in_temp", "°C"),
|
||||
GROUND_OUT_TEMP("ground_out_temp", "°C"),
|
||||
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
|
||||
GRID_POWER("grid_power", "W"),
|
||||
PV_POWER("pv_power", "W"),
|
||||
LOAD_POWER("load_power", "W"),
|
||||
BATTERY_SOC("battery_soc", "%");
|
||||
|
||||
private final String id;
|
||||
private final String unit;
|
||||
|
||||
Series(String id, String unit) {
|
||||
this.id = id;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String unit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public static Optional<Series> fromId(String id) {
|
||||
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,307 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
|
||||
class BackendTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
private ApiServer backend;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
gateway = stub(Map.of(
|
||||
"/health", "src/test/resources/gateway/health-all-up.json",
|
||||
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private record Command(String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Command> commandsSeen = new ArrayList<>();
|
||||
|
||||
/** The gateway's /commands, answering with the gateway's own contract examples. */
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
}
|
||||
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
backend.stop();
|
||||
gateway.stop(0);
|
||||
influx.stop(0);
|
||||
}
|
||||
|
||||
private static HttpServer stub(Map<String, String> bodies) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
|
||||
byte[] body = Files.readAllBytes(Path.of(file));
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}));
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private static URI url(HttpServer server) {
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** The names of every field, nested ones as a.b; array elements are not descended into. */
|
||||
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
|
||||
node.fieldNames().forEachRemaining(name -> {
|
||||
names.add(prefix + name);
|
||||
fieldNames(node.get(name), prefix + name + ".", names);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
private void assertServesApiShapeOf(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
|
||||
assertEquals(fieldNames(expected, "", new TreeSet<>()),
|
||||
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
|
||||
}
|
||||
|
||||
/** The body equals the documented example, value by value. */
|
||||
private void assertServesExactly(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
|
||||
JSON.readTree(response.body()), path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHeatPumpFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHealthOfEveryGatewayDevice() throws Exception {
|
||||
assertServesApiShapeOf("/api/health", "health");
|
||||
JsonNode health = JSON.readTree(get("/api/health").body());
|
||||
|
||||
assertTrue(health.get("gatewayReachable").asBoolean());
|
||||
List<String> names = new ArrayList<>();
|
||||
health.get("devices").forEach(device -> {
|
||||
names.add(device.get("name").asText());
|
||||
assertTrue(device.get("up").asBoolean(), names.getLast());
|
||||
});
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHistoryFromInflux() throws Exception {
|
||||
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
|
||||
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
|
||||
assertFalse(points.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesSetpointsWithTheBuiltInPresets() throws Exception {
|
||||
JsonNode view = JSON.readTree(get("/api/setpoints").body());
|
||||
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
|
||||
var response = post("/api/setpoints",
|
||||
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
} finally {
|
||||
custom.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertTrue(commandsSeen.isEmpty());
|
||||
} finally {
|
||||
noToken.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsNeedBothUrls() {
|
||||
var e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
|
||||
assertTrue(e.getMessage().contains("GATEWAY_URL"));
|
||||
e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
|
||||
assertTrue(e.getMessage().contains("INFLUX_URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
||||
|
||||
assertEquals(8080, settings.port());
|
||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
|
||||
var set = Settings.fromEnvironment(env);
|
||||
env.put("GATEWAY_TOKEN", " ");
|
||||
env.put("PRESETS_FILE", "");
|
||||
|
||||
assertEquals(null, none.gatewayToken());
|
||||
assertEquals(null, none.presetsFile());
|
||||
assertEquals("s3cret-token", set.gatewayToken());
|
||||
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
|
||||
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectBadValues() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
for (Map<String, String> bad : List.of(
|
||||
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
|
||||
}
|
||||
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
|
||||
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
|
||||
var env = new HashMap<>(urls);
|
||||
env.put(name, value);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
|
||||
assertTrue(e.getMessage().contains(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PresetFileTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private Path file(String json) throws IOException {
|
||||
return Files.writeString(dir.resolve("presets.json"), json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsAreBoostAndAway() {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileReplacesTheDefaults() throws Exception {
|
||||
var presets = PresetFile.load(file("""
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableFilesAreRefusedWithTheirName() throws Exception {
|
||||
for (String json : List.of(
|
||||
"not json",
|
||||
"{}",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
|
||||
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileIsRefused() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GatewayHttpClientTest {
|
||||
|
||||
private HttpServer server;
|
||||
private GatewayHttpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.start();
|
||||
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private void serve(String path, int status, String body) {
|
||||
server.createContext(path, exchange -> {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
|
||||
serve("/health", healthStatus, resource(healthFile));
|
||||
serve("/snapshot", 200, resource(snapshotFile));
|
||||
}
|
||||
|
||||
private static String resource(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/gateway", name));
|
||||
}
|
||||
|
||||
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
|
||||
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsEveryDeviceWithItsMeasurements() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
|
||||
devices.stream().map(DeviceSnapshot::name).toList());
|
||||
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
|
||||
DeviceSnapshot pump = device(devices, "heat-pump");
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
|
||||
assertNull(pump.lastError());
|
||||
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
|
||||
var measurement = pump.measurements().get(1);
|
||||
assertEquals("Dom_Pompa", measurement.database());
|
||||
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
|
||||
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertTrue(device(devices, "heat-pump").up());
|
||||
DeviceSnapshot deye = device(devices, "deye");
|
||||
assertFalse(deye.up());
|
||||
assertEquals("Connect timed out", deye.lastError());
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
|
||||
// The last good reading stays available, with its own time.
|
||||
var last = deye.measurements().get(0);
|
||||
assertEquals(570.0, last.fields().get("load_power_w"));
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
|
||||
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
var pump = device(devices, "heat-pump");
|
||||
assertFalse(pump.up());
|
||||
assertNull(pump.lastSuccess());
|
||||
assertEquals("Connection refused", pump.lastError());
|
||||
assertTrue(pump.measurements().isEmpty());
|
||||
assertTrue(device(devices, "deye").up());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresPropertiesItDoesNotKnow() throws Exception {
|
||||
serve("/health", 200, """
|
||||
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
|
||||
serve("/snapshot", 200, """
|
||||
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
|
||||
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
|
||||
|
||||
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenSnapshotAnswers503() throws Exception {
|
||||
serve("/health", 200, resource("health-all-up.json"));
|
||||
serve("/snapshot", 503, "[]");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnServerError() {
|
||||
serve("/health", 500, "{\"error\":\"boom\"}");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnBadJson() {
|
||||
serve("/health", 200, "<html>not json</html>");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
|
||||
server.createContext("/health", exchange -> {
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException ignored) {
|
||||
// the client gave up; nothing to answer
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
assertThrows(HttpTimeoutException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsUnreachable() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
|
||||
class GatewayHttpCommandsTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
answerCommands();
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private int status;
|
||||
private String body = "{}";
|
||||
|
||||
private void serve(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
private static String contract(String name) throws IOException {
|
||||
return Files.readString(CONTRACT.resolve(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
|
||||
|
||||
assertEquals(Status.APPLIED, result.status());
|
||||
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
|
||||
assertEquals(1, seen.size());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
|
||||
|
||||
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionIsAVerdictNotAnError() throws Exception {
|
||||
serve(422, contract("response-rejected.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
|
||||
|
||||
assertEquals(Status.REJECTED, result.status());
|
||||
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
|
||||
assertNull(result.restoreAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIsAVerdict() throws Exception {
|
||||
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
|
||||
|
||||
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSetpointsWithRestores() throws Exception {
|
||||
serve(200, contract("setpoints.json"));
|
||||
|
||||
var state = commands("s3cret-token").setpoints();
|
||||
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(4, state.setpoints().size());
|
||||
assertEquals(48, state.setpoints().get(2).max());
|
||||
assertEquals(1, state.restores().size());
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
+ "\"value\":null}],\"restores\":[]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().setpoints().getFirst().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherStatusesAreNoVerdict() throws Exception {
|
||||
for (int status : List.of(401, 404, 500, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints(), "GET " + status);
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")), "POST " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableAnswerIsNoVerdict() {
|
||||
serve(200, "<html>proxy</html>");
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenNothingIsSent() {
|
||||
serve(200, "{}");
|
||||
var commands = commands(null);
|
||||
|
||||
assertThrows(IOException.class, () -> commands.setpoints());
|
||||
assertThrows(IOException.class, () -> commands.apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
assertTrue(seen.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIsAnIOException() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints());
|
||||
}
|
||||
|
||||
/** A contract file by name, or JSON text as is, parsed so that formatting does not matter. */
|
||||
private static JsonNode contractJson(String nameOrJson) throws IOException {
|
||||
return JSON.readTree(nameOrJson.endsWith(".json") ? contract(nameOrJson) : nameOrJson);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ApiServerTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Instant HEALTH_TIME = Instant.parse("2026-10-23T22:14:00Z");
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private boolean commandsDown;
|
||||
private int answersBeforeDrop;
|
||||
private ApiServer server;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
snapshot = exampleSnapshot();
|
||||
GatewayClient gateway = () -> {
|
||||
if (gatewayDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
if (gatewayBug != null) {
|
||||
throw gatewayBug;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
HistoryRepository history = (series, from, to, max) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(new Point(Instant.parse("2026-10-23T10:00:00Z"), 14.1),
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (commandsDown || sent.size() >= answersBeforeDrop) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
sent.add(command);
|
||||
return switch (verdict) {
|
||||
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
|
||||
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
|
||||
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
|
||||
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
|
||||
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
|
||||
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
|
||||
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
|
||||
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
return new GatewaySnapshot(List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertMatchesExample(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertEquals(example(example), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot(devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
HttpResponse<String> response = get("/api/health");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSeriesOrRangeIs400() throws Exception {
|
||||
assertEquals(400, get("/api/history?series=nope").statusCode());
|
||||
assertEquals(400, get("/api/history").statusCode());
|
||||
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedQueryIs400NotADroppedConnection() throws Exception {
|
||||
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
|
||||
assertEquals(400, rawGet(path), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableGatewayPayloadIs502() throws Exception {
|
||||
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
|
||||
new NullPointerException("status"))) {
|
||||
gatewayBug = bug;
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(502, response.statusCode(), path);
|
||||
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
|
||||
}
|
||||
|
||||
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
|
||||
|
||||
/** A POST as the web UI sends it; extra headers come after the defaults. */
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
|
||||
all.addAll(List.of(headers));
|
||||
return rawPost(path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
|
||||
|
||||
@Test
|
||||
void setpointsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/setpoints", "setpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(422, response.statusCode());
|
||||
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIs502() throws Exception {
|
||||
verdict = Status.FAILED;
|
||||
|
||||
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSetpointRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
|
||||
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", "");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
|
||||
assertEquals("APPLIED", body.get("status").asText());
|
||||
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayOnSetpointsIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutJsonContentTypeAre415() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
|
||||
.statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonContentTypeMayCarryACharset() throws Exception {
|
||||
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "application/json; charset=utf-8").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutTheCustomHeaderAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
|
||||
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromAnotherOriginAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
|
||||
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
|
||||
}
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromTheSameOriginAreAccepted() throws Exception {
|
||||
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setpointMethodsAreChecked() throws Exception {
|
||||
assertEquals(405, post("/api/history", "").statusCode());
|
||||
assertEquals(405, get("/api/presets/away").statusCode());
|
||||
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
|
||||
.PUT(HttpRequest.BodyPublishers.noBody()).build();
|
||||
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiAcceptsOnlyGet() throws Exception {
|
||||
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
|
||||
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyResponseCarriesTheSecurityHeaders() throws Exception {
|
||||
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
|
||||
for (String path : paths) {
|
||||
assertSecurityHeaders(get(path), path);
|
||||
}
|
||||
assertSecurityHeaders(post("/api/health", ""), "405");
|
||||
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
|
||||
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
|
||||
}
|
||||
|
||||
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
|
||||
var headers = response.headers();
|
||||
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
headers.firstValue("Content-Security-Policy").orElse(null), what);
|
||||
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
|
||||
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRoutesMatchTheExactPathOnly() throws Exception {
|
||||
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
|
||||
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
|
||||
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopClosesThePort() {
|
||||
server.stop();
|
||||
|
||||
assertThrows(IOException.class, () -> get("/api/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
|
||||
String padding = " ".repeat(4096 - BOOST.length());
|
||||
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
|
||||
sent.clear();
|
||||
|
||||
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
|
||||
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
|
||||
assertEquals(0, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void answersOnlyTheApi() throws Exception {
|
||||
// The web UI is the frontend container's (ADR-010); the backend serves no files.
|
||||
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
}
|
||||
|
||||
/** The JDK client normalises dot segments, so send the request line as is. */
|
||||
private int rawGet(String path) throws IOException {
|
||||
try (Socket socket = new Socket("localhost", server.port())) {
|
||||
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
|
||||
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
|
||||
.lines().findFirst().orElseThrow();
|
||||
return Integer.parseInt(statusLine.split(" ")[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ServicesTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private static DeviceSnapshot device(String name, String database, String measurement,
|
||||
Map<String, Double> fields) {
|
||||
return new DeviceSnapshot(name, true, READ, null,
|
||||
List.of(new Measurement(database, measurement, fields, READ)));
|
||||
}
|
||||
|
||||
private static GatewayClient gateway(DeviceSnapshot... devices) {
|
||||
return () -> new GatewaySnapshot(List.of(devices));
|
||||
}
|
||||
|
||||
private static GatewayClient unreachable() {
|
||||
return () -> {
|
||||
throw new IOException("tunnel down");
|
||||
};
|
||||
}
|
||||
|
||||
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
|
||||
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
|
||||
.current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpStateComesFromPumpMode() throws IOException {
|
||||
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
|
||||
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
|
||||
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
|
||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
|
||||
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertEquals(66, view.compressorPercent());
|
||||
assertTrue(view.heaterOn());
|
||||
assertTrue(view.alarm());
|
||||
assertTrue(view.pumps().cwu());
|
||||
assertFalse(view.pumps().co());
|
||||
assertEquals(8, view.setpoints().cwuEconomy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpAlarmCodeRaisesTheAlarmAndIsNamedInPolish() throws IOException {
|
||||
HeatPumpView sensor = heatPump(Map.of("alarm", 0.0, "alarm_code", 3.0));
|
||||
HeatPumpView pressure = heatPump(Map.of("alarm_code", 1003.0));
|
||||
HeatPumpView unknown = heatPump(Map.of("alarm_code", 77.0));
|
||||
HeatPumpView healthy = heatPump(Map.of("alarm", 0.0, "alarm_code", 0.0));
|
||||
|
||||
assertTrue(sensor.alarm());
|
||||
assertEquals(3, sensor.alarmCode());
|
||||
assertEquals("Usterka czujnika temperatury CO", sensor.alarmText());
|
||||
assertEquals("Zadziałał presostat (ciśnienie)", pressure.alarmText());
|
||||
assertEquals("Kod usterki 77", unknown.alarmText());
|
||||
assertFalse(healthy.alarm());
|
||||
assertNull(healthy.alarmText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMissingFieldsAreNull() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of());
|
||||
|
||||
assertNull(view.state());
|
||||
assertNull(view.compressorPercent());
|
||||
assertNull(view.temperatures().outdoor());
|
||||
assertNull(view.targets().cwu());
|
||||
assertNull(view.setpoints().coComfort());
|
||||
assertFalse(view.alarm());
|
||||
assertNull(view.alarmCode());
|
||||
assertNull(view.alarmText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpTemperaturesDropBinaryNoiseOfTheGatewayScaling() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("indoor_temp", 212 * 0.1, "target_co_temp", 310 * 0.1,
|
||||
"outdoor_temp", -43 * 0.1));
|
||||
|
||||
assertEquals(21.2, view.temperatures().indoor());
|
||||
assertEquals(31.0, view.targets().co());
|
||||
assertEquals(-4.3, view.temperatures().outdoor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
|
||||
HeatPumpService service = new HeatPumpService(gateway(
|
||||
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
|
||||
|
||||
HeatPumpView view = service.current();
|
||||
|
||||
assertNull(view.state());
|
||||
assertNull(view.time());
|
||||
assertTrue(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutAReadingHasNoTimeAndIsStale() throws IOException {
|
||||
HeatPumpView view = new HeatPumpService(gateway(), CLOCK).current();
|
||||
|
||||
assertNull(view.time());
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.state());
|
||||
assertNull(view.temperatures().outdoor());
|
||||
assertNull(view.setpoints().cwuComfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPumpModeOutOfRangeHasNoState() throws IOException {
|
||||
assertNull(heatPump(Map.of("pump_mode", 4.0)).state());
|
||||
assertNull(heatPump(Map.of("pump_mode", -1.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpIsFreshOnlyWhileUpAndNotOlderThanTenMinutes() throws IOException {
|
||||
assertFalse(heatPump(Map.of("pump_mode", 0.0)).stale());
|
||||
assertTrue(heatPumpRead(true, NOW.minusSeconds(601)).stale());
|
||||
assertFalse(heatPumpRead(true, NOW.minusSeconds(600)).stale());
|
||||
HeatPumpView down = heatPumpRead(false, READ);
|
||||
assertTrue(down.stale());
|
||||
assertEquals(READ, down.time());
|
||||
}
|
||||
|
||||
private static HeatPumpView heatPumpRead(boolean up, Instant time) throws IOException {
|
||||
var reading = new Measurement("Dom_Pompa", "heat_pump", Map.of("pump_mode", 0.0), time);
|
||||
return new HeatPumpService(gateway(new DeviceSnapshot("heat-pump", up, time, null, List.of(reading))),
|
||||
CLOCK).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPropagatesGatewayFailure() {
|
||||
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
|
||||
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
|
||||
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
|
||||
|
||||
@Test
|
||||
void energySumsPvOfBothInverters() throws IOException {
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE),
|
||||
device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(3500.0, view.pvPowerW());
|
||||
assertEquals(14.4, view.today().pvKwh());
|
||||
assertEquals(-200.0, view.gridPowerW());
|
||||
assertEquals(80.0, view.batterySocPct());
|
||||
assertNull(view.batteryPowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE));
|
||||
|
||||
assertEquals(1500.0, view.pvPowerW());
|
||||
assertEquals(10.0, view.today().pvKwh());
|
||||
assertEquals(READ, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
|
||||
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutSourcesHasNoTimeAndBothSourcesStale() throws IOException {
|
||||
EnergyView view = energy();
|
||||
|
||||
assertNull(view.pvPowerW());
|
||||
assertNull(view.today().pvKwh());
|
||||
assertNull(view.time());
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
|
||||
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithStaleDeyeKeepsOnlyFreshFroniusAndTheOldestTime() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
|
||||
EnergyView view = energy(deyeRead(true, old), device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(old, view.time());
|
||||
assertEquals(List.of(new Source("deye", old, true), new Source("fronius", READ, false)), view.sources());
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.batteryPowerW());
|
||||
assertNull(view.batterySocPct());
|
||||
assertNull(view.loadPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithStaleFroniusKeepsDeyeAlone() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var fronius = new DeviceSnapshot("fronius", true, old, null,
|
||||
List.of(new Measurement("home", "fronius", FRONIUS, old)));
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE), fronius);
|
||||
|
||||
assertEquals(1500.0, view.pvPowerW());
|
||||
assertEquals(10.0, view.today().pvKwh());
|
||||
assertEquals(80.0, view.batterySocPct());
|
||||
assertEquals(old, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energySourceWhoseLinkIsDownIsStaleEvenWithAFreshReading() throws IOException {
|
||||
EnergyView view = energy(deyeRead(false, READ));
|
||||
|
||||
assertTrue(view.sources().get(0).stale());
|
||||
assertNull(view.gridPowerW());
|
||||
assertEquals(READ, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyPropagatesGatewayFailure() {
|
||||
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthListsDevices() {
|
||||
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
|
||||
|
||||
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
|
||||
down), CLOCK).current();
|
||||
|
||||
assertTrue(view.gatewayReachable());
|
||||
assertEquals(NOW, view.time());
|
||||
assertEquals(2, view.devices().size());
|
||||
assertEquals("connect timed out", view.devices().get(1).lastError());
|
||||
assertFalse(view.devices().get(1).up());
|
||||
assertEquals(READ, view.devices().get(0).lastSuccess());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK).current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
}
|
||||
|
||||
/** Remembers the request and answers with one point. */
|
||||
private static final class RecordingHistory implements HistoryRepository {
|
||||
Series series;
|
||||
Instant from;
|
||||
Instant to;
|
||||
int maxPoints;
|
||||
|
||||
@Override
|
||||
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
|
||||
this.series = series;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.maxPoints = maxPoints;
|
||||
return List.of(new Point(from, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyDefaultsToLast24Hours() throws IOException {
|
||||
RecordingHistory repository = new RecordingHistory();
|
||||
|
||||
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
|
||||
|
||||
assertEquals("cwu_temp", view.series());
|
||||
assertEquals("°C", view.unit());
|
||||
assertEquals(1, view.points().size());
|
||||
assertEquals(Series.CWU_TEMP, repository.series);
|
||||
assertEquals(NOW, repository.to);
|
||||
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
|
||||
assertEquals(300, repository.maxPoints);
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyOfSevenDays() throws IOException {
|
||||
RecordingHistory repository = new RecordingHistory();
|
||||
|
||||
new HistoryService(repository, CLOCK).history("pv_power", "7d");
|
||||
|
||||
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRejectsUnknownSeriesAndRange() {
|
||||
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyPropagatesRepositoryFailure() {
|
||||
HistoryService service = new HistoryService((series, from, to, max) -> {
|
||||
throw new IOException("influx down");
|
||||
}, CLOCK);
|
||||
|
||||
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final SetpointState STATE = new SetpointState(List.of(
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)), List.<Restore>of());
|
||||
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
|
||||
private final List<Object> script = new ArrayList<>();
|
||||
private boolean down;
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
Object next = script.isEmpty() ? Status.APPLIED : script.removeFirst();
|
||||
if (next instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
sent.add(command);
|
||||
return new CommandResult((Status) next, command.setpoint(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@Test
|
||||
void viewJoinsGatewayStateAndPresets() throws Exception {
|
||||
var view = service.current();
|
||||
|
||||
assertEquals(STATE.setpoints(), view.setpoints());
|
||||
assertEquals(presets, view.presets());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hoursBecomeAWholeNumberOfMinutes() throws Exception {
|
||||
service.apply("cwu_economy", 45, 1.5, "anna");
|
||||
service.apply("cwu_comfort", 10, null, "anna");
|
||||
|
||||
assertEquals(new Command("cwu_economy", 45, Duration.ofMinutes(90), "anna"), sent.get(0));
|
||||
assertEquals(new Command("cwu_comfort", 10, null, "anna"), sent.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void durationUnderAMinuteIsRefusedBeforeSending() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 0.001, "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, -1.0, "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 72.5, "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void seventyTwoHoursIsTheLongestBoost() throws Exception {
|
||||
service.apply("cwu_economy", 45, 72.0, "anna");
|
||||
|
||||
assertEquals(Duration.ofHours(72), sent.getFirst().duration());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
|
||||
sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("cwu_economy"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
private static List<ChangeStatus> statuses(PresetResult result) {
|
||||
return result.results().stream().map(ChangeResult::status).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
assertEquals(1, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIsAnIOException() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class SeriesTest {
|
||||
|
||||
@Test
|
||||
void findsSeriesByItsId() {
|
||||
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIdFindsNothing() {
|
||||
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"series": "cwu_temp",
|
||||
"unit": "°C",
|
||||
"points": [
|
||||
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
|
||||
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
|
||||
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": "2026-10-24T12:00:00Z" }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"status": "APPLIED",
|
||||
"results": [
|
||||
{ "status": "APPLIED", "message": "cwu_comfort 45 → 10", "restoreAt": null },
|
||||
{ "status": "APPLIED", "message": "co_comfort 19 → 15", "restoreAt": null }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"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 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "status": "REJECTED", "message": "cwu_economy allows 5–48 in steps of 1, not 60", "restoreAt": null }
|
||||
@@ -0,0 +1 @@
|
||||
{ "setpoint": "cwu_economy", "value": 45, "hours": 2 }
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"setpoints": [
|
||||
{ "name": "co_economy", "register": 4131, "min": 5, "max": 25, "step": 1, "value": 18 },
|
||||
{ "name": "co_comfort", "register": 4132, "min": 5, "max": 25, "step": 1, "value": 19 },
|
||||
{ "name": "cwu_economy", "register": 4143, "min": 5, "max": 48, "step": 1, "value": 8 },
|
||||
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
|
||||
],
|
||||
"restores": [
|
||||
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
|
||||
],
|
||||
"presets": [
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2.0 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"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}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"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}]}
|
||||
@@ -0,0 +1 @@
|
||||
[{"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
|
||||
@@ -0,0 +1 @@
|
||||
[{"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"}]}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"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
|
||||
@@ -0,0 +1 @@
|
||||
{"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]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"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,30 @@
|
||||
# ADR-008: Backend and web UI stack
|
||||
|
||||
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
|
||||
|
||||
## Context
|
||||
|
||||
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
|
||||
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
|
||||
the gateway already shows the stack is enough (ADR-007).
|
||||
|
||||
## Decision
|
||||
|
||||
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
|
||||
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
|
||||
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
|
||||
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
|
||||
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
|
||||
the contract between backend and UI.
|
||||
- **Web UI**: React, TypeScript and Vite in `frontend/`, Tailwind for styling, Recharts for
|
||||
charts; the overview animates an SVG layer over a picture of the installation. Polish texts.
|
||||
Changed on 2026-10-24 at the user's request: the plain-JS UI was too weak to grow. Served by
|
||||
its own nginx container, not by the backend (ADR-010).
|
||||
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
|
||||
`Dom_Pompa.POMPA` fields, which already hold years of data.
|
||||
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A JavaScript toolchain to keep up to date (`package-lock.json`); CI sets up Node as well.
|
||||
- `scripts/check.sh` builds and tests both projects.
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-009: Web UI reachable from Krosno only, without a login for now
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
M3 needed a login method before the backend goes behind Traefik in Kraków (ADR-008 left it
|
||||
open). Keycloak runs there but no stack uses it yet. The first users are in the house in
|
||||
Krosno, on its network.
|
||||
|
||||
## Decision
|
||||
|
||||
- No login in the app or in Traefik for now. The backend's router gets an `ipwhitelist`
|
||||
middleware that admits only Krosno: its public address or the WireGuard range, whichever path
|
||||
the phones take; settled when the route is set up.
|
||||
- A `headers` middleware on the same router clears a client's `Remote-User`, so the name sent as
|
||||
`X-Requested-By` with a setpoint change is never one a client made up; without a login it is
|
||||
always `phone`.
|
||||
- The container publishes no host port; only Traefik reaches it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Anyone on the Krosno network can change setpoints once writes are enabled (#53). The limits
|
||||
and timed restores (ADR-006) still apply.
|
||||
- Not usable away from home. Before that, choose a login (Keycloak forward-auth or Traefik
|
||||
basic auth with `headerField: Remote-User`) in a new ADR that supersedes this one.
|
||||
- If Krosno's public address changes, the whitelist must follow; the WireGuard range does not.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-010: Gateway, backend and frontend as three images, each with its compose
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
The code had grown into a gateway at the root and a backend that also built and served the
|
||||
web UI. The Pi ran a jar under systemd, copied by a deploy script; Kraków runs Docker stacks
|
||||
from homelab-compose through Arcane. The user wanted the parts separate and each deployable the
|
||||
same way.
|
||||
|
||||
## Decision
|
||||
|
||||
- Three folders, `gateway/`, `backend/`, `frontend/`, each with a `Dockerfile` and a
|
||||
`compose.yaml`. The backend serves only `/api/`; nginx in the frontend image serves the UI
|
||||
and passes `/api/` to the backend, so they share one origin (CSP and the Origin check hold).
|
||||
- A pushed tag `vMAJOR.MINOR.PATCH` (`scripts/release.sh`) makes CI publish the three images in
|
||||
the Gitea registry; the gateway also for arm64. The Dockerfiles build on the runner's own
|
||||
platform and only copy into the final stage, so no emulation is needed.
|
||||
- The gateway runs in Docker on the Pi (Docker installed there, the OS untouched): host network
|
||||
for Modbus, `/home/pi/home-pump` mounted at the same path, logs to journald. `update.sh` on
|
||||
the Pi pulls a release with its `compose.yaml` and goes back to the previous one if `/health`
|
||||
fails or the container restarts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No jar copying: the Pi and Kraków run the same published images; a version is a git tag.
|
||||
- The Pi needs Docker and, if the packages are private, a registry login: one-time steps.
|
||||
- CI needs Node and a `REGISTRY_TOKEN` secret with package write access.
|
||||
- Supersedes the systemd unit and `scripts/deploy-gateway.sh`; ADR-006 (gateway on the Pi)
|
||||
stands.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Frisko app and map knowledge for the RX910-SE21
|
||||
|
||||
What the vendor app (`FriskoOnline2025.apk`, decompiled with androguard, nothing run), the XML maps
|
||||
and the PDFs say. **Everything here is unverified on the device** unless
|
||||
[rx910-se21.md](rx910-se21.md) says so. Source tags: `PDF` = `RX910-SE21-AdresyMODBUS`, `XML` =
|
||||
`RX910-SE21_v24062015.xml`, `MR208` = MR208-PC+ manual/map (another model: hints only), `APK` =
|
||||
class in `com.frisko.frisko_mobile`.
|
||||
|
||||
## Key finding: the app has no control logic
|
||||
|
||||
The app is a generic Modbus viewer: every screen, scale, value name and icon comes from the XML
|
||||
(`XmlHandler`, `ListDataParam`). It contains no register numbers except 4011 (`Comm_thread.STATUS_REG`),
|
||||
base 4000 (`Frisko_Sterownik.ADR_BAZOWY`, only for its local array index; the wire address is the raw
|
||||
register) and the XML tag set. It never reads 4221, 4222–4225, 4227, 4228, 4119 or 4129. Meanings
|
||||
below therefore come from the PDF, the XML and the MR208 manual, not from code.
|
||||
|
||||
## Alarm: register 4011 (the app) vs 4221 (the PDF)
|
||||
|
||||
| Register | Source | Meaning |
|
||||
|---|---|---|
|
||||
| 4221 "Instalacja" | PDF | 0 = installation OK, 1 = installation fault. Binary, no bits, no codes. |
|
||||
| 4011 "Status" | XML (Status screen) | Fault code, not a bit field: 0 OK, 2 Err_Tzew, 3 Err_Tco, 4 Err_Twew, 5 Err_Tcwu, 14 Err_Tpc, 15 Err_Tdz, 1003 Err_Presostat |
|
||||
| 4073 "Presostat" | XML, PDF | 0 OK, 1 error (pressure switch input) |
|
||||
|
||||
- The app polls 4011 always, and on any non-zero value (even one not in the XML list) raises a
|
||||
notification "Awaria sterownika: <number>" with sound and vibration; it repeats only when the code
|
||||
changes (`Comm_thread.update_regs`, `status_error`). It never reads 4221.
|
||||
- Likely mapping of names (inference from naming and the MR208 manual): `Tzew` outdoor 4065, `Tco`
|
||||
buffer 4067, `Twew` indoor 4066, `Tcwu` 4069, `Tpc` pump outlet 4072, `Tdz` ground source
|
||||
(4070/4071); codes 2–5, 14, 15 = sensor fault (MR208 "ErrTemp": pump off), 1003 = pressure switch
|
||||
(MR208 "ErrPres": pump off; three trips in one cycle latch it until service clears it).
|
||||
- MR208 also reports TmaxPC, TminDZ, dTmaxDZ shutdowns; SE21 may put them in 4011 or 4221
|
||||
(unknown). 4011 is not in the PDF map. Next: log a read-only FC 3 of 4011 beside 4221 and 4073.
|
||||
|
||||
## Registers 4119, 4222–4225, 4228
|
||||
|
||||
| Reg | What is known | Source |
|
||||
|---|---|---|
|
||||
| 4119 `WyCHL` | Output, "0 OFF, 100 ON" like 4112–4118. "CHL" probably means cooling (the gateway calls it `output_cooling`; a guess). Not in the XML, so the app never shows it. The −14308 read means the register is not implemented on this firmware | PDF; read per rx910-se21.md |
|
||||
| 4222–4225 | Not in the PDF, XML or APK. MR208 uses 4225 for TFer (a different layout, do not transfer). Gateway fixtures show 0 | none |
|
||||
| 4228 `StanZB` | PDF text is cut: "0 –", "2 – zbCW" (value 1 and the text of 0 are missing from the PDF extract, also with `-raw`). "ZB" is not explained; not in the XML | PDF |
|
||||
| 4227 `StanCO/CW` | 0 pc STOP, 1 pcCO, 2 pcCW, 3 pcCO/CW. Not in the XML; the app shows output icons instead | PDF |
|
||||
| 4226 `Status` | 0 Ferie, 1 Tygodn (weekly), 2 Party, 3 ROZRUCH (start-up), 4 STOP, 5 Dezynf. Same list in the XML "Program pracy" with icons `program_ferie`, `program_tygodniowy`, `program_party`, `praca`, `stop`, `dezynf` | PDF, XML |
|
||||
|
||||
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
|
||||
|
||||
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
|
||||
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
|
||||
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
|
||||
|
||||
## Modes and programs
|
||||
|
||||
| Reg | Values | Source |
|
||||
|---|---|---|
|
||||
| 4121 `TrybLZ` | Effective season: 0 summer, 1 winter. Read-only on the app's Status screen | PDF, XML |
|
||||
| 4122 `TrypRA` | 0 R, 1 A, 2 *. Not in the XML. MR208 shows "Zima(A)" on its main screen: season plus A (automatic) or R (manual) | PDF, MR208 manual |
|
||||
| 4123 `Tryb` | Season setting: 0 Lato, 1 Zima, 2 Auto (editable; backup value 2). Auto switches at 4124 `Temp progu Z/L` (5–35 °C whole, backup 18) | XML, PDF, backup |
|
||||
| 4125 `Tryb pracy` | 0 STOP, 1 PRACA (editable; backup 1) | XML, PDF |
|
||||
| 4140 `Prg` | 0 Ferie, 1 Tygodniowy, 2 Party (editable) | XML, PDF |
|
||||
| 4141 Ferie days | 0–90 (XML). MR208: 0 = until switched back by hand | XML, MR208 manual |
|
||||
| 4142 Party hours | 0–90 (XML). MR208: 0 = until switched back; counts down to the end of Party | XML, MR208 manual |
|
||||
|
||||
MR208 semantics (SE21 likely the same, unverified): Ferie keeps CO at the ferie target (4133) and CWU
|
||||
at economy or off; Party keeps comfort for CO and CWU; weekly intervals are comfort, the rest economy.
|
||||
|
||||
## Economy or comfort period
|
||||
|
||||
- The app does not decide this. It shows the XML values only; the XML has no "period" register.
|
||||
- The controller decides: weekly program intervals (4169–4176) give comfort, everything else economy
|
||||
(MR208 manual, "Programy"). Ferie and Party override it as above.
|
||||
- The effective targets 4201 (room), 4203 (CWU) are the answer. Inference for a UI: compare 4201
|
||||
with 4131 (economy), 4132 (comfort), 4133 (ferie), and 4203 with 4143 / 4144. Ambiguous when two
|
||||
setpoints are equal. 4202 (CO supply target) comes from the heating curve (4134) and is not a setpoint.
|
||||
|
||||
## "Heating CWU / CO" in the app
|
||||
|
||||
The app shows no such state. Its "Stan wyjść" screen lists outputs 4117 Pompa DZ, 4113 BUF, 4112 CO,
|
||||
4118 CWU, 4116 CCW, 4114 Sprężarka (0/33/66/100), 4115 Grzałka CWU, each as an icon for 0, 1 or 100
|
||||
(XML). Heating CO or CWU means output 4112 or 4118 on. 4227 is the controller's own summary of the
|
||||
same thing (PDF); the app ignores it.
|
||||
|
||||
## How the app writes (read-only knowledge for a later milestone)
|
||||
|
||||
| Item | Finding | Source |
|
||||
|---|---|---|
|
||||
| Function codes | FC 3 read, FC 6 write one register. FC 16 exists in the code (`WRITE_REG_MULTI = 16`, `MDBwriteMulti`) but no UI path uses it | `Frisko_MDBRozkaz`, `Frisko_ModbusTCPSystem` |
|
||||
| Unlock or password | None. No other frame is sent; `HasloLAN` 4129 (PDF) is never used. Matches the FC 6 test on 4143 | APK, rx910-se21.md |
|
||||
| Unit id | The "slave" number set per controller in the app, in the MBAP unit byte | `Comm_thread`, `MDBsendData` |
|
||||
| Scales | Value = register / 10 for format `XXX.X`; whole for `XX`. `ster_format` MZ = sign-magnitude: a negative write n is sent as 0x8000 + \|n\| | XML, `JavaToSterownik` |
|
||||
| Range check | Min and max from the XML only, on the phone | XML |
|
||||
| Queue | Writes go to the front of the queue (`AddPriorRozkaz`), ahead of polling | `Frisko_ModbusTCPSystem` |
|
||||
| Confirmation | The reply (function, transaction id, address) is checked; no explicit read-back. The screen shows the new value after the next poll of that register | `MDBreadODP` |
|
||||
| Failure | Up to 3 attempts per request, 500 ms between them; then the socket is closed and re-opened | `MBD_ERROR`, `ERROR_TIME_OUT` |
|
||||
| Delay after write | 200 ms after FC 6, 300 ms after FC 16 | `run()` |
|
||||
| Program selector | Circuit 4177 and day 4167 are plain writes, then the interval registers are polled; no explicit wait. MR208 map prescribes 500 ms between selecting and reading or writing | APK, MR208 map |
|
||||
|
||||
The XML schema also has `menu_set_adr` / `menu_set_val` (write a fixed value on opening a menu);
|
||||
the SE21 XML does not use it.
|
||||
|
||||
## Request pacing
|
||||
|
||||
| Item | Value | Source |
|
||||
|---|---|---|
|
||||
| Pause after each request | 200 ms (300 ms after FC 16); poll thread starts after 250 ms | `Frisko_ModbusTCPSystem.run` |
|
||||
| Poll list empty | 200 ms, then rebuilt: continuous polling, about 5 requests/s while a screen is open | `MDB_TIME_BREAK` |
|
||||
| Timeout | 5000 ms default, user setting (2–5 digits) | `MDB_TIME_OUT`, `SterSettingsActivity` |
|
||||
| Block read | Up to 28 registers per request, gaps up to 5 registers are read through | `MBD_SIZE_OPTYMAL`, `MBD_BREAK_OPTYMAL` |
|
||||
| What is polled | Only the registers on the open screen, plus 4011 | `REG_DATA_STATUS`, `Comm_thread.set_reg` |
|
||||
| Connections | One socket held open, up to 50 reconnect attempts | `disconnect_retries` |
|
||||
|
||||
The vendor documents no limit. The app's 200 ms pause is the only known pacing; our 36 registers in
|
||||
5 blocks took 2.5 s (rx910-se21.md), so a 10 s cycle is far below the app's load.
|
||||
|
||||
## Open questions
|
||||
|
||||
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
|
||||
- Does 4011 exist and move on this firmware? Its relation to 4221.
|
||||
- Is 4226 numbered as the PDF says (see above)?
|
||||
- What does 4119 read when it is implemented, and is the register used at all?
|
||||
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
|
||||
@@ -28,7 +28,7 @@ unless marked otherwise.
|
||||
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
|
||||
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater delay = 20 | read 2026-10-23 |
|
||||
|
||||
Unknown: 4119 `WyCHL` reads −14308.
|
||||
Unknown: 4119 `WyCHL` reads −14308. Vendor-app findings, all unverified: [app-knowledge.md](app-knowledge.md).
|
||||
|
||||
## Weekly program
|
||||
|
||||
|
||||
+27
-24
@@ -5,42 +5,43 @@ Closed milestones shrink to one line here; details stay in git history and in de
|
||||
|
||||
## Done
|
||||
|
||||
- (none yet) · baseline `e345063`: read-only, sends 8 sensor values to InfluxDB every 300 s;
|
||||
no control of the controller. Runs on the Pi.
|
||||
- M0 Foundation · `v1.0.0`: `scripts/check.sh` gate in CI and pre-push, deploy with health check
|
||||
and rollback, `/ship` and `/close-milestone`. Baseline (`e345063`) still does the work: read-only,
|
||||
8 heat pump values to InfluxDB every 300 s.
|
||||
|
||||
## Current: M0 Foundation
|
||||
## Current: M1 Gateway: all readings
|
||||
|
||||
No behaviour change.
|
||||
Done when: a week on the Pi without manual action, Grafana unchanged. Existing measurements keep
|
||||
their names and fields; new data goes into new measurements (ADR-005). The heat pump stays at
|
||||
300 s polls while the Frisko app is still in use (MK01 takes one client).
|
||||
|
||||
- [x] Docs structure: `CLAUDE.md`, `CONTRIBUTING.md`, `docs/`
|
||||
- [x] `scripts/check.sh`: build, tests, doc size limits, secret scan
|
||||
- [x] Git `pre-push` hook and CI both run `scripts/check.sh`; remove `.drone.yml` and `Dockerfile`
|
||||
- [ ] Deploy: `scripts/deploy-gateway.sh` copies the jar to the Pi, restarts, checks `/health`,
|
||||
rolls back on failure. Written and tested against stubs; open until its first run on the Pi
|
||||
- [x] Skills: `/ship`, `/close-milestone`
|
||||
|
||||
## Feasibility checks (after M0, before M1)
|
||||
|
||||
- [x] C1 Gateway sharing: MK01 takes one client; our poll drops my parents' Frisko app.
|
||||
- [x] C2 Frisko app is used rarely: keep 300 s polls until our UI replaces it, then the backend
|
||||
is the only client, with a "release gateway" switch for the app.
|
||||
- [x] C5 Parents raise the economy temperature (CWU 4143, CO 4131) to force heating; no tariff.
|
||||
- [x] C6 Energy sources readable locally: Deye via Solarman logger, Fronius Solar API, house meter.
|
||||
- [x] C3 Backup of the 14 app-editable settings: `research/frisko/settings-backup-2026-10-23.json`.
|
||||
- [x] C4 Write works: FC6 4143 8 → 9 → 8 (my request), echo OK, read-back after 1 s, no password.
|
||||
- [x] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
|
||||
- [x] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
|
||||
- [x] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
|
||||
and the setpoints into `heat_pump`
|
||||
- [x] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
|
||||
- [x] Deye and Fronius into `electricity.deye`, `electricity.fronius`
|
||||
- [x] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
|
||||
- [x] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
|
||||
- [x] HTTP: `/health` per device, `/snapshot`
|
||||
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
baseline classes (D4, D5)
|
||||
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
|
||||
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
|
||||
`rpi-measurements` and the old `home-pump.service`
|
||||
|
||||
## Next
|
||||
|
||||
| Milestone | Scope | Done when |
|
||||
|---|---|---|
|
||||
| M1 Gateway: all readings | Fix D7 first. Baseline becomes the gateway: heat pump incl. state registers (4112–4119, 4201–4203, 4221–4228), Deye, Fronius, house meter and BME280 (moved from `rpi-measurements`, same schemas; then retire it) into Influx with a buffer; snapshot; config outside the jar; fix D1–D5 | A week on the Pi without manual action, Grafana unchanged |
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; CI builds its image; Traefik route and auth (decide the login method first) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6 | Father boosts hot water from his phone, value restored on time |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
|
||||
|
||||
## Known defects in the baseline (input for M1)
|
||||
## Known defects in the baseline
|
||||
|
||||
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
|
||||
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
|
||||
@@ -48,6 +49,8 @@ No behaviour change.
|
||||
- D4 Dead code and config: `pollIntervalMs`, `windowSeconds`, `ModbusCachedWrapper`, write path.
|
||||
- D5 Single and batch reads use different addresses for registers ≥ 4100 (`ModbusRtuTcpRepository`).
|
||||
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
|
||||
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
|
||||
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
|
||||
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
|
||||
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
|
||||
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,14 @@
|
||||
# Built on the build machine's own platform; the image only holds the static files and nginx.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.29-alpine
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
ENV BACKEND_URL=http://backend:8080
|
||||
EXPOSE 8080
|
||||
@@ -0,0 +1,15 @@
|
||||
# Frontend
|
||||
|
||||
The web UI (ADR-008, ADR-010): React, TypeScript, Vite, Tailwind and Recharts, Polish texts. In
|
||||
production nginx serves the build and passes `/api/` to the backend, so both share one origin.
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci
|
||||
npm run dev # http://localhost:5173, /api to BACKEND (default http://localhost:8080)
|
||||
npm test # Vitest; reads the API contract from backend/src/test/resources/api
|
||||
npm run build # into dist/
|
||||
docker compose -f frontend/compose.yaml up --build # after the backend's; .env sets HOST and KROSNO_SOURCES
|
||||
```
|
||||
|
||||
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
|
||||
the sizes and the pipe masks in `src/assets/` from it.
|
||||
@@ -0,0 +1,32 @@
|
||||
# The web UI in Kraków (ADR-010): nginx serves the build and passes /api to the backend on the
|
||||
# network home-pump (start backend/compose.yaml first). Traefik routes to it; .env next to this
|
||||
# file sets HOST and KROSNO_SOURCES.
|
||||
name: home-pump-frontend
|
||||
services:
|
||||
frontend:
|
||||
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-frontend:${VERSION:-latest}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
networks: [home-pump, proxy]
|
||||
labels:
|
||||
com.getarcaneapp.arcane.updater: "false"
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: proxy
|
||||
traefik.http.routers.home-pump.rule: Host(`${HOST:?the public host name}`)
|
||||
traefik.http.routers.home-pump.entrypoints: web-secured
|
||||
traefik.http.routers.home-pump.tls: "true"
|
||||
traefik.http.routers.home-pump.tls.certresolver: http
|
||||
traefik.http.routers.home-pump.service: home-pump
|
||||
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
|
||||
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
|
||||
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
|
||||
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
|
||||
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
external: true
|
||||
proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0e2a21">
|
||||
<title>Dom Krosno</title>
|
||||
<link rel="icon" href="data:,">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
# The web UI and /api in one origin, so the backend's Origin check and the CSP hold (ADR-010).
|
||||
# BACKEND_URL is filled in at start by the nginx image's envsubst step.
|
||||
|
||||
# Vite names assets after their content, so they never change under the same name.
|
||||
map $uri $cache_control {
|
||||
~^/assets/ "public, max-age=31536000, immutable";
|
||||
default "no-cache";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_tokens off;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# The same hardening the backend sends with the API (SecurityHeaders.java).
|
||||
add_header Content-Security-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'" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Cache-Control $cache_control;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
# With the port: the backend compares Origin with Host before a write.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+3442
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "home-pump-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"recharts": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Builds the installation picture's assets from research/ui/house-cutaway.webp (1536 x 1024).
|
||||
|
||||
Writes into src/assets: the picture at two widths, and one mask per pipe circuit, found by the
|
||||
pipes' orange and blue colours inside the circuit's region. Schematic.tsx makes a masked
|
||||
circuit flow while its pump runs and greys it out while it stands.
|
||||
|
||||
Usage, from frontend/: python3 scripts/house-assets.py (needs Pillow and NumPy)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
SOURCE = Path("../research/ui/house-cutaway.webp")
|
||||
OUT = Path("src/assets")
|
||||
|
||||
# Regions as (x0, y0, x1, y1) in the 1536 x 1024 picture.
|
||||
CIRCUITS = {
|
||||
"ground": (150, 585, 832, 1024), # boreholes and the brine pipes under the house
|
||||
"boiler-room": (595, 355, 832, 585), # heat pump to the CWU tank and the CO buffer
|
||||
"floor": (880, 595, 1350, 690), # underfloor heating in the living room
|
||||
}
|
||||
|
||||
|
||||
def pipes(rgb: np.ndarray) -> np.ndarray:
|
||||
r, g, b = (rgb[..., i].astype(int) for i in range(3))
|
||||
orange = (r > 190) & (g > 70) & (g < 170) & (b < 90) & (r - b > 120)
|
||||
blue = (b > 150) & (r < 110) & (b - r > 70) & (g > 60) & (g < 190)
|
||||
return orange | blue
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picture = Image.open(SOURCE).convert("RGB")
|
||||
assert picture.size == (1536, 1024), picture.size
|
||||
for width in (1536, 768):
|
||||
picture.resize((width, width * 2 // 3), Image.LANCZOS).save(OUT / f"house-{width}.webp", quality=80)
|
||||
found = pipes(np.asarray(picture))
|
||||
for name, (x0, y0, x1, y1) in CIRCUITS.items():
|
||||
mask = np.zeros_like(found)
|
||||
mask[y0:y1, x0:x1] = found[y0:y1, x0:x1]
|
||||
# A little wider and softer than the pipe, so the flow covers its edges.
|
||||
image = Image.fromarray((mask * 255).astype("uint8")).filter(ImageFilter.MaxFilter(3))
|
||||
image.filter(ImageFilter.GaussianBlur(0.8)).save(OUT / f"mask-{name}.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { celsius, hour, longDate } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Energy } from "./Energy";
|
||||
import { History } from "./History";
|
||||
import { Overview } from "./Overview";
|
||||
import { usePoll } from "./poll";
|
||||
import { Settings } from "./Settings";
|
||||
import { Status } from "./Status";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import type { HeatPump } from "./types";
|
||||
import { Dot } from "./ui";
|
||||
|
||||
const SCREENS = {
|
||||
overview: { label: "Podgląd", icon: "home", view: Overview },
|
||||
energy: { label: "Energia", icon: "bolt", view: Energy },
|
||||
history: { label: "Historia", icon: "chart", view: History },
|
||||
settings: { label: "Ustawienia", icon: "sliders", view: Settings },
|
||||
status: { label: "Stan", icon: "check", view: Status },
|
||||
} satisfies Record<string, { label: string; icon: IconName; view: () => unknown }>;
|
||||
type ScreenId = keyof typeof SCREENS;
|
||||
|
||||
const fromHash = (): ScreenId => {
|
||||
const id = location.hash.slice(1);
|
||||
return id in SCREENS ? (id as ScreenId) : "overview";
|
||||
};
|
||||
|
||||
function useClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
const THEMES: Record<Theme, string> = { auto: "Auto", light: "Jasny", dark: "Ciemny" };
|
||||
|
||||
function ThemeSwitch({ className }: { className: string }) {
|
||||
const [theme, setTheme] = useTheme();
|
||||
return (
|
||||
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
|
||||
{Object.entries(THEMES).map(([id, label]) => (
|
||||
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Weather, clock and whether the house answers, at the foot of the side bar on a large screen.
|
||||
function SideFooter() {
|
||||
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const now = useClock();
|
||||
return (
|
||||
<div className="mt-auto space-y-4 px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Krosno</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
|
||||
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [screen, setScreen] = useState<ScreenId>(fromHash);
|
||||
useEffect(() => {
|
||||
const onHash = () => setScreen(fromHash());
|
||||
addEventListener("hashchange", onHash);
|
||||
return () => removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const show = (id: ScreenId) => {
|
||||
location.hash = id;
|
||||
setScreen(id);
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
const View = SCREENS[screen].view;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh lg:pl-64">
|
||||
<nav aria-label="Ekrany"
|
||||
className="fixed inset-x-0 bottom-0 z-10 flex border-t border-edge bg-panel pb-[env(safe-area-inset-bottom)] lg:inset-y-0 lg:left-0 lg:right-auto lg:w-64 lg:flex-col lg:gap-1 lg:border-0 lg:bg-side lg:px-4 lg:py-6 lg:text-white">
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(SCREENS).map(([id, s]) => (
|
||||
<button key={id} onClick={() => show(id as ScreenId)} aria-current={id === screen ? "page" : undefined}
|
||||
className="flex min-h-16 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-semibold text-soft aria-[current=page]:text-brand aria-[current=page]:shadow-[inset_0_3px_0_var(--color-brand)] lg:min-h-12 lg:flex-none lg:flex-row lg:justify-start lg:gap-3 lg:rounded-xl lg:px-4 lg:text-base lg:text-white/75 lg:hover:bg-white/5 lg:aria-[current=page]:bg-side-hi lg:aria-[current=page]:text-white lg:aria-[current=page]:shadow-none">
|
||||
<Icon name={s.icon} className="size-6" />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
|
||||
<View />
|
||||
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import { kwh, percent, power, when } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Energy as EnergyData } from "./types";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
|
||||
|
||||
/** Inverters without a fresh reading, in plain words. A source the backend does not list is missing too. */
|
||||
export function silentInverters(e: EnergyData) {
|
||||
return Object.entries(INVERTERS).flatMap(([id, label]) => {
|
||||
const source = e.sources?.find((s) => s.name === id);
|
||||
if (source && !source.stale && source.time) return [];
|
||||
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
});
|
||||
}
|
||||
|
||||
const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
|
||||
const magnitude = (watts: number | null) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
// The SDM630 and Deye sign: + the house takes from the grid, − it gives back.
|
||||
export function gridText(watts: number | null) {
|
||||
if (watts == null) return "—";
|
||||
if (!flowing(watts)) return "bez wymiany";
|
||||
return watts > 0 ? "pobór" : "oddawanie";
|
||||
}
|
||||
|
||||
function batteryText(watts: number | null) {
|
||||
if (watts == null) return "—";
|
||||
if (!flowing(watts)) return "bez przepływu";
|
||||
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
|
||||
}
|
||||
|
||||
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
|
||||
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
|
||||
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
const a = NODES[from], b = NODES[to];
|
||||
const on = flowing(watts);
|
||||
const [s, e] = on && watts! < 0 ? [b, a] : [a, b];
|
||||
const d = `M${s.x} ${s.y} L${e.x} ${e.y}`;
|
||||
// Faster dots for more power: 2.5 s per run at 10 W, down to 0.6 s at 5 kW and more.
|
||||
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
|
||||
return (
|
||||
<g data-flow={`${from}-${to}`} data-on={on}>
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note?: string }) {
|
||||
const { x, y, label, icon, colour } = NODES[id];
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
|
||||
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
|
||||
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPicture({ e }: { e: EnergyData }) {
|
||||
return (
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" role="img" aria-label="Przepływ energii w domu">
|
||||
<Flow from="pv" to="house" watts={e.pvPowerW} />
|
||||
<Flow from="grid" to="house" watts={e.gridPowerW} />
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
<Node id="pv" value={power(e.pvPowerW)} />
|
||||
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
|
||||
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
const today = e?.today;
|
||||
const load = today?.loadKwh ?? null;
|
||||
const bought = today?.gridBoughtKwh ?? null;
|
||||
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
|
||||
const parts = [
|
||||
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
|
||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||
];
|
||||
const partial = e ? silentInverters(e).length > 0 : false;
|
||||
return (
|
||||
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
|
||||
{!e ? <Loading /> : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="relative size-36 shrink-0">
|
||||
<PieChart width={144} height={144}>
|
||||
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
stroke="none" isAnimationActive={false}>
|
||||
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-44 flex-1 text-sm">
|
||||
{parts.map((p) => (
|
||||
<Row key={p.name} label={<><span className="inline-block size-2.5 rounded-full" style={{ background: p.colour }} />{p.name}</>}
|
||||
value={kwh(p.name === "Z sieci" ? bought : own)} />
|
||||
))}
|
||||
<Row label="Produkcja PV" value={kwh(today?.pvKwh)} />
|
||||
<Row label="Oddano do sieci" value={kwh(today?.gridSoldKwh)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
|
||||
<div>
|
||||
<p className="text-sm text-soft">{label}</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
{note && <p className="text-xs text-soft">{note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Energy() {
|
||||
const { data: e, offline } = usePoll<EnergyData>("api/energy");
|
||||
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
|
||||
if (!e) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const missing = silentInverters(e);
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<Freshness offline={offline} time={e.time} />
|
||||
{missing.length > 0 && <Banner tone="warn">{missing.map((m) => <div key={m.label}>{m.label}: {m.since}</div>)}</Banner>}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} /></Card>
|
||||
<EnergyToday e={e} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
||||
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
import type { History as HistoryData } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
outdoor_temp: ["Temperatura na zewnątrz", "#3b82f6"],
|
||||
indoor_temp: ["Temperatura w domu", "#10b981"],
|
||||
co_buffer_temp: ["Bufor CO", "#ef4444"],
|
||||
cwu_temp: ["Ciepła woda (CWU)", "#f59e0b"],
|
||||
ground_in_temp: ["Gruntowe: do pompy", "#22a6c9"],
|
||||
ground_out_temp: ["Gruntowe: z pompy", "#2f7fe0"],
|
||||
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
||||
pv_power: ["Produkcja PV", "#f59e0b"],
|
||||
load_power: ["Zużycie domu", "#10b981"],
|
||||
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
||||
} as const;
|
||||
type SeriesId = keyof typeof SERIES;
|
||||
|
||||
const RANGES: Record<Range, string> = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
function Chart({ series, range }: { series: SeriesId; range: Range }) {
|
||||
// One poller per choice, so an answer is never shown for another one.
|
||||
const { data, offline } = usePoll<HistoryData>(`api/history?series=${series}&range=${range}`);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const values = data.points.map((p) => p.v);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
const [name, colour] = SERIES[series];
|
||||
return (
|
||||
<>
|
||||
{offline && <Freshness offline />}
|
||||
{data.points.length < 2 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
|
||||
<>
|
||||
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
|
||||
unit={data.unit} range={range} height={320} area />
|
||||
<p className="mt-2 text-sm text-soft">
|
||||
Najmniej {number(Math.min(...values), digits)} {data.unit} · najwięcej {number(Math.max(...values), digits)} {data.unit}.
|
||||
Dotknij wykresu, aby zobaczyć wartość.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function History() {
|
||||
const [series, setSeries] = useState<SeriesId>("cwu_temp");
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
|
||||
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
|
||||
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2 rounded-xl bg-page p-1">
|
||||
{Object.entries(RANGES).map(([id, name]) => (
|
||||
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
|
||||
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
|
||||
function serveHeatPump(hp: HeatPump) {
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
|
||||
}
|
||||
|
||||
const flowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-circuit][data-on=true]")].map((p) => p.getAttribute("data-circuit"));
|
||||
const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("draws a resting pump with nothing flowing", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
const { container } = render(<Overview />);
|
||||
|
||||
expect(await screen.findAllByText("Praca prawidłowa")).not.toHaveLength(0);
|
||||
expect(flowing(container)).toEqual([]);
|
||||
expect(glowing(container)).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes the pipes of the running pumps flow", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true, ground: true } });
|
||||
const Overview = await overview();
|
||||
const { container } = render(<Overview />);
|
||||
|
||||
expect(await screen.findAllByText("Grzeje wodę (CWU)")).not.toHaveLength(0);
|
||||
expect(flowing(container)).toEqual(["ground", "boiler-room"]);
|
||||
expect(glowing(container)).toEqual(["heat-pump", "cwu"]);
|
||||
});
|
||||
|
||||
it("shows the alarm in words", async () => {
|
||||
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true, alarmCode: 5, alarmText: "Usterka czujnika temperatury CWU" });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("ALARM pompy ciepła. Usterka czujnika temperatury CWU.");
|
||||
});
|
||||
|
||||
it("claims nothing before the first reading", async () => {
|
||||
serveHeatPump(example("heat-pump-unread"));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByText("Brak odczytu z pompy")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Praca prawidłowa")).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when the reading is old", async () => {
|
||||
serveHeatPump(example("heat-pump")); // its time is long past
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
|
||||
const HEADLINES: Record<PumpState, string> = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
HEATING_CWU: "Grzeje wodę (CWU)",
|
||||
HEATING_CO: "Grzeje dom (CO)",
|
||||
HEATING_BOTH: "Grzeje dom i wodę",
|
||||
};
|
||||
|
||||
export const PUMP_NAMES = {
|
||||
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
} as const;
|
||||
|
||||
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
|
||||
|
||||
const TEMPERATURE_LINES: Line[] = [
|
||||
{ key: "cwu_temp", name: "CWU", colour: "#f59e0b" },
|
||||
{ key: "co_buffer_temp", name: "Bufor CO", colour: "#ef4444" },
|
||||
{ key: "indoor_temp", name: "W domu", colour: "#10b981" },
|
||||
{ key: "outdoor_temp", name: "Na zewnątrz", colour: "#3b82f6", dashed: true },
|
||||
];
|
||||
|
||||
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
|
||||
|
||||
function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
|
||||
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
|
||||
<p className="text-xs text-soft">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3">{status}</div>
|
||||
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
const rows: [IconName, string, number | null, string][] = [
|
||||
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
|
||||
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
|
||||
["home", "W domu", t.indoor, "text-brand"],
|
||||
["heat", "Bufor CO", t.coBuffer, "text-hot"],
|
||||
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
|
||||
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
|
||||
];
|
||||
return (
|
||||
<Card title="Temperatury">
|
||||
{rows.map(([icon, label, value, colour]) => (
|
||||
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is green and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
const items: [string, boolean | null, string][] = [
|
||||
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
|
||||
["Grzałka", hp.heaterOn, onOff(hp.heaterOn, "włączona", "wyłączona")],
|
||||
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
|
||||
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
|
||||
return [name, on, onOff(on, "pracuje", "stoi")];
|
||||
}),
|
||||
];
|
||||
return (
|
||||
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{items.map(([name, on, text]) => (
|
||||
<li key={name} title={`${name}: ${text}`}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "ok" : "off"} live={!!on} />
|
||||
<span>{name}</span>
|
||||
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
|
||||
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Targets({ hp }: { hp: HeatPump }) {
|
||||
const s = hp.setpoints;
|
||||
return (
|
||||
<Card title="Cele i nastawy">
|
||||
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
|
||||
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
|
||||
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
const indoor = usePoll<History>("api/history?series=indoor_temp&range=24h", HISTORY_MS);
|
||||
const outdoor = usePoll<History>("api/history?series=outdoor_temp&range=24h", HISTORY_MS);
|
||||
const all = [cwu, co, indoor, outdoor].map((p) => p.data);
|
||||
if (all.some((d) => !d)) return null;
|
||||
return toRows(Object.fromEntries(all.map((d) => [d!.series, d!.points])));
|
||||
}
|
||||
|
||||
function TemperatureChart() {
|
||||
const rows = useTemperatureHistory();
|
||||
return (
|
||||
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2">
|
||||
<Legend lines={TEMPERATURE_LINES} />
|
||||
{rows == null ? <Loading /> : rows.length < 2
|
||||
? <p className="text-soft">Za mało danych w tym okresie.</p>
|
||||
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range="24h" />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NoReading() {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xl font-bold">Brak odczytu z pompy</p>
|
||||
<p className="mt-1 text-soft">Dom jeszcze nie odczytał pompy ciepła. Odczyt pojawi się tutaj, gdy będzie dostępny.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const { data: e } = usePoll<Energy>("api/energy");
|
||||
const now = hp?.time ? new Date(hp.time) : null;
|
||||
const title = (
|
||||
<PageTitle title="Dom"
|
||||
subtitle={now ? `Pompa ciepła i energia · odczyt z ${hour(now)}, ${longDate(now)}` : "Pompa ciepła i energia"} />
|
||||
);
|
||||
if (!hp) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<Freshness offline={offline} time={hp.time} stale={hp.stale} />
|
||||
{/* alarmText is one of the backend's fixed Polish sentences for register 4011, never user input. */}
|
||||
{hp.alarm && (
|
||||
<div role="alert" className="mb-4 flex items-start gap-3 rounded-xl border-2 border-alarm bg-red-50 px-4 py-3 font-semibold text-alarm dark:bg-red-950/60 dark:text-red-200">
|
||||
<Icon name="alert" className="size-6 shrink-0" />
|
||||
<span>ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.</span>
|
||||
</div>
|
||||
)}
|
||||
{hp.time == null ? <NoReading /> : (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<Temperatures hp={hp} />
|
||||
<Targets hp={hp} />
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
|
||||
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
|
||||
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
|
||||
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
|
||||
</Tile>
|
||||
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")}>
|
||||
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
|
||||
</Tile>
|
||||
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
|
||||
status={running(hp.pumps.co, "Grzeje dom")}>
|
||||
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
|
||||
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
|
||||
</Tile>
|
||||
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
|
||||
status={running(hp.pumps.ground)}>
|
||||
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
|
||||
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
|
||||
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
|
||||
</Tile>
|
||||
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
|
||||
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
|
||||
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
|
||||
<Row label="Bateria" value={percent(e?.batterySocPct)} />
|
||||
</Tile>
|
||||
</div>
|
||||
<TemperatureChart />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import house1536 from "./assets/house-1536.webp";
|
||||
import house768 from "./assets/house-768.webp";
|
||||
import maskBoilerRoom from "./assets/mask-boiler-room.png";
|
||||
import maskFloor from "./assets/mask-floor.png";
|
||||
import maskGround from "./assets/mask-ground.png";
|
||||
import { celsius, number, percent, power } from "./format";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
// The installation as it is now, over the house picture (research/ui/house-cutaway.webp,
|
||||
// assets from scripts/house-assets.py). Coordinates are pixels of the 1536 × 1024 picture.
|
||||
// A pipe circuit flows while its pump runs and is greyed out while it stands; the solar
|
||||
// collector has no sensor, so it is never animated.
|
||||
|
||||
const W = 1536, H = 1024;
|
||||
|
||||
const CIRCUITS = {
|
||||
ground: { mask: maskGround, name: "Odwierty (pompa gruntowa)" },
|
||||
"boiler-room": { mask: maskBoilerRoom, name: "Kotłownia (pompy CWU i bufora)" },
|
||||
floor: { mask: maskFloor, name: "Podłogówka (pompa CO)" },
|
||||
} as const;
|
||||
type Circuit = keyof typeof CIRCUITS;
|
||||
|
||||
function Pipes({ id, on }: { id: Circuit; on: boolean }) {
|
||||
return (
|
||||
<g data-circuit={id} data-on={on}>
|
||||
<title>{`${CIRCUITS[id].name}: ${on ? "płynie" : "stoi"}`}</title>
|
||||
<mask id={`mask-${id}`} maskUnits="userSpaceOnUse" x="0" y="0" width={W} height={H}>
|
||||
<image href={CIRCUITS[id].mask} width={W} height={H} />
|
||||
</mask>
|
||||
{on
|
||||
? <g mask={`url(#mask-${id})`}><rect className="shimmer" x={-120} width={W + 240} height={H} fill="url(#stripes)" /></g>
|
||||
: <rect width={W} height={H} mask={`url(#mask-${id})`} className="fill-[#a3acb3]" opacity="0.8" />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// A pulsing outline around a device that works now.
|
||||
function Glow({ x, y, w, h, colour, on, id }: { x: number; y: number; w: number; h: number; colour: string; on: boolean; id: string }) {
|
||||
return (
|
||||
<rect data-glow={id} data-on={on} x={x} y={y} width={w} height={h} rx={14} fill="none"
|
||||
stroke={colour} strokeWidth={on ? 6 : 0} className={on ? "glow" : ""} filter="url(#soft)" />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A label with a value, joined by a thin line to the thing it describes; the box grows to fit
|
||||
* its text once the browser has measured it. Hidden on a phone.
|
||||
*/
|
||||
function Callout({ x, y, title, value, to, tone = "var(--color-ink)" }: {
|
||||
x: number; y: number; title: string; value: ReactNode; to?: [number, number]; tone?: string;
|
||||
}) {
|
||||
const texts = useRef<SVGGElement>(null);
|
||||
const [w, setW] = useState(220);
|
||||
useLayoutEffect(() => {
|
||||
const box = texts.current?.getBBox?.(); // absent in tests without layout
|
||||
if (box) setW(Math.ceil(box.width) + 40);
|
||||
});
|
||||
const h = 92;
|
||||
return (
|
||||
<g className="label">
|
||||
{to && <>
|
||||
<line x1={Math.min(Math.max(to[0], x + 24), x + w - 24)} y1={to[1] < y ? y : y + h} x2={to[0]} y2={to[1]}
|
||||
stroke="#fff" strokeWidth={3} opacity={0.9} />
|
||||
<circle cx={to[0]} cy={to[1]} r={7} fill="#fff" stroke={tone} strokeWidth={3} />
|
||||
</>}
|
||||
<rect x={x} y={y} width={w} height={h} rx={18} className="fill-panel" opacity={0.94} filter="url(#shadow)" />
|
||||
<g ref={texts}>
|
||||
<text x={x + 20} y={y + 36} fontSize={24} fontWeight={600} className="scene-soft">{title}</text>
|
||||
<text x={x + 20} y={y + 74} fontSize={32} fontWeight={700} fill={tone}>{value}</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const target = (value: number | null) => <tspan fontSize={22} className="scene-soft">{` cel ${number(value, 0)}°`}</tspan>;
|
||||
|
||||
export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | null | undefined }) {
|
||||
const { temperatures: t, targets, pumps } = hp;
|
||||
const ground = !!pumps.ground, buffer = !!pumps.buffer, cwu = !!pumps.cwu, co = !!pumps.co;
|
||||
const compressor = (hp.compressorPercent ?? 0) > 0;
|
||||
const sunny = (pvPowerW ?? 0) >= 10;
|
||||
|
||||
return (
|
||||
<div className="scene relative overflow-hidden rounded-xl bg-white">
|
||||
<img src={house1536} srcSet={`${house768} 768w, ${house1536} 1536w`} sizes="(min-width: 1024px) 60vw, 100vw"
|
||||
width={W} height={H} alt="" className="block h-auto w-full dark:brightness-90" />
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 h-full w-full select-none" role="img"
|
||||
aria-label="Schemat instalacji: odwierty, pompa ciepła w kotłowni, zbiornik CWU, bufor CO, podłogówka, kolektor i fotowoltaika">
|
||||
<defs>
|
||||
<pattern id="stripes" width="64" height="64" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
|
||||
<rect width="64" height="64" fill="#fff" opacity="0.15" />
|
||||
<rect width="18" height="64" fill="#fff" opacity="0.95" />
|
||||
</pattern>
|
||||
<linearGradient id="sweep" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0.35" stopColor="#fff" stopOpacity="0" />
|
||||
<stop offset="0.5" stopColor="#fff" stopOpacity="0.55" />
|
||||
<stop offset="0.65" stopColor="#fff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="4" /></filter>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" floodOpacity="0.18" />
|
||||
</filter>
|
||||
<clipPath id="pv"><polygon points="985,188 1226,193 1369,328 1100,319" /></clipPath>
|
||||
</defs>
|
||||
|
||||
<Pipes id="ground" on={ground} />
|
||||
<Pipes id="boiler-room" on={cwu || buffer} />
|
||||
<Pipes id="floor" on={co} />
|
||||
|
||||
{/* Photovoltaics: a light sweeps over the panels while they produce */}
|
||||
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
|
||||
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
|
||||
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
|
||||
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
|
||||
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
|
||||
|
||||
{/* Warmth rising from the floor while the CO pump runs */}
|
||||
{co && [960, 1080, 1200, 1300].map((x, i) => (
|
||||
<path key={x} className="rise" style={{ animationDelay: `${i * 0.6}s` }} fill="none" stroke="#f97316"
|
||||
strokeWidth={5} strokeLinecap="round" opacity="0" d={`M${x} 600 c-12-14 12-24 0-38 c-12-14 12-24 0-38`} />
|
||||
))}
|
||||
|
||||
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
|
||||
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
|
||||
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
|
||||
<Callout x={660} y={730} title="Bufor CO" to={[781, 617]} tone={buffer ? "#dc2626" : undefined}
|
||||
value={<>{celsius(t.coBuffer)}{target(targets.co)}</>} />
|
||||
<Callout x={380} y={850} title="Odwierty: do pompy · z pompy" to={[365, 880]}
|
||||
value={`${celsius(t.groundIn)} · ${celsius(t.groundOut)}`} />
|
||||
<Callout x={1000} y={370} title="W domu" tone={co ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.indoor)}{target(targets.room)}</>} />
|
||||
<Callout x={1250} y={50} title="Fotowoltaika" to={[1230, 230]} tone={sunny ? "#d97706" : undefined} value={power(pvPowerW)} />
|
||||
<Callout x={470} y={95} title="Kolektor słoneczny" to={[790, 190]}
|
||||
value={<tspan fontSize={24} className="scene-soft">bez czujnika</tspan>} />
|
||||
|
||||
{hp.alarm && (
|
||||
<g className="glow">
|
||||
<rect x={492} y={392} width={130} height={40} rx={20} fill="var(--color-alarm)" />
|
||||
<text x={557} y={420} fontSize={24} fontWeight={700} textAnchor="middle" fill="#fff">ALARM</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user