Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e28a25c22 | ||
|
|
2343fe410f | ||
|
|
e2f3b58777 | ||
|
|
1cc8a3b545 | ||
|
|
9ec0ae2425 | ||
|
|
f84bf5a953 | ||
|
|
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 | ||
|
|
0a5d961113 | ||
|
|
6828049404 | ||
|
|
b224ffa9dc | ||
|
|
1cae89f634 | ||
|
|
5c1787de76 | ||
|
|
bfecae8b1f | ||
|
|
dc5efc07e1 | ||
|
|
585c1d4e3d | ||
|
|
b47bcf7a18 | ||
|
|
2eaddcc414 | ||
|
|
6a3dfdf651 | ||
|
|
c2c63918ab | ||
|
|
f7136c7ddb | ||
|
|
6a293ee15e | ||
|
|
408bba73ac | ||
|
|
c73059eef7 | ||
|
|
3839668348 | ||
|
|
0286c4c18d | ||
|
|
656b581812 | ||
|
|
162a25eceb | ||
|
|
225648db81 | ||
|
|
365d0b124a | ||
|
|
e5571548aa | ||
|
|
887806d329 | ||
|
|
607058e42b | ||
|
|
1676b46b33 | ||
|
|
9f795485ee | ||
|
|
e35dee9b4f | ||
|
|
4642408039 | ||
|
|
a59a5494ff | ||
|
|
7bbda72e59 | ||
|
|
26f7b30abe | ||
|
|
13bc0955f0 | ||
|
|
d230d86250 | ||
|
|
484186e2dc | ||
|
|
fe8257b96c | ||
|
|
2544421c72 | ||
|
|
17d326fd5f | ||
|
|
32424a118c | ||
|
|
c309916736 |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: close-milestone
|
||||
description: Close the current milestone in docs/roadmap.md, remove its working notes and start the next one. Use when every item of the current milestone is done.
|
||||
---
|
||||
|
||||
# Close milestone
|
||||
|
||||
1. Read `docs/roadmap.md`. Every item of "Current" is checked and its "Done when" holds.
|
||||
Anything you cannot verify yourself (e.g. "a week on the Pi") you ask the user about.
|
||||
Stop and report if something is open.
|
||||
2. Branch `docs/close-<milestone>` (e.g. `docs/close-m1`).
|
||||
3. Under "Done", add one line: `- M<n> <name> · <tag or commit>: <what it delivered>`.
|
||||
Remove the "Current" checklist and any placeholder line.
|
||||
4. Remove the milestone's working notes: fixed defects (D…), consumed feasibility checks,
|
||||
notes that served only this milestone. Verified knowledge moves first: controller facts
|
||||
to `docs/frisko/`, scope or architecture to `docs/spec.md`, decisions to a new ADR.
|
||||
5. Make the next row of "Next" the new "Current": a checklist of PR-sized items in build
|
||||
order, keeping its "Done when". Ask the user when the split is not obvious.
|
||||
6. `scripts/check.sh` (roadmap ≤ 80 lines), then `/ship`.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Ship
|
||||
|
||||
Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
(no API token here; reads need none).
|
||||
|
||||
1. On a topic branch (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), clean tree, commits
|
||||
in Conventional Commits. Run `scripts/check.sh`.
|
||||
2. `git push -u origin <branch>`; CI runs on every pushed branch.
|
||||
3. Open the PR by push (Gitea AGit), title in Conventional Commits, description says why:
|
||||
`git push origin HEAD:refs/for/master -o topic=<branch> -o title="…" -o description="…"`.
|
||||
The description is one line (push options cannot hold newlines). After later fixes push
|
||||
both the branch and `refs/for/master` with the same topic, so CI and the PR both update.
|
||||
If that is refused, give the user the compare link `…/compare/master...<branch>`.
|
||||
4. Wait for CI on the head commit, polling every 30 s:
|
||||
`curl -s …/api/v1/repos/mariusz.pacek/home-pump/commits/<sha>/status` → `state`.
|
||||
On `failure`, read the run under `…/actions`, fix, push again. Never merge red.
|
||||
5. Merge only if the user asked for it in this task:
|
||||
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
|
||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||
6. 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.
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: maven:3-jdk-11
|
||||
commands:
|
||||
- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
|
||||
- mvn test -B
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.mariuszpacek.pl/home-pump
|
||||
tags: latest
|
||||
when:
|
||||
branch:
|
||||
- master
|
||||
@@ -28,5 +28,5 @@ jobs:
|
||||
uses: stCarolas/setup-maven@v5
|
||||
with:
|
||||
maven-version: 3.9.6
|
||||
- name: Run Maven tests (parallel)
|
||||
run: mvn -B -T 1C test
|
||||
- name: Check
|
||||
run: scripts/check.sh
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Enabled once per clone: git config core.hooksPath .githooks
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/check.sh"
|
||||
@@ -56,3 +56,6 @@ coverage/
|
||||
/apk/
|
||||
/old/
|
||||
/old_backup/
|
||||
|
||||
# Agent worktrees
|
||||
/.claude/worktrees/
|
||||
@@ -0,0 +1,15 @@
|
||||
Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP) from a phone.
|
||||
|
||||
@CONTRIBUTING.md
|
||||
|
||||
## For agents
|
||||
|
||||
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
|
||||
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
|
||||
- Production: `pi@raspberrypi.local`, service `home-pump.service`. Read-only inspection is fine;
|
||||
deploy, restart or file changes on the Pi need explicit approval each time.
|
||||
- The Pi also runs `rpi-measurements.service` (house meter and BME280 into Influx); do not break
|
||||
it. It moves into the gateway in M1; never run both against the meter at once. Do not upgrade
|
||||
or reinstall the Pi's OS.
|
||||
- Never write to the controller `192.168.0.150` and never to the `Dom_Pompa` database.
|
||||
- Ask before force-pushing or rewriting history on Gitea.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Repository rules
|
||||
|
||||
What we build: [docs/spec.md](docs/spec.md). Plan: [docs/roadmap.md](docs/roadmap.md).
|
||||
Reasons: [docs/decisions/](docs/decisions/).
|
||||
|
||||
## Branches and pull requests
|
||||
|
||||
- `master` is always deployable and matches what runs on the Pi.
|
||||
- Every change goes through a pull request on Gitea; no direct commits to `master`.
|
||||
- Branch names: `feat/…`, `fix/…`, `docs/…`, `chore/…`, `refactor/…`.
|
||||
- One topic per PR, small enough to review in one sitting. Merge only with green CI.
|
||||
- `archive/spring-boot-attempt` is reference only. Never merge it.
|
||||
|
||||
## Commits
|
||||
|
||||
- English, Conventional Commits: `fix(health): restart without /health polling`.
|
||||
- Imperative subject ≤ 72 characters; the body says why when it is not obvious.
|
||||
- Each commit builds and passes tests.
|
||||
|
||||
## Code
|
||||
|
||||
- Java 21, Maven, no framework (ADR-001). `mvn clean package`, `mvn test`.
|
||||
- `scripts/check.sh` is the gate: doc limits, secret scan, build and tests. CI runs it, and so
|
||||
does the pre-push hook once enabled with `git config core.hooksPath .githooks`.
|
||||
- Readable first: small classes and methods, names from the domain, comments only for the why.
|
||||
- No dead code, no unused config, no duplicated logic, no speculative abstractions.
|
||||
- Every behaviour change has a test. Modbus is tested against `MockModbusServer` only.
|
||||
|
||||
## Safety
|
||||
|
||||
- Tests and local runs never use the real controller or the `Dom_Pompa` database.
|
||||
- A register is written only if it is on the allowlist (min, max, step) and verified in
|
||||
`docs/frisko/`.
|
||||
- Changes to how or when we write to the controller need a test of the failure case.
|
||||
|
||||
## Knowledge and docs
|
||||
|
||||
Docs are written so an agent needs little context: read only what the task touches.
|
||||
|
||||
| File | Limit | Changes |
|
||||
|---|---|---|
|
||||
| `CLAUDE.md` | 40 lines | rarely |
|
||||
| `docs/spec.md` | 120 lines | when scope or architecture changes |
|
||||
| `docs/roadmap.md` | 80 lines | every milestone |
|
||||
| `docs/decisions/NNN-*.md` | 40 lines | never after acceptance; supersede with a new ADR |
|
||||
| `docs/frisko/<topic>.md` | 120 lines | when knowledge is verified |
|
||||
| `<package>/CLAUDE.md` | 30 lines | rules of that layer |
|
||||
| `research/<source>/` | none | raw vendor material, kept as found; read only on demand |
|
||||
|
||||
- Limits are checked by `scripts/check.sh`.
|
||||
- No status, audit or log documents: the state is git history and the roadmap.
|
||||
- Closing a milestone compresses it to one line in the roadmap and removes its working notes.
|
||||
- English everywhere; the UI is Polish.
|
||||
|
||||
## Configuration, secrets, releases
|
||||
|
||||
- No secrets in git. Production config lives on the Pi, 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.
|
||||
@@ -1,4 +0,0 @@
|
||||
FROM amazoncorretto:11.0.17
|
||||
COPY target/test*.jar app/home-pump.jar
|
||||
|
||||
CMD ["java", "-jar", "app/home-pump.jar"]
|
||||
@@ -1,168 +1,35 @@
|
||||
# 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
|
||||
|
||||
- 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
|
||||
|
||||
## 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.
|
||||
- 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)
|
||||
|
||||
```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, tests
|
||||
git config core.hooksPath .githooks # once per clone: check before every push
|
||||
scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH # deploy master to the Pi (approval needed)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Backend (phone API and web UI)
|
||||
|
||||
Edit `src/main/resources/registryMapping.json` to configure:
|
||||
Settings are environment variables; the backend refuses to start without the two URLs.
|
||||
|
||||
- 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:
|
||||
| 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 |
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
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 # UI at http://localhost:8080/
|
||||
|
||||
docker build -t home-pump-backend backend # after the mvn package above
|
||||
docker run --rm -p 8080:8080 -e GATEWAY_URL=... -e INFLUX_URL=... home-pump-backend
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Build the jar first: mvn -f backend/pom.xml clean package
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
RUN adduser -D -u 10001 app
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY 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,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,31 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
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 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. */
|
||||
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()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
ApiServer server = Backend.assemble(settings, Clock.systemUTC());
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout) {
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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,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,123 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
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.HttpServer;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
|
||||
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.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);
|
||||
|
||||
private final HttpServer server;
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
server.createContext("/", new StaticFiles());
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/health", query -> health.current());
|
||||
server.createContext("/api/history", exchange -> handleHistory(exchange, history));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
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) {
|
||||
server.createContext(path, exchange -> respond(exchange, () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error("gateway unreachable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
respond(exchange, () -> {
|
||||
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 static void respond(HttpExchange exchange, Action action) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
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,63 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serves the web UI from the classpath folder {@code web/}; only files of known types. */
|
||||
final class StaticFiles implements HttpHandler {
|
||||
|
||||
private static final Map<String, String> CONTENT_TYPES = Map.of(
|
||||
"html", "text/html; charset=utf-8",
|
||||
"css", "text/css; charset=utf-8",
|
||||
"js", "text/javascript; charset=utf-8",
|
||||
"svg", "image/svg+xml",
|
||||
"png", "image/png",
|
||||
"ico", "image/x-icon");
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String resource = path.equals("/") ? "index.html" : path.substring(1);
|
||||
String type = contentType(resource);
|
||||
try (InputStream in = type == null || !isSafe(resource) ? null : open(resource)) {
|
||||
if (in == null) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
byte[] body = in.readAllBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", type);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream open(String resource) {
|
||||
return StaticFiles.class.getClassLoader().getResourceAsStream("web/" + resource);
|
||||
}
|
||||
|
||||
/** The path is already URL-decoded, so this also catches %2e%2e. */
|
||||
private static boolean isSafe(String resource) {
|
||||
return !resource.contains("\\") && !resource.contains("\0") && !resource.contains("//")
|
||||
&& Arrays.stream(resource.split("/")).noneMatch(s -> s.equals("..") || s.equals("."));
|
||||
}
|
||||
|
||||
private static String contentType(String resource) {
|
||||
int dot = resource.lastIndexOf('.');
|
||||
return dot < 0 ? null : CONTENT_TYPES.get(resource.substring(dot + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
|
||||
/** 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<>();
|
||||
for (Source source : SeriesSources.of(series)) {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
// A part without data is absent, not zero, so a bucket is kept if any part has it.
|
||||
InfluxResponse.parse(fetch(source.database(), query)).forEach((t, v) -> summed.merge(t, v, Double::sum));
|
||||
}
|
||||
return summed.entrySet().stream()
|
||||
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
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,56 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
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.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 {
|
||||
|
||||
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();
|
||||
Optional<Measurement> deye = SnapshotFields.find(snapshot, "deye", "deye");
|
||||
Optional<Measurement> fronius = SnapshotFields.find(snapshot, "fronius", "fronius");
|
||||
Map<String, Double> d = deye.map(Measurement::fields).orElse(Map.of());
|
||||
Map<String, Double> f = fronius.map(Measurement::fields).orElse(Map.of());
|
||||
|
||||
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"));
|
||||
return new EnergyView(time(deye, fronius),
|
||||
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 Instant time(Optional<Measurement> deye, Optional<Measurement> fronius) {
|
||||
return Stream.of(deye, fronius).flatMap(Optional::stream).map(Measurement::time)
|
||||
.max(Instant::compareTo).orElseGet(clock::instant);
|
||||
}
|
||||
|
||||
/** 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,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,65 @@
|
||||
package home.backend.application;
|
||||
|
||||
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 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 {
|
||||
return SnapshotFields.find(gateway.snapshot(), "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()))
|
||||
.map(m -> view(m.time(), m.fields()))
|
||||
.orElseGet(() -> view(clock.instant(), Map.of()));
|
||||
}
|
||||
|
||||
private static HeatPumpView view(Instant time, Map<String, Double> f) {
|
||||
return new HeatPumpView(time, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm"),
|
||||
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")));
|
||||
}
|
||||
|
||||
/** 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,35 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
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() {
|
||||
}
|
||||
|
||||
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,15 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Null when the source device is not read.
|
||||
*/
|
||||
public record EnergyView(Instant time, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
|
||||
Double batterySocPct, Double loadPowerW, Today today) {
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
}
|
||||
|
||||
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
|
||||
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** What the heat pump does now (F1). Values are null when the gateway has not read them yet. */
|
||||
public record HeatPumpView(Instant time, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Pumps pumps, Temperatures temperatures, Targets targets, Setpoints setpoints) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
|
||||
}
|
||||
|
||||
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
|
||||
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
|
||||
}
|
||||
|
||||
/** Targets the controller works to right now (economy or comfort period). */
|
||||
public record Targets(Double room, Double co, Double cwu) {
|
||||
}
|
||||
|
||||
/** The four setpoints F3 will change; whole °C. */
|
||||
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** One series over a time range, already averaged into chart-sized steps. */
|
||||
public record HistoryView(String series, String unit, List<Point> points) {
|
||||
|
||||
public record Point(Instant t, double v) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
|
||||
public enum Series {
|
||||
OUTDOOR_TEMP("outdoor_temp", "°C"),
|
||||
INDOOR_TEMP("indoor_temp", "°C"),
|
||||
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
|
||||
CWU_TEMP("cwu_temp", "°C"),
|
||||
GROUND_IN_TEMP("ground_in_temp", "°C"),
|
||||
GROUND_OUT_TEMP("ground_out_temp", "°C"),
|
||||
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
|
||||
GRID_POWER("grid_power", "W"),
|
||||
PV_POWER("pv_power", "W"),
|
||||
LOAD_POWER("load_power", "W"),
|
||||
BATTERY_SOC("battery_soc", "%");
|
||||
|
||||
private final String id;
|
||||
private final String unit;
|
||||
|
||||
Series(String id, String unit) {
|
||||
this.id = id;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String unit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public static Optional<Series> fromId(String id) {
|
||||
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export async function getJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, message, restoreAt}
|
||||
// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails.
|
||||
export async function postJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
if (response.status === 503) throw new Error(`${url}: 503`);
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new Error(`${url}: ${response.status}`);
|
||||
return answer;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ageMinutes, ago } from "./format.js";
|
||||
import heatPump from "./heat-pump.js";
|
||||
import energy from "./energy.js";
|
||||
import history from "./history.js";
|
||||
import settings from "./settings.js";
|
||||
import status from "./status.js";
|
||||
|
||||
const REFRESH_MS = 30000;
|
||||
const STALE_MINUTES = 10;
|
||||
const screens = { "heat-pump": heatPump, energy, history, settings, status };
|
||||
const root = document.getElementById("screen");
|
||||
const tabs = document.querySelectorAll("#tabs button");
|
||||
const cache = {}; // last good data per screen, kept while the backend is unreachable
|
||||
|
||||
let current = screens[location.hash.slice(1)] ? location.hash.slice(1) : "heat-pump";
|
||||
|
||||
function notice(offline, data) {
|
||||
if (offline) {
|
||||
const last = data
|
||||
? ` Pokazuję ostatnie dane (${ago(data.time)}).`
|
||||
: " Nie można pobrać danych z domu. Aplikacja spróbuje znowu za chwilę.";
|
||||
return `<div class="banner bad" role="alert">Brak połączenia.${last}</div>`;
|
||||
}
|
||||
if (data.time && ageMinutes(data.time) >= STALE_MINUTES) {
|
||||
return `<div class="banner warn">Dane są nieaktualne: ${ago(data.time)}.</div>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function draw(name, offline) {
|
||||
if (name !== current) return;
|
||||
const data = cache[name];
|
||||
root.innerHTML = notice(offline, data) + (data ? screens[name].view(data) : "");
|
||||
screens[name].bind?.(root, data, () => refresh(name));
|
||||
}
|
||||
|
||||
async function refresh(name) {
|
||||
let offline = false;
|
||||
try {
|
||||
cache[name] = await screens[name].load();
|
||||
} catch {
|
||||
offline = true;
|
||||
}
|
||||
draw(name, offline);
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
current = name;
|
||||
location.hash = name;
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.screen === name) tab.setAttribute("aria-current", "page");
|
||||
else tab.removeAttribute("aria-current");
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
if (cache[name]) draw(name, false);
|
||||
else root.innerHTML = `<p class="muted">Ładowanie…</p>`;
|
||||
refresh(name);
|
||||
}
|
||||
|
||||
for (const tab of tabs) tab.addEventListener("click", () => show(tab.dataset.screen));
|
||||
setInterval(() => refresh(current), REFRESH_MS);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) refresh(current);
|
||||
});
|
||||
show(current);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const W = 340, H = 220;
|
||||
const MARGIN = { left: 48, right: 22, top: 24, bottom: 26 };
|
||||
const Y_TICKS = 4; // about; the step is rounded
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
const DAY_MS = 86400000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
|
||||
// Local midnights (or every 6th full hour) inside the range, so the axis reads like a clock.
|
||||
function timeTicks(t0, t1, range) {
|
||||
const ticks = [];
|
||||
const date = new Date(t0);
|
||||
date.setMinutes(0, 0, 0);
|
||||
if (range === "7d") date.setHours(0);
|
||||
else date.setHours(Math.ceil(date.getHours() / HOURS_BETWEEN_TICKS) * HOURS_BETWEEN_TICKS);
|
||||
for (; date.getTime() <= t1; range === "7d" ? date.setDate(date.getDate() + 1) : date.setHours(date.getHours() + HOURS_BETWEEN_TICKS)) {
|
||||
if (date.getTime() >= t0) ticks.push(date.getTime());
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
|
||||
function niceStep(rough) {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
||||
return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
|
||||
}
|
||||
|
||||
const xText = (px, anchor, text) => `<text class="sub" x="${px}" y="${H - 6}" text-anchor="${anchor}">${text}</text>`;
|
||||
|
||||
function hourLabels(ticks, x) {
|
||||
return ticks.map((t) => xText(x(t), "middle", hour(new Date(t)))).join("");
|
||||
}
|
||||
|
||||
// A weekday name sits in the middle of its day, between two midnight lines.
|
||||
function dayLabels(midnights, t0, t1, x) {
|
||||
const edges = [t0, ...midnights, t1];
|
||||
return edges.slice(0, -1).map((from, i) => {
|
||||
const to = edges[i + 1];
|
||||
if (to - from < DAY_MS / 3) return "";
|
||||
return xText((x(from) + x(to)) / 2, "middle", DAY_NAMES[new Date(from + 1).getDay()]);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
|
||||
export function lineChart(points, range, unit) {
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const values = points.map((p) => p.v);
|
||||
const [t0, t1] = [Math.min(...times), Math.max(...times)];
|
||||
let [lo, hi] = [Math.min(...values), Math.max(...values)];
|
||||
if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
|
||||
const step = niceStep((hi - lo) / Y_TICKS);
|
||||
[lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
|
||||
|
||||
const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
|
||||
const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
|
||||
|
||||
const digits = step < 1 ? 1 : 0;
|
||||
const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
|
||||
const v = lo + i * step;
|
||||
return `<line class="axis" x1="${MARGIN.left}" x2="${W - MARGIN.right}" y1="${y(v)}" y2="${y(v)}"/>
|
||||
<text class="sub" x="${MARGIN.left - 6}" y="${y(v) + 4}" text-anchor="end">${number(v, digits)}</text>`;
|
||||
}).join("");
|
||||
const ticks = timeTicks(t0, t1, range);
|
||||
const grid = ticks.map((t) => `<line class="axis" x1="${x(t)}" x2="${x(t)}" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>`).join("");
|
||||
const xLabels = range === "7d" ? dayLabels(ticks, t0, t1, x) : hourLabels(ticks, x);
|
||||
const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
|
||||
|
||||
const svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Wykres">
|
||||
<text class="sub" x="2" y="12">${unit}</text>
|
||||
${yLabels}${grid}${xLabels}<path class="line" d="${path}"/>
|
||||
<g id="cursor" hidden><line class="cursor" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>
|
||||
<circle class="mark" r="5"/></g></svg>`;
|
||||
return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
|
||||
}
|
||||
|
||||
// Shows the point nearest to the finger or mouse in the readout element.
|
||||
export function trackPointer(svg, readout, chart, { points, unit }) {
|
||||
const cursor = svg.querySelector("#cursor");
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const show = (event) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const px = ((event.clientX - box.left) / box.width) * W;
|
||||
let best = 0;
|
||||
times.forEach((time, i) => {
|
||||
if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
|
||||
});
|
||||
const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
|
||||
cursor.hidden = false;
|
||||
cursor.querySelector("line").setAttribute("x1", cx);
|
||||
cursor.querySelector("line").setAttribute("x2", cx);
|
||||
cursor.querySelector("circle").setAttribute("cx", cx);
|
||||
cursor.querySelector("circle").setAttribute("cy", cy);
|
||||
readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
|
||||
};
|
||||
svg.addEventListener("pointerdown", show);
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (event.buttons || event.pointerType === "mouse") show(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { kwh, percent, power } from "./format.js";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
|
||||
// Nodes sit around the house; an arrow runs from the source to the sink of each flow.
|
||||
const NODES = {
|
||||
pv: { x: 68, y: 40, label: "Słońce" },
|
||||
grid: { x: 292, y: 40, label: "Sieć" },
|
||||
house: { x: 180, y: 125, label: "Dom" },
|
||||
battery: { x: 180, y: 215, label: "Bateria" },
|
||||
};
|
||||
|
||||
function node(id, value) {
|
||||
const { x, y, label } = NODES[id];
|
||||
return `<rect class="box" x="${x - 58}" y="${y - 30}" width="116" height="60" rx="10"/>
|
||||
<text x="${x}" y="${y - 7}" text-anchor="middle" font-size="15" class="sub">${label}</text>
|
||||
<text x="${x}" y="${y + 18}" text-anchor="middle" font-size="20" font-weight="700">${value}</text>`;
|
||||
}
|
||||
|
||||
function flow(from, to, watts) {
|
||||
if (watts == null || Math.abs(watts) < FLOWING_W) return "";
|
||||
const [source, sink] = watts > 0 ? [from, to] : [to, from];
|
||||
const a = NODES[source], b = NODES[sink];
|
||||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const t1 = 40 / length, t2 = 1 - 46 / length;
|
||||
const point = (t, axis) => a[axis] + (b[axis] - a[axis]) * t;
|
||||
return `<line class="flow" x1="${point(t1, "x")}" y1="${point(t1, "y")}"
|
||||
x2="${point(t2, "x")}" y2="${point(t2, "y")}" marker-end="url(#arrow)"/>`;
|
||||
}
|
||||
|
||||
function flowPicture(e) {
|
||||
return `<svg class="schematic" viewBox="0 0 360 260" role="img" aria-label="Przepływ energii w domu">
|
||||
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5"
|
||||
orient="auto"><path d="M0 0 10 5 0 10z" fill="var(--accent)"/></marker></defs>
|
||||
${flow("pv", "house", e.pvPowerW)}${flow("grid", "house", e.gridPowerW)}
|
||||
${flow("battery", "house", e.batteryPowerW)}
|
||||
${node("pv", power(e.pvPowerW))}${node("grid", power(magnitude(e.gridPowerW)))}
|
||||
${node("house", power(e.loadPowerW))}${node("battery", percent(e.batterySocPct))}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const magnitude = (watts) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
function gridText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez wymiany";
|
||||
return watts > 0 ? "pobór" : "oddawanie";
|
||||
}
|
||||
|
||||
function batteryText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez przepływu";
|
||||
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
|
||||
}
|
||||
|
||||
const tile = (label, value, note = "") =>
|
||||
`<div class="tile"><div class="muted">${label}</div><div class="big">${value}</div>
|
||||
<div class="muted">${note}</div></div>`;
|
||||
|
||||
function view(e) {
|
||||
const today = e.today;
|
||||
return `<section class="card"><h1>Energia teraz</h1>${flowPicture(e)}</section>
|
||||
<section class="card"><div class="grid2">
|
||||
${tile("Produkcja PV", power(e.pvPowerW), "Deye + Fronius")}
|
||||
${tile("Zużycie domu", power(e.loadPowerW))}
|
||||
${tile("Sieć", power(magnitude(e.gridPowerW)), gridText(e.gridPowerW))}
|
||||
${tile("Bateria", percent(e.batterySocPct), batteryText(e.batteryPowerW))}
|
||||
</div></section>
|
||||
<section class="card"><h2>Dzisiaj</h2><div class="grid2">
|
||||
${tile("Produkcja PV", kwh(today.pvKwh))}
|
||||
${tile("Zużycie", kwh(today.loadKwh))}
|
||||
${tile("Pobrano z sieci", kwh(today.gridBoughtKwh))}
|
||||
${tile("Oddano do sieci", kwh(today.gridSoldKwh))}
|
||||
</div></section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/energy"), view };
|
||||
@@ -0,0 +1,39 @@
|
||||
// Polish number and time formats; every function accepts null and shows a dash.
|
||||
const DASH = "—";
|
||||
|
||||
const decimals = (digits) => new Intl.NumberFormat("pl-PL", {
|
||||
minimumFractionDigits: digits, maximumFractionDigits: digits,
|
||||
});
|
||||
const formats = [decimals(0), decimals(1), decimals(2)];
|
||||
|
||||
// The typographic minus is wider than the hyphen, so -3 is not read as a dash or a range.
|
||||
export function number(value, digits = 0) {
|
||||
return value == null ? DASH : formats[digits].format(value).replace("-", "\u2212");
|
||||
}
|
||||
|
||||
export const celsius = (value) => (value == null ? DASH : `${number(value, 1)} °C`);
|
||||
export const percent = (value) => (value == null ? DASH : `${number(value)} %`);
|
||||
export const kwh = (value) => (value == null ? DASH : `${number(value, 1)} kWh`);
|
||||
|
||||
export function power(watts) {
|
||||
if (watts == null) return DASH;
|
||||
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
|
||||
}
|
||||
|
||||
export function ageMinutes(iso, now = Date.now()) {
|
||||
return Math.max(0, Math.floor((now - Date.parse(iso)) / 60000));
|
||||
}
|
||||
|
||||
export function ago(iso) {
|
||||
if (iso == null) return "brak odczytu";
|
||||
const minutes = ageMinutes(iso);
|
||||
if (minutes < 1) return "przed chwilą";
|
||||
if (minutes < 60) return `${minutes} min temu`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours} godz. temu`;
|
||||
return `${Math.floor(hours / 24)} dni temu`;
|
||||
}
|
||||
|
||||
export const hour = (date) => date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" });
|
||||
export const weekday = (date) => date.toLocaleDateString("pl-PL", { weekday: "short" });
|
||||
export const dayAndHour = (date) => `${weekday(date)} ${hour(date)}`;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { celsius, hour } from "./format.js";
|
||||
import { schematic } from "./schematic.js";
|
||||
|
||||
const HEADLINES = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
HEATING_CWU: "Grzeje wodę (CWU)",
|
||||
HEATING_CO: "Grzeje dom (CO)",
|
||||
HEATING_BOTH: "Grzeje dom i wodę",
|
||||
};
|
||||
|
||||
const PUMP_NAMES = {
|
||||
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
};
|
||||
|
||||
const row = (label, value) => `<div><span>${label}</span><b>${value}</b></div>`;
|
||||
|
||||
function pumpRows(pumps) {
|
||||
return Object.entries(PUMP_NAMES)
|
||||
.map(([key, name]) => row(name, pumps[key] ? "działa" : "stoi")).join("");
|
||||
}
|
||||
|
||||
function view(data) {
|
||||
const { targets, temperatures: t } = data;
|
||||
const alarm = data.alarm
|
||||
? `<div class="banner bad" role="alert">ALARM pompy ciepła. Sprawdź sterownik w domu.</div>` : "";
|
||||
return `${alarm}
|
||||
<section class="card">
|
||||
<p class="headline">${HEADLINES[data.state]}</p>
|
||||
<p class="muted">Odczyt z ${hour(new Date(data.time))}</p>
|
||||
${schematic(data)}
|
||||
</section>
|
||||
<section class="card rows"><h2>Pompy</h2>${pumpRows(data.pumps)}</section>
|
||||
<section class="card rows"><h2>Temperatury</h2>
|
||||
${row("Na zewnątrz", celsius(t.outdoor))}
|
||||
${row("Średnia zewnętrzna", celsius(t.outdoorAvg))}
|
||||
</section>
|
||||
<section class="card rows"><h2>Cele teraz</h2>
|
||||
${row("Pokój", celsius(targets.room))}
|
||||
${row("Woda grzewcza CO", celsius(targets.co))}
|
||||
${row("Ciepła woda CWU", celsius(targets.cwu))}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/heat-pump"), view };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { number } from "./format.js";
|
||||
import { lineChart, trackPointer } from "./chart.js";
|
||||
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
outdoor_temp: "Temperatura na zewnątrz",
|
||||
indoor_temp: "Temperatura w domu",
|
||||
co_buffer_temp: "Bufor CO",
|
||||
cwu_temp: "Ciepła woda (CWU)",
|
||||
ground_in_temp: "Gruntowe: do pompy",
|
||||
ground_out_temp: "Gruntowe: z pompy",
|
||||
pump_outlet_temp: "Wyjście pompy ciepła",
|
||||
grid_power: "Moc z sieci (+ pobór, − oddawanie)",
|
||||
pv_power: "Produkcja PV",
|
||||
load_power: "Zużycie domu",
|
||||
battery_soc: "Bateria (naładowanie)",
|
||||
};
|
||||
const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
const choice = { series: "cwu_temp", range: "24h" };
|
||||
let chart = null; // the one drawn by the last view, for the pointer handler
|
||||
|
||||
const options = () => Object.entries(SERIES)
|
||||
.map(([id, name]) => `<option value="${id}"${id === choice.series ? " selected" : ""}>${name}</option>`).join("");
|
||||
|
||||
const rangeButtons = () => Object.entries(RANGES)
|
||||
.map(([id, name]) => `<button data-range="${id}" aria-pressed="${id === choice.range}">${name}</button>`).join("");
|
||||
|
||||
function body(data) {
|
||||
chart = null;
|
||||
if (data.points.length < 2) return `<p class="muted">Za mało danych w tym okresie.</p>`;
|
||||
chart = lineChart(data.points, choice.range, data.unit);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
return `${chart.svg}
|
||||
<p class="readout" id="readout">Dotknij wykresu, aby zobaczyć wartość.</p>
|
||||
<p class="muted">Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}</p>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<section class="card"><h1>Historia</h1>
|
||||
<select id="series" aria-label="Wielkość">${options()}</select>
|
||||
<div class="seg">${rangeButtons()}</div>
|
||||
${body(data)}</section>`;
|
||||
|
||||
function bind(root, data, reload) {
|
||||
root.querySelector("#series").addEventListener("change", (event) => {
|
||||
choice.series = event.target.value;
|
||||
reload();
|
||||
});
|
||||
for (const button of root.querySelectorAll("[data-range]")) {
|
||||
button.addEventListener("click", () => {
|
||||
choice.range = button.dataset.range;
|
||||
reload();
|
||||
});
|
||||
}
|
||||
if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
|
||||
}
|
||||
|
||||
const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
|
||||
|
||||
export default { load, view, bind };
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Dom Krosno</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="screen" aria-live="polite"></main>
|
||||
<nav id="tabs" aria-label="Ekrany">
|
||||
<button data-screen="heat-pump"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-5 1 1 1.5 2 2 2.5C11.5 8.5 11 6 12 3z"/></svg>Pompa ciepła</button>
|
||||
<button data-screen="energy"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 13h6l-1 9 8-11h-6z"/></svg>Energia</button>
|
||||
<button data-screen="history"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18 9 11l4 4 8-10" fill="none" stroke-width="2.5"/></svg>Historia</button>
|
||||
<button data-screen="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10" fill="none" stroke-width="2.5"/><circle cx="16" cy="7" r="2.5"/><circle cx="8" cy="17" r="2.5"/></svg>Ustawienia</button>
|
||||
<button data-screen="status"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke-width="2.5"/><path d="m8 12 3 3 5-6" fill="none" stroke-width="2.5"/></svg>Stan</button>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { celsius, number } from "./format.js";
|
||||
|
||||
// The installation as one picture: ground loop → heat pump → CO buffer → house, and the CWU
|
||||
// tank. The solar collector has no sensor, so it is drawn without values.
|
||||
|
||||
const on = (flag) => (flag ? " on" : "");
|
||||
|
||||
function pump(x, y, running) {
|
||||
return `<g class="${running ? "on" : ""}" transform="translate(${x} ${y})">
|
||||
<circle class="pump" r="13"/>
|
||||
<path class="spokes" d="M-8 0H8M0-8V8"/></g>`;
|
||||
}
|
||||
|
||||
function box(x, y, width, height, lines) {
|
||||
const text = lines.map(([content, cls], i) =>
|
||||
`<text x="${x + 10}" y="${y + 22 + i * 20}" class="${cls ?? ""}" font-size="${cls ? 15.5 : 17}"
|
||||
font-weight="${cls ? 400 : 700}">${content}</text>`).join("");
|
||||
return `<rect class="box" x="${x}" y="${y}" width="${width}" height="${height}" rx="10"/>${text}`;
|
||||
}
|
||||
|
||||
export function schematic(view) {
|
||||
const { temperatures: t, pumps, state } = view;
|
||||
const heatsCo = state === "HEATING_CO" || state === "HEATING_BOTH";
|
||||
const heatsCwu = state === "HEATING_CWU" || state === "HEATING_BOTH";
|
||||
const compressor = view.compressorPercent == null ? "—" : `${number(view.compressorPercent)} %`;
|
||||
return `<svg class="schematic" viewBox="0 0 360 460" role="img"
|
||||
aria-label="Schemat instalacji: odwierty, pompa ciepła, bufor CO, dom i zbiornik CWU">
|
||||
<path class="pipe cold${on(pumps.ground)}" d="M150 78V130M210 78V130"/>
|
||||
<path class="pipe${on(heatsCo)}" d="M100 218V262M100 346V376"/>
|
||||
<path class="pipe${on(heatsCwu)}" d="M260 218V262"/>
|
||||
${box(15, 10, 330, 68, [["Odwierty (dolne źródło)"],
|
||||
[`Do pompy ${celsius(t.groundIn)}`, "sub"], [`Z pompy ${celsius(t.groundOut)}`, "sub"]])}
|
||||
${box(15, 130, 330, 90, [["Pompa ciepła"],
|
||||
[`Sprężarka ${compressor} · grzałka ${view.heaterOn ? "włączona" : "wyłączona"}`, "sub"],
|
||||
[`Wyjście pompy ${celsius(t.pumpOutlet)}`, "sub"]])}
|
||||
${box(15, 262, 165, 84, [["Bufor CO"], [celsius(t.coBuffer), "sub"], [`Cel ${celsius(view.targets.co)}`, "sub"]])}
|
||||
${box(190, 262, 155, 84, [["Zbiornik CWU"], [celsius(t.cwu), "sub"], [`Cel ${celsius(view.targets.cwu)}`, "sub"]])}
|
||||
${box(15, 376, 165, 70, [["Dom (grzejniki)"], [`Wewnątrz ${celsius(t.indoor)}`, "sub"], [`Cel ${celsius(view.targets.room)}`, "sub"]])}
|
||||
${box(190, 376, 155, 70, [["Kolektor"], ["na dachu", "sub"], ["bez czujnika", "sub"]])}
|
||||
<path class="pipe" d="M270 346V376" stroke-dasharray="3 7"/>
|
||||
${pump(210, 104, pumps.ground)}
|
||||
${pump(100, 240, pumps.buffer)}
|
||||
${pump(100, 361, pumps.co)}
|
||||
${pump(260, 240, pumps.cwu)}
|
||||
${pump(145, 361, pumps.circulation)}
|
||||
</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { getJson, postJson } from "./api.js";
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const NAMES = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
{ hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" },
|
||||
];
|
||||
const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę.";
|
||||
const RESULT_TEXT = {
|
||||
REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.",
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
|
||||
// Screen state outlives the 30 s refresh, so a half-made change is not lost.
|
||||
// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
|
||||
const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null };
|
||||
|
||||
const name = (id) => NAMES[id] ?? id;
|
||||
const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`;
|
||||
const disabled = () => (state.busy ? " disabled" : "");
|
||||
const find = (data, id) => data.setpoints.find((s) => s.name === id);
|
||||
|
||||
function when(iso) {
|
||||
const date = new Date(iso);
|
||||
return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date);
|
||||
}
|
||||
|
||||
function restores(data) {
|
||||
if (!data.restores.length) return "";
|
||||
const lines = data.restores.map((r) =>
|
||||
`<p>${name(r.setpoint)}: <b>${degrees(r.appliedValue)}</b> do ${when(r.due)}, potem <b>${degrees(r.originalValue)}</b>.</p>`);
|
||||
return `<section class="card"><h2>Zmiany czasowe</h2>${lines.join("")}</section>`;
|
||||
}
|
||||
|
||||
function resultBox() {
|
||||
const r = state.result;
|
||||
if (!r) return "";
|
||||
if (r.status === "APPLIED") {
|
||||
const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : "";
|
||||
return `<div class="banner ok" role="status">Zapisano.${back}</div>`;
|
||||
}
|
||||
const detail = r.message ? `<br><span class="detail">${r.message}</span>` : "";
|
||||
return `<div class="banner bad" role="alert">${r.text ?? RESULT_TEXT[r.status]}${detail}</div>`;
|
||||
}
|
||||
|
||||
function confirmButtons(yes, action) {
|
||||
return `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="${action}"${disabled()}>${yes}</button></div>`;
|
||||
}
|
||||
|
||||
function presets(data) {
|
||||
const buttons = data.presets.map((p) =>
|
||||
`<button class="big" data-preset="${p.id}"${disabled()}>${p.label}</button>`).join("");
|
||||
return `<section class="card"><h2>Szybkie ustawienia</h2><div class="stack">${buttons}</div></section>`;
|
||||
}
|
||||
|
||||
function presetConfirm(data) {
|
||||
const preset = data.presets.find((p) => p.id === state.preset);
|
||||
const effect = preset.description ? `<p class="lead">${preset.description}</p>` : "";
|
||||
return `<section class="card"><h2>${preset.label}</h2>${effect}<p>Potwierdzasz?</p>
|
||||
${confirmButtons("Tak, ustaw", "preset-ok")}</section>`;
|
||||
}
|
||||
|
||||
function stepper(s) {
|
||||
const shown = state.draft?.name === s.name ? state.draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
return `<div class="setpoint${shown === s.value ? "" : " changed"}"><span class="name">${label}</span>
|
||||
<div class="step">
|
||||
<button data-step="-1" data-name="${s.name}" aria-label="Mniej: ${label}"${disabled()}${shown <= s.min ? " disabled" : ""}>−</button>
|
||||
<output>${degrees(shown)}</output>
|
||||
<button data-step="1" data-name="${s.name}" aria-label="Więcej: ${label}"${disabled()}${shown >= s.max ? " disabled" : ""}>+</button>
|
||||
</div><span class="muted">od ${degrees(s.min)} do ${degrees(s.max)}</span></div>`;
|
||||
}
|
||||
|
||||
function saveBox(data) {
|
||||
const d = state.draft;
|
||||
if (!d) return "";
|
||||
const s = find(data, d.name);
|
||||
const options = DURATIONS.map((o) =>
|
||||
`<button data-hours="${o.hours ?? ""}" aria-pressed="${o.hours === state.hours}"${disabled()}>${o.label}</button>`).join("");
|
||||
const action = state.confirming
|
||||
? `<p class="lead">${saveSummary(s)}</p>${confirmButtons("Tak, zapisz", "save-ok")}`
|
||||
: `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="save"${disabled()}>Zapisz</button></div>`;
|
||||
return `<div class="savebox"><p class="lead">${name(d.name)}: z ${degrees(s.value)} na <b>${degrees(d.value)}</b></p>
|
||||
<p>Na jak długo?</p><div class="seg wrap">${options}</div>${action}</div>`;
|
||||
}
|
||||
|
||||
function saveSummary(s) {
|
||||
const value = degrees(state.draft.value);
|
||||
if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`;
|
||||
const back = when(new Date(Date.now() + state.hours * 3600000).toISOString());
|
||||
return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`;
|
||||
}
|
||||
|
||||
function body(data) {
|
||||
if (state.preset) return resultBox() + presetConfirm(data);
|
||||
return `${resultBox()}${restores(data)}${presets(data)}
|
||||
<section class="card"><h2>Nastawy</h2>${data.setpoints.map(stepper).join("")}${saveBox(data)}</section>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<div id="settings">${body(data)}</div>`;
|
||||
|
||||
function change(data, el) {
|
||||
const { step, name: id, hours, preset, act } = el.dataset;
|
||||
state.result = null;
|
||||
if (step) {
|
||||
const s = find(data, id);
|
||||
const from = state.draft?.name === id ? state.draft.value : s.value;
|
||||
const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step));
|
||||
state.draft = value === s.value ? null : { name: id, value };
|
||||
state.confirming = false;
|
||||
} else if (hours !== undefined) {
|
||||
state.hours = hours === "" ? null : Number(hours);
|
||||
state.confirming = false;
|
||||
} else if (preset) {
|
||||
state.preset = preset;
|
||||
} else if (act === "cancel") {
|
||||
Object.assign(state, { draft: null, preset: null, confirming: false });
|
||||
} else if (act === "save") {
|
||||
state.confirming = true;
|
||||
}
|
||||
}
|
||||
|
||||
function bind(root, data, reload) {
|
||||
const box = root.querySelector("#settings");
|
||||
|
||||
async function send(url, payload) {
|
||||
state.busy = true;
|
||||
box.innerHTML = body(data);
|
||||
try {
|
||||
state.result = await postJson(url, payload);
|
||||
if (state.result.status === "APPLIED") state.draft = null;
|
||||
} catch {
|
||||
state.result = { status: "FAILED", text: OFFLINE };
|
||||
}
|
||||
Object.assign(state, { busy: false, preset: null, confirming: false });
|
||||
window.scrollTo(0, 0);
|
||||
reload();
|
||||
}
|
||||
|
||||
box.addEventListener("click", (event) => {
|
||||
const el = event.target.closest("button");
|
||||
if (!el || state.busy) return;
|
||||
const act = el.dataset.act;
|
||||
if (act === "save-ok") {
|
||||
const { name: setpoint, value } = state.draft;
|
||||
send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined });
|
||||
} else if (act === "preset-ok") {
|
||||
send(`api/presets/${state.preset}`);
|
||||
} else {
|
||||
change(data, el);
|
||||
box.innerHTML = body(data);
|
||||
box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/setpoints"), view, bind };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { ago } from "./format.js";
|
||||
|
||||
const DEVICE_NAMES = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
"house-meter": "Licznik domu",
|
||||
bme280: "Czujnik pogody",
|
||||
deye: "Falownik Deye (bateria, PV)",
|
||||
fronius: "Falownik Fronius (PV)",
|
||||
};
|
||||
|
||||
const dot = (up) => `<span class="dot ${up ? "" : "down"}" aria-hidden="true"></span>`;
|
||||
|
||||
function device(d) {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
const detail = d.up ? last : `nie odpowiada, ${last}`;
|
||||
return `<div><span>${dot(d.up)}${DEVICE_NAMES[d.name] ?? d.name}<br>
|
||||
<span class="muted">${detail}</span></span><b>${d.up ? "działa" : "brak"}</b></div>`;
|
||||
}
|
||||
|
||||
const devices = (data) => (data.devices.length
|
||||
? data.devices.map(device).join("")
|
||||
: `<p class="muted">Lista pojawi się, gdy dom odpowie.</p>`);
|
||||
|
||||
function view(data) {
|
||||
const gateway = data.gatewayReachable
|
||||
? `${dot(true)}Dom odpowiada` : `${dot(false)}Dom nie odpowiada`;
|
||||
return `<section class="card"><h2>Połączenie z domem</h2><p class="headline">${gateway}</p></section>
|
||||
<section class="card rows"><h2>Urządzenia</h2>${devices(data)}</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/health"), view };
|
||||
@@ -0,0 +1,102 @@
|
||||
:root {
|
||||
--bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3;
|
||||
--accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e;
|
||||
--warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93;
|
||||
--box: #e8f0eb; --ok-bg: #d9f2e4;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c;
|
||||
--accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80;
|
||||
--warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82;
|
||||
--box: #223229; --ok-bg: #153d2a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font-size: 18px; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.35;
|
||||
}
|
||||
main { max-width: 640px; margin: 0 auto; padding: 16px 12px 96px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 8px; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
.headline { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
|
||||
.banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-weight: 600; }
|
||||
.banner.bad { background: var(--bad-bg); color: var(--bad); border: 2px solid var(--bad); }
|
||||
.banner.warn { background: var(--warn-bg); color: var(--warn); border: 2px solid var(--warn); }
|
||||
.rows > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.rows > div:first-child { border-top: 0; }
|
||||
.rows b { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.tile { background: var(--box); border-radius: 12px; padding: 12px; }
|
||||
.tile .big { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; margin-right: 8px; vertical-align: -1px; background: var(--ok); }
|
||||
.dot.down { background: var(--bad); }
|
||||
nav {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; display: flex; background: var(--card);
|
||||
border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
nav button {
|
||||
flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit;
|
||||
font-size: 0.7rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 2px; padding: 4px 2px;
|
||||
}
|
||||
nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; }
|
||||
nav button[aria-current="page"] { color: var(--accent); box-shadow: inset 0 3px 0 var(--accent); }
|
||||
button:focus-visible, select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.seg { display: flex; gap: 8px; margin: 8px 0; }
|
||||
.seg button, select {
|
||||
flex: 1; min-height: 48px; border: 2px solid var(--line); background: var(--card); color: var(--text);
|
||||
border-radius: 12px; font: inherit; font-weight: 600;
|
||||
}
|
||||
select { width: 100%; padding: 0 10px; }
|
||||
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
svg text { fill: var(--text); font-family: inherit; }
|
||||
svg .sub { fill: var(--muted); }
|
||||
.schematic { width: 100%; height: auto; display: block; }
|
||||
.box { fill: var(--box); stroke: var(--line); stroke-width: 1.5; }
|
||||
.pipe { stroke: var(--off); stroke-width: 5; fill: none; }
|
||||
.pipe.on { stroke: var(--hot); }
|
||||
.pipe.cold.on { stroke: var(--cold); }
|
||||
.pump { fill: var(--card); stroke: var(--off); stroke-width: 3; }
|
||||
.on .pump { stroke: var(--ok); }
|
||||
.spokes { stroke: var(--off); stroke-width: 3; transform-origin: center; transform-box: fill-box; }
|
||||
.on .spokes { stroke: var(--ok); animation: spin 2s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .on .spokes { animation: none; } }
|
||||
.flow { stroke: var(--accent); stroke-width: 4; }
|
||||
.chart text { font-size: 14.5px; }
|
||||
.chart [hidden] { display: none; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linejoin: round; }
|
||||
.chart .cursor { stroke: var(--muted); stroke-width: 1; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart { touch-action: pan-y; width: 100%; height: auto; display: block; }
|
||||
.readout { min-height: 2.7em; font-weight: 600; }
|
||||
.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); }
|
||||
.detail { font-weight: 400; font-size: 0.85rem; }
|
||||
.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; }
|
||||
.stack { display: grid; gap: 10px; }
|
||||
button.big, .seg button.primary {
|
||||
min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit;
|
||||
font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent);
|
||||
}
|
||||
.seg button.primary { background: var(--accent); color: var(--on-accent); }
|
||||
button:disabled { opacity: 0.45; }
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.seg.wrap button { flex: 1 1 40%; }
|
||||
.setpoint { padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.setpoint:first-of-type { border-top: 0; }
|
||||
.setpoint .name { display: block; font-weight: 600; }
|
||||
.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; }
|
||||
.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
.step button {
|
||||
width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card);
|
||||
color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1;
|
||||
}
|
||||
.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; }
|
||||
.savebox { scroll-margin-bottom: 84px; }
|
||||
@@ -0,0 +1,193 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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 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.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();
|
||||
|
||||
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"));
|
||||
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));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
backend.start();
|
||||
}
|
||||
|
||||
@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 servesTheRealWebUi() throws Exception {
|
||||
HttpResponse<String> index = get("/");
|
||||
HttpResponse<String> script = get("/app.js");
|
||||
|
||||
assertEquals(200, index.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", index.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals(Files.readString(Path.of("src/main/resources/web/index.html")), index.body());
|
||||
// Browsers refuse ES modules served with any other type.
|
||||
assertEquals(200, script.statusCode());
|
||||
assertEquals("text/javascript; charset=utf-8", script.headers().firstValue("Content-Type").orElseThrow());
|
||||
}
|
||||
|
||||
@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 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 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"))) {
|
||||
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,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,248 @@
|
||||
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.application.GatewayClient;
|
||||
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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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 ApiServer server;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
snapshot = exampleSnapshot();
|
||||
GatewayClient gateway = () -> {
|
||||
if (gatewayDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
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));
|
||||
};
|
||||
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));
|
||||
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", false, null, "connect timed out",
|
||||
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(snapshot.devices().subList(0, 2));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@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 influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").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 unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheRealIndexAtRoot() throws Exception {
|
||||
HttpResponse<String> response = get("/");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertTrue(response.body().contains("Dom Krosno"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesStaticFilesWithContentTypes() throws Exception {
|
||||
Map<String, String> types = Map.of(
|
||||
"/index.html", "text/html; charset=utf-8",
|
||||
"/test.css", "text/css; charset=utf-8",
|
||||
"/test.js", "text/javascript; charset=utf-8",
|
||||
"/test.svg", "image/svg+xml",
|
||||
"/test.png", "image/png",
|
||||
"/test.ico", "image/x-icon");
|
||||
|
||||
for (Map.Entry<String, String> entry : types.entrySet()) {
|
||||
HttpResponse<String> response = get(entry.getKey());
|
||||
assertEquals(200, response.statusCode(), entry.getKey());
|
||||
assertEquals(entry.getValue(), response.headers().firstValue("Content-Type").orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingUnknownTypeAndDirectoriesAre404() throws Exception {
|
||||
for (String path : List.of("/missing.css", "/sub/data.txt", "/sub", "/sub/", "/web")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTraversalIsRefused() throws Exception {
|
||||
// secret.css sits next to web/ on the test classpath
|
||||
for (String path : List.of("/../secret.css", "/%2e%2e/secret.css", "/sub/../../secret.css",
|
||||
"/%2e%2e%2fsecret.css", "/..%5csecret.css")) {
|
||||
assertEquals(404, rawGet(path), 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,208 @@
|
||||
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.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 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 = new ArrayList<>();
|
||||
private final Map<String, String> bodyByField = new HashMap<>();
|
||||
private int status = 200;
|
||||
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
|
||||
requests.add(params);
|
||||
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);
|
||||
exchange.sendResponseHeaders(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 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,251 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
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 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());
|
||||
}
|
||||
|
||||
@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());
|
||||
assertEquals(NOW, view.time());
|
||||
}
|
||||
|
||||
@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 energyWithoutSourcesIsNullAtClockTime() throws IOException {
|
||||
EnergyView view = energy();
|
||||
|
||||
assertNull(view.pvPowerW());
|
||||
assertNull(view.today().pvKwh());
|
||||
assertEquals(NOW, 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,20 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class SeriesTest {
|
||||
|
||||
@Test
|
||||
void findsSeriesByItsId() {
|
||||
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIdFindsNothing() {
|
||||
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"pvPowerW": 0.0,
|
||||
"gridPowerW": 645.0,
|
||||
"batteryPowerW": -24.0,
|
||||
"batterySocPct": 99.0,
|
||||
"loadPowerW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"state": "IDLE",
|
||||
"compressorPercent": 0,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
|
||||
"temperatures": {
|
||||
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
|
||||
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
||||
},
|
||||
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"series": "cwu_temp",
|
||||
"unit": "°C",
|
||||
"points": [
|
||||
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
|
||||
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
|
||||
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"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 @@
|
||||
secret
|
||||
@@ -0,0 +1 @@
|
||||
x
|
||||
@@ -0,0 +1 @@
|
||||
body{}
|
||||
@@ -0,0 +1 @@
|
||||
ICO
|
||||
@@ -0,0 +1 @@
|
||||
a
|
||||
@@ -0,0 +1 @@
|
||||
PNG
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"/>
|
||||
|
After Width: | Height: | Size: 42 B |
@@ -0,0 +1,22 @@
|
||||
# ADR-001: Evolve the running baseline, plain Java
|
||||
|
||||
Status: accepted · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
Two starting points: the code running on the Pi (`e345063`, ~1300 lines, plain Java 21, works
|
||||
since February) or a rewrite. The previous attempt (Spring Boot, branch
|
||||
`archive/spring-boot-attempt`) grew large and complex without doing the essential job: it was
|
||||
built on a register catalog that does not match the device.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the baseline and reshape it in small, deployable steps. Introduce the layers from the spec
|
||||
as code is touched. Stay on plain Java 21 without a framework; add a small library only when it
|
||||
removes more code than it adds, and record it here.
|
||||
|
||||
## Consequences
|
||||
|
||||
- What is proven on the device (decoding, sign handling, register addresses) is kept.
|
||||
- Every step can be compared against the current Grafana data.
|
||||
- No dependency-injection or web framework to learn before reading the code.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR-002: Backend on the Pi, server as the gateway
|
||||
|
||||
Status: superseded by ADR-006 · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
The phone is outside the Krosno network. The backend could run on the Pi, next to the controller,
|
||||
or on my server and reach Modbus through WireGuard.
|
||||
|
||||
## Decision
|
||||
|
||||
The backend runs on the Pi. My server only routes and authenticates: Traefik forwards an
|
||||
authenticated HTTPS route through WireGuard to the Pi.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Modbus never crosses the WAN: low latency, and the backend keeps running when the tunnel drops.
|
||||
- No pump logic on the server.
|
||||
- Deploy target stays the Pi (`scripts/deploy.sh`).
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-003: One Modbus owner; snapshot queries, queued commands
|
||||
|
||||
Status: accepted · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
The controller answers slowly, and with frequent reads, commands were not carried out reliably.
|
||||
Several parts of the system want data at once (Influx export, API, health).
|
||||
|
||||
## Decision
|
||||
|
||||
- A single worker thread owns the only Modbus connection and talks to the controller in turn.
|
||||
- It polls at a fixed, conservative rate and publishes an immutable snapshot.
|
||||
- Queries (API, Influx export, health) read the snapshot only.
|
||||
- Commands go into a queue and get an id. The worker runs them between polls, reads the register
|
||||
back and marks them `APPLIED` or `FAILED`. The client polls the command status.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Load on the controller is fixed and set in one place, whatever the number of users.
|
||||
- Commands take seconds, not milliseconds; the UI shows them as pending.
|
||||
- The safe poll rate and command timing are measured in M2.
|
||||
- The MK01 gateway accepts one TCP client and a new one drops the old: each of our polls
|
||||
disconnects the Frisko phone app. While the Frisko app is still in use, poll rarely (as now,
|
||||
300 s) and keep each connection short.
|
||||
@@ -0,0 +1,18 @@
|
||||
# ADR-004: Mobile-first web UI before any native app
|
||||
|
||||
Status: accepted · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
The users are my parents, on their phones, away from home. A native app (React Native) or a
|
||||
WebView wrapper is possible later, but only once the backend works well.
|
||||
|
||||
## Decision
|
||||
|
||||
Build a mobile-first web UI, served by the backend, talking to the JSON API. Decide on native,
|
||||
WebView or web-only in M6, once real use shows what is missing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One API serves the web UI now and any app later.
|
||||
- Frontend technology is chosen in M3 with the same rule as ADR-001: the least code that works.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-005: InfluxDB for data, one local file for pending restores, no other database
|
||||
|
||||
Status: accepted · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
We store readings and energy data (time series), an audit log of writes (S3), and the state of
|
||||
running boosts: the value to restore and when (F3). InfluxDB 1.x already holds the history and
|
||||
feeds Grafana. It runs on my server, so the Pi loses it whenever the tunnel or DNS fails (D7).
|
||||
|
||||
## Decision
|
||||
|
||||
- **InfluxDB** holds all time series: heat pump readings and state, energy, and the audit log
|
||||
as events. New data goes into new measurements, so existing Grafana dashboards keep working.
|
||||
- **One small JSON file on the Pi** holds the running boosts (register, previous value, restore
|
||||
time), written atomically. A restore must work without the network.
|
||||
- Configuration (limits, presets, device addresses) is a file on the Pi, not data.
|
||||
- No other database. The command queue is in memory; a restart drops pending commands, and the
|
||||
UI shows them as failed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Nothing new to install or back up besides one file.
|
||||
- Audit history in the app depends on InfluxDB; the journal on the Pi keeps a local copy.
|
||||
- If we ever need users, many records or complex queries, revisit this decision.
|
||||
@@ -0,0 +1,40 @@
|
||||
# ADR-006: Thin gateway on the Pi, backend on the server in Kraków
|
||||
|
||||
Status: accepted · 2026-10-23 · supersedes ADR-002
|
||||
|
||||
## Context
|
||||
|
||||
The Pi is the weakest part (SD card, Debian 11 without support). My server in Kraków runs
|
||||
Proxmox and Docker next to InfluxDB. But the MK01 gateway takes one client at a time, and a boost
|
||||
must be restored on time even when the tunnel is down.
|
||||
|
||||
## Decision
|
||||
|
||||
**Gateway (Pi)**, the only client of the devices, and nothing else:
|
||||
- polls every device (heat pump, Deye, Fronius, house meter, BME280 on the Pi) at a fixed rate, writes the readings
|
||||
to InfluxDB with a local buffer, and serves the last snapshot (`GET /snapshot`);
|
||||
- accepts commands `register, value, minutes or until changed` (`POST /commands`) only from the
|
||||
backend (token and firewall), checks them itself (allowlist, min/max, rate limit), writes,
|
||||
reads back, reports `APPLIED` or `FAILED`;
|
||||
- keeps timed restores in a local file and runs them without the server.
|
||||
|
||||
Restore rules:
|
||||
- The value to restore is read **from the controller just before the first write**, never sent
|
||||
by the server.
|
||||
- A new boost on a register already boosted replaces the end time and keeps the original value.
|
||||
- At restore time, if the register no longer holds our value (someone changed it on the panel or
|
||||
in the Frisko app), the gateway leaves it and reports a conflict.
|
||||
- After a restart, overdue restores run first.
|
||||
|
||||
**Backend (Docker in Kraków)**: phone API, auth via Traefik, presets, notifications. Reads history
|
||||
from InfluxDB and the live snapshot from the gateway; sends commands only to the gateway.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The baseline code evolves into the gateway (ADR-001); the backend is a new, separate app.
|
||||
- Two deployables: a Docker image for the server, a jar for the Pi.
|
||||
- While the tunnel is down the app shows no live data, but readings are buffered and restores
|
||||
still happen on time.
|
||||
- `rpi-measurements` (house meter, BME280) moves into the gateway and is retired: one place for
|
||||
all devices. The Pi's OS stays as it is (Debian 11), by decision.
|
||||
- Reading Deye and Fronius from the Pi needs the subnet overlap with Kraków fixed first (D7).
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-007: Gateway stack: JDK first, our own small Modbus core
|
||||
|
||||
Status: accepted · 2026-10-23
|
||||
|
||||
## Context
|
||||
|
||||
The gateway talks to four kinds of devices: the heat pump (Modbus TCP, sign-magnitude), the
|
||||
house meter (Modbus TCP, FC4, float32), Deye (Modbus RTU inside Solarman V5 on port 8899) and
|
||||
Fronius (HTTP JSON), plus the BME280 on the Pi's I2C. No Java library speaks Solarman V5. The
|
||||
baseline uses modbus4j and influxdb-java; the latter pulls in most of the 10 MB jar.
|
||||
|
||||
## Decision
|
||||
|
||||
Java 21 (already on the Pi), Maven, a fat jar. As few dependencies as possible:
|
||||
|
||||
| Area | Choice |
|
||||
|---|---|
|
||||
| Modbus | Our own package: FC3/FC4/FC6 codec with exceptions, a TCP transport and a Solarman V5 transport (RTU frame, CRC16). Readable as protocol documentation, tested with frames recorded from our devices. modbus4j is removed. |
|
||||
| Reading | Explicit register blocks per device in the config; one request per block, strictly one at a time per device, with a pause (200–300 ms for the heat pump). No automatic batching, no async pipelining. Devices run in parallel, one virtual thread each. |
|
||||
| InfluxDB | JDK `HttpClient`, line protocol to `/write`, our own buffer kept in a file. influxdb-java is removed. |
|
||||
| HTTP API | JDK `com.sun.net.httpserver` with virtual threads. |
|
||||
| JSON, config | Jackson. |
|
||||
| Logs, tests | slf4j-simple; JUnit 5, Mockito. |
|
||||
| BME280 | The kernel's IIO driver (`dtoverlay=i2c-sensor,bme280,addr=0x76` in `/boot/config.txt`, approved; applied in M1 with a planned reboot) and plain file reads from `/sys/bus/iio`. No library. |
|
||||
|
||||
## Consequences
|
||||
|
||||
- About 250 lines of protocol code we own, instead of two libraries; a much smaller jar.
|
||||
- Fallback for Modbus if our core disappoints: j2mod (Apache 2.0).
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-008: Backend and web UI stack
|
||||
|
||||
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
|
||||
|
||||
## Context
|
||||
|
||||
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
|
||||
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
|
||||
the gateway already shows the stack is enough (ADR-007).
|
||||
|
||||
## Decision
|
||||
|
||||
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
|
||||
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
|
||||
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
|
||||
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
|
||||
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
|
||||
the contract between backend and UI.
|
||||
- **Web UI**: static HTML, CSS and plain JavaScript in `backend/src/main/resources/web/`, no
|
||||
build step, no npm. Charts and the schematic are small hand-written SVG. Polish texts.
|
||||
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
|
||||
`Dom_Pompa.POMPA` fields, which already hold years of data.
|
||||
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
|
||||
|
||||
## Consequences
|
||||
|
||||
- No JavaScript toolchain to install, update or audit; the UI is a few files a person can read.
|
||||
- Richer interactions later may need a library; revisit then (M6 decides native or not).
|
||||
- `scripts/check.sh` builds and tests both projects.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Frisko RX910-SE21: what we know
|
||||
|
||||
Sources: [research/frisko/](../../research/frisko/README.md). Facts here are verified on the device
|
||||
unless marked otherwise.
|
||||
|
||||
## Identity and protocol
|
||||
|
||||
- Heat pump controller **RX910-SE21**, firmware **v24062015**: a read of 4131–4133 gave 18/19/15
|
||||
(whole °C; firmware v10112017 uses tenths).
|
||||
- Behind the MK01 gateway (`192.168.0.150:502`, Modbus TCP → RTU 9600 8N1). One TCP client at a
|
||||
time; a new one drops the old.
|
||||
- FC 3 read, FC 6 write verified 2026-10-23 (4143 8 → 9: echo OK, read-back 9 after 1 s, no
|
||||
password needed); FC 16 per MR208-PC+ docs. Values are sign-magnitude
|
||||
integers; the baseline's "legacy wrap" decodes this. Addresses are raw (4131 is 4131).
|
||||
- `RX910-SE21-AdresyMODBUS.pdf` is the official map (~90 registers) and has no scales: take them
|
||||
from `RX910-SE21_v24062015.xml` (format, min/max). Scale is per register, not a global 0.1.
|
||||
- No SE21 manual for the control logic; `MR208-PC+_9P_v3.pdf` is the closest reference.
|
||||
- `frisko/mr208_m3p.csv` on `archive/spring-boot-attempt` is for MR208-M3+: do not use.
|
||||
|
||||
## Registers we rely on
|
||||
|
||||
| Group | Registers | Notes |
|
||||
|---|---|---|
|
||||
| Temperatures | 4065 outdoor, 4066 indoor, 4067 CO/buffer, 4069 CWU, 4070/4071 ground in/out, 4072 pump outlet, 4199 avg outdoor | X10 |
|
||||
| Targets now | 4201 room, 4202 CO supply, 4203 CWU | X10; show economy vs comfort period |
|
||||
| State | 4227 pump (0 stop, 1 CO, 2 CWU, 3 both), 4226 program, 4221 alarm, 4073 pressure switch | |
|
||||
| Outputs | 4112 CO, 4113 buffer, 4114 compressor (0/33/66/100), 4115 heater, 4116 circulation, 4117 ground, 4118 CWU | 0 or 100 |
|
||||
| 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.
|
||||
|
||||
## Weekly program
|
||||
|
||||
Behind a selector: write circuit (4177) and day (4167), then read or write the intervals
|
||||
(4169–4176). Reading it is therefore not read-only, and the selector is shared with the panel and
|
||||
the Frisko app. Party mode (4140 = 2, hours in 4142) is a simpler "heat now" override.
|
||||
|
||||
## Observed behaviour
|
||||
|
||||
- 2026-10-23 21:52: pump stop, weekly program, all outputs 0, targets 18.0 / 31.0 / 8.0 °C
|
||||
(economy period). 5 blocks, 36 registers in 2.5 s.
|
||||
- CWU is heated daily at ~11:00 and ~17:30 to ~44 °C (~15–20 °C/h, pump outlet ~47 °C).
|
||||
Slow morning rises with the pump off come from the solar collector.
|
||||
- The pump outlet tops out at ~52 °C (TmaxPc), so the pump alone reaches ~47–48 °C in the tank.
|
||||
Higher CWU values in history occur only in summer (solar).
|
||||
- Setting CWU above what the pump reaches risks the electric heater (after the delay) and
|
||||
pressure-switch trips; three trips in one cycle latch a fault until manual reset (MR208-PC+ docs).
|
||||
- Frisko phone app: direct Modbus TCP, LAN only, 200–300 ms between requests, 5 s timeout.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Roadmap
|
||||
|
||||
Bottom-up: each milestone builds on a verified one below it, and master stays deployable.
|
||||
Closed milestones shrink to one line here; details stay in git history and in decisions/.
|
||||
|
||||
## Done
|
||||
|
||||
- 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: M1 Gateway: all readings
|
||||
|
||||
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).
|
||||
|
||||
- [ ] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
|
||||
- [ ] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
|
||||
- [ ] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
|
||||
and the setpoints into `heat_pump`
|
||||
- [ ] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
|
||||
- [ ] Deye and Fronius into `electricity.deye`, `electricity.fronius`
|
||||
- [ ] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
|
||||
- [ ] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
|
||||
- [ ] HTTP: `/health` per device, `/snapshot`
|
||||
- [ ] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
baseline classes (D4, D5)
|
||||
- [ ] On the Pi, each step approved: D7, config file and unit, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, deploy, then retire it
|
||||
|
||||
## Next
|
||||
|
||||
| Milestone | Scope | Done when |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
|
||||
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
|
||||
- D3 Config is packaged inside the jar.
|
||||
- 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
|
||||
`10.0.0.0/24, 10.2.0.0/24` (Influx and Gitea go via the public IP anyway), plus outbound NAT on
|
||||
OPNsense's WireGuard interface so the Pi answers Kraków through the tunnel.
|
||||
|
||||
## Knowledge
|
||||
|
||||
- Controller: [docs/frisko/rx910-se21.md](frisko/rx910-se21.md)
|
||||
- Energy devices: [research/energy/](../research/energy/README.md)
|
||||
- UI reference: [research/ui/](../research/ui/README.md)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Specification
|
||||
|
||||
Stable description of what we build. The plan lives in [roadmap.md](roadmap.md), the reasons in
|
||||
[decisions/](decisions/).
|
||||
|
||||
## Goal
|
||||
|
||||
An app that helps my father run and understand the house in Krosno, from his phone, also away
|
||||
from home. The heat pump comes first: see what it does and change its few setpoints, replacing
|
||||
the outdated Frisko app. Right after the first heat pump view, the energy: PV, battery, grid and consumption, which my
|
||||
father wants a lot.
|
||||
Simpler, readable and better tested than Frisko. Grafana stays for long-term analysis; the app
|
||||
shows up to 7 days of history.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
- Heat pump: live state, history, four setpoints (boost, away), alarms.
|
||||
- Energy, read-only: PV production (Deye, Fronius), battery, grid import/export, house consumption.
|
||||
- Access from outside through my server; Polish UI for my parents.
|
||||
|
||||
Out, until decided otherwise:
|
||||
- Writing to the inverters or the battery, and controlling PV.
|
||||
- Editing the weekly program of the heat pump.
|
||||
- Cloud services (Solarman, Frisko Online) and the logger's push to a second server.
|
||||
- Replacing Grafana.
|
||||
|
||||
## Principles
|
||||
|
||||
1. The heating must keep working. Safety beats features.
|
||||
2. Understand a device first, then add features one at a time, bottom-up.
|
||||
3. Frisko is a source of knowledge about the controller, never of code.
|
||||
4. Code a person reads quickly: small classes, clear names, few dependencies.
|
||||
|
||||
## Context
|
||||
|
||||
| Component | Where | Notes |
|
||||
|---|---|---|
|
||||
| Heat pump controller Frisko RX910-SE21, fw v24062015 | behind the gateway | Ground source (boreholes); slow to respond; see [frisko/](frisko/rx910-se21.md) |
|
||||
| Gateway Frisko MK01 (DS1206/EM500) | `192.168.0.150:502` | Modbus TCP → RTU 9600; **one TCP client at a time, a new one drops the old** |
|
||||
| Solar thermal collector | roof | Also heats the CWU tank; not connected to anything we read |
|
||||
| Hybrid inverter Deye SUN-10K-SG04LP3-EU | logger `192.168.0.232:8899` | 10 kW, 3 phases, 48 V battery (backup only); most of the PV |
|
||||
| PV inverter Fronius Symo 3.7-3-M | `192.168.0.143` | 4.2 kWp; local Solar API (JSON) |
|
||||
| House meter SDM630 | RS485 gateway `192.168.0.10:502` | Whole house; read today by `rpi-measurements` into Influx `electricity`/`PRAD2`, moving into our gateway |
|
||||
| Solar-Log | `192.168.0.210` | Old monitoring, no data since April 2026 |
|
||||
| Raspberry Pi (`raspberrypi`) | `192.168.0.158` | Debian 11 (kept as is); runs the gateway (and `rpi-measurements` until the move); BME280 sensor; WireGuard `10.0.0.2` |
|
||||
| My server | Kraków | Proxmox and Docker: backend, Traefik, InfluxDB 1.x, Grafana, Gitea. Same subnet `192.168.0.0/24` as Krosno; WireGuard on OPNsense |
|
||||
|
||||
InfluxDB has no authentication; Traefik allows only Krosno LAN, home LAN and WireGuard.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
phone (web UI) ─HTTPS─▶ Traefik + auth ─▶ backend (Docker, Kraków) ─WireGuard─▶ gateway (Pi) ─▶ devices
|
||||
history in InfluxDB owns every device link
|
||||
```
|
||||
|
||||
- **Gateway** on the Pi ([ADR-006](decisions/006-gateway-on-the-pi.md)): the only client of the heat
|
||||
pump (MK01), Deye, Fronius and the house meter. Polls, writes readings to InfluxDB with a buffer,
|
||||
serves the snapshot, executes checked commands and timed restores, even without the tunnel.
|
||||
- **Backend** in Kraków: phone API, presets, notifications. Reads history from InfluxDB and the
|
||||
live snapshot from the gateway; sends commands only to the gateway.
|
||||
|
||||
Layers in both apps, dependencies point inwards only:
|
||||
|
||||
| Layer | Contains | Depends on |
|
||||
|---|---|---|
|
||||
| `domain` | readings, settings, commands, rules | nothing |
|
||||
| `application` | use cases, ports | `domain` |
|
||||
| `adapters` | gateway: Modbus, Solarman, Fronius, InfluxDB writer, HTTP; backend: gateway client, InfluxDB reader, HTTP | `application`, `domain` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Functional:
|
||||
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures.
|
||||
- F2 History for the last 7 days.
|
||||
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
|
||||
(4131, 4132). Any value within limits, or one tap on a named preset. Limits and presets live in
|
||||
the backend config: CWU 5–50 °C (pump maximum per my father; 48 °C proposed for margin, to
|
||||
decide in M2), CO 5–25 °C.
|
||||
- **Boost**: raise the economy temperature for a duration the user picks; the previous value
|
||||
is restored automatically.
|
||||
- **Away / not needed**: lower the comfort temperature (e.g. CWU 10 °C, CO 15 °C) until changed back.
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals.
|
||||
|
||||
Safety, for every write:
|
||||
|
||||
- S1 Only allowlisted registers, each with min, max and step.
|
||||
- S2 Read back and verify after writing.
|
||||
- S3 Audit log: who, when, register, old and new value.
|
||||
- S4 Rate limit per register.
|
||||
- S5 Tests and development never write to a real device or the production database.
|
||||
- S6 The gateway accepts commands only from the backend: a shared token, and a firewall rule on
|
||||
the Pi that allows only the backend's address.
|
||||
|
||||
Non-functional:
|
||||
|
||||
- N1 Reachable from outside only through Traefik with authentication.
|
||||
- N2 Recovers on its own from device, InfluxDB and tunnel outages.
|
||||
- N3 Existing Grafana dashboards keep working.
|
||||
- N4 Usable by my parents: large controls, few screens, Polish UI.
|
||||
@@ -92,6 +92,10 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<!-- Test logs go to target/surefire-reports; a failure still prints its summary. -->
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": false,
|
||||
"pollIntervalMs": 30000,
|
||||
"windowSeconds": 300,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"timeoutMs": 5000,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1000,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Temp wew",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Energy sources
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `deye-nameplate.jpg` | Nameplate of the hybrid inverter Deye SUN-10K-SG04LP3-EU, SN 2408266176 (photo 2026-10-23) |
|
||||
|
||||
## Deye hybrid inverter, read locally
|
||||
|
||||
Solarman Wi-Fi logger at `192.168.0.232:8899`, logger SN **3112107147** (sticker on the stick;
|
||||
the `PWD` on the sticker is the stick's own Wi-Fi hotspot password and is not needed).
|
||||
Protocol: Solarman V5 wrapping Modbus RTU, slave id 1, FC3 read only (`pysolarmanv5`).
|
||||
|
||||
Registers verified 2026-10-23 22:26 (serial 3–7 matched the nameplate, grid power matched the
|
||||
SDM630 house meter: 645 W vs 640 W):
|
||||
|
||||
| Register | Meaning | Scale | Value at check |
|
||||
|---|---|---|---|
|
||||
| 3–7 | inverter serial, ASCII | – | 2408266176 |
|
||||
| 586 | battery temperature | (x − 1000) / 10 °C | 19.2 |
|
||||
| 587 | battery voltage | /100 V | 56.49 |
|
||||
| 588 | battery SOC | % | 99 |
|
||||
| 590 | battery power, + discharge / − charge | W, signed | −24 |
|
||||
| 598–600 | grid voltage L1–L3 | /10 V | 233.9 / 233.4 / 231.9 |
|
||||
| 622–624, 625 | grid power per phase, total; + import / − export | W, signed | 390 / 52 / 203, 645 |
|
||||
| 650–653 | load power per phase, total | W | 364 / 26 / 180, 570 |
|
||||
| 672, 673 | PV1, PV2 power | W | 0, 0 (night) |
|
||||
| 514, 515 | today battery charge, discharge | /10 kWh | 0.5, 0.1 |
|
||||
| 520, 521 | today grid bought, sold | /10 kWh | 16.8, 22.3 |
|
||||
| 526 | today load | /10 kWh | 24.2 |
|
||||
| 529 | today PV production | /10 kWh | 29.9 |
|
||||
|
||||
The battery is configured as **backup only** (power cuts), on purpose: day and night the house
|
||||
runs on PV and the grid, so a full battery with ~0 W flow is the normal state.
|
||||
|
||||
Fronius Symo 3.7 (`192.168.0.143`, Solar API JSON) produced another 4.4 kWh that day.
|
||||
|
||||
Considered and rejected (2026-10-23): pointing the logger's second server ("Server B") at our
|
||||
backend. Push frames are poorly documented binary data every ~5 min, need a TCP server with
|
||||
acks, and a wrong setting could cut off the Solarman cloud my father uses. Local FC3 polling
|
||||
gives documented registers at our own rate. Server B stays empty; fallback only.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 MiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
Mapa pamięci sterownika MR208-PC+
|
||||
|
||||
|
||||
Komunikacja
|
||||
|
||||
MR208-PC+ posiada dwa porty komunikacyjne RS1 (RS232/RS485 wybierany na etapie
|
||||
zamawiania) oraz RS2 (zawsze RS485).
|
||||
|
||||
Regulator obsługuje protokół MODBUS RTU o parametrach transmisji:
|
||||
szybkość 9600b/s,
|
||||
format 8N1 (znak 8 bitów, brak kontroli parzystości, 1 bit stopu).
|
||||
|
||||
Dostęp do rejestrów realizowany jest funkcjami:
|
||||
3 - odczyt grupy rejestrów,
|
||||
6 - zapis pojedynczego rejestru,
|
||||
16 - zapis grupy rejestrów.
|
||||
|
||||
Mapa pamięci
|
||||
|
||||
Rejestry zapisane są w formacie moduł-znak jako liczby całkowite.
|
||||
Oznaczenia w opisie:
|
||||
X10 - wartość rejestru należy podzielić przez 10 aby otrzymać wartość parametru (np. wartość
|
||||
rejestru =1 oznacza 0.1, wartość =10 oznacza 1.0, wartość =100 oznacza 10.0 itd. ).
|
||||
X1 - wartość rejestru odpowiada wartości parametru,
|
||||
|
||||
|
||||
Pomiary
|
||||
Parametr Rejestr Zakres Opis
|
||||
Tzew 4248 -300÷950 X10
|
||||
Tzsr 4154 -300÷950 X10
|
||||
Twew 4065 -300÷950 X10
|
||||
ZdS 4243 -40÷40 X10
|
||||
ZadTwew 4150 0÷350 X10
|
||||
Tcwu_g 4069 -300÷950 X10
|
||||
Tcwu_d 4068 -300÷950 X10
|
||||
ZadTcwu 4121 0÷900 X10
|
||||
Tpodl 4066 -300÷950 X10
|
||||
Tbuf 4067 -300÷950 X10
|
||||
ZadTbuf(podl) 4158 0÷900 X10
|
||||
TzasPC 4072 -300÷950 X10
|
||||
TweDZ 4071 -300÷950 X10
|
||||
TwyDZ 4070 -300÷950 X10
|
||||
TsrDZ 4197 -300÷950 X10
|
||||
WePres 4073 0, 1 0-ROZWARTE, 1-ZWARTE
|
||||
WeFerie 4236 0, 1 0-ROZWARTE, 1-ZWARTE
|
||||
WeParty 4237 0, 1 0-ROZWARTE, 1-ZWARTE
|
||||
|
||||
|
||||
|
||||
Mapa pamięci sterownika MR208-PC+ 1/4
|
||||
Zegar i tryb
|
||||
Parametr Rejestr Zakres Opis
|
||||
Godzina 4002 0÷23 X1
|
||||
Minuta 4001 0÷59 X1
|
||||
Dzien 4006 0÷6 0-Pn. 1-Wt,…,6-Ni
|
||||
Tryb 4159 0÷2 0-Lato, 1-Zima, 2-Auto
|
||||
TprogZ/L 4160 5÷35 X1
|
||||
|
||||
|
||||
Nastawy CO
|
||||
Parametr Rejestr Zakres Opis
|
||||
TEko 4129 50÷350 X10
|
||||
TKmf 4130 50÷350 X10
|
||||
TFer 4225 50÷350 X10
|
||||
Krzywa 4131 0÷15 X1
|
||||
PrzesKrz 4238 -99÷999 X10
|
||||
RegPokoj 4183 0, 1 0-NIE, 1-TAK
|
||||
WspKor 4124 0÷9 X1
|
||||
TmaxCO 4125 5÷90 X1
|
||||
t_zwlBZC 4187 0÷999 X1
|
||||
t_minGrz 4173 1÷99 X1
|
||||
HistBUF 4163 1÷20 X1
|
||||
PriorCWU 4170 0, 1 0-NIE, 1-TAK
|
||||
AdrCzujnika 4244 11÷18 X1
|
||||
ZdS 4245 0÷3 0-Eko, 1-Kmf, 2-Kmf+Eko, 3-Wyl
|
||||
|
||||
|
||||
|
||||
Nastawy CWU
|
||||
Parametr Rejestr Zakres Opis
|
||||
TEko 4155 5÷70 X1
|
||||
TKmf 4156 5÷70 X1
|
||||
TrybBZC 4169 0, 1 0-Eko, 1-Kmf
|
||||
Dezynf. 4143 0, 1, 2 0-Wyl, 1-Zal, 2-Auto
|
||||
Dez.Dni 4229 0÷20 X1
|
||||
Dez.Czas 4228 0÷23 X1
|
||||
HistCWU 4144 1÷20 X1
|
||||
TgrzPC 4171 0÷99 X1
|
||||
∆Eko 4168 0÷99 X1
|
||||
t_wybPcw 4172 0÷999 X1
|
||||
t_minGrz 4174 1÷99 X1
|
||||
t_zalPccw 4179 0÷99 X1
|
||||
t_wylPccw 4180 0÷99 X1
|
||||
|
||||
|
||||
|
||||
|
||||
Mapa pamięci sterownika MR208-PC+ 2/4
|
||||
Programy
|
||||
Parametr Rejestr Zakres Opis
|
||||
Obwod 4114 0÷3 0-CO1, 1-TAR, 2-CCW, 3-CWU
|
||||
Dzien 4134 0÷6 0-Pn. 1-Wt,…,6-Ni - dla CO, CWU i CCW
|
||||
0, 5 0-Pn-Pt, 5-So-Ni - dla TAR
|
||||
Godz1 4135 0÷24 X1 – godzina początku 1 przedziału
|
||||
Minuta1 4139 0÷59 X1 – minuta początku 1 przedziału
|
||||
Godz2 4136 0÷24 X1 – godzina końca 1 przedziału
|
||||
Minuta2 4140 0÷59 X1 – minuta końca 1 przedziału
|
||||
Godz3 4137 0÷24 X1 – godzina początku 2 przedziału
|
||||
Minuta3 4141 0÷59 X1 – minuta początku 2 przedziału
|
||||
Godz4 4138 0÷24 X1 – godzina końca 2 przedziału
|
||||
Minuta4 4142 0÷59 X1 – minuta końca 2 przedziału
|
||||
Godz5 4230 0÷24 X1 – godzina początku 3 przedziału
|
||||
Minuta5 4235 0÷59 X1 – minuta początku 3 przedziału
|
||||
Godz6 4233 0÷24 X1 – godzina końca 3 przedziału
|
||||
Minuta6 4234 0÷59 X1 – minuta końca 3 przedziału
|
||||
Kopiuj do: 4133 0÷7 0-Pn. 1-Wt,…,6-Ni, 7-?
|
||||
Procedura zapisu/odczytu programu dobowego jest następująca:
|
||||
należy ustawić rejestry Obwod i Dzien,
|
||||
odczekać 500ms,
|
||||
dokonać zapisu/odczytu rejestrów Godz1...Minuta6.
|
||||
|
||||
|
||||
Parametry
|
||||
Parametr Rejestr Zakres Opis
|
||||
Prg 4123 0, 1, 2 0-Ferie, 1-Tygodniowy, 2-Party
|
||||
Dni 4191 0÷99 X1
|
||||
Godzin 4227 0÷99 X1
|
||||
|
||||
|
||||
Parametry PC
|
||||
Parametr Rejestr Zakres Opis
|
||||
TmaxPC 4162 0÷90 X1
|
||||
TminDZ 4188 -20÷20 X1
|
||||
∆TmaxDZ 4189 0÷99 X1
|
||||
t_blokSPR 4175 0÷99 X1
|
||||
t_zwlPC 4164 0÷999 X1
|
||||
t_wybDZ 4165 0÷999 X1
|
||||
|
||||
|
||||
Parametry TAR
|
||||
Parametr Rejestr Zakres Opis
|
||||
CzasTAR - godziny 4115 0÷3 X1
|
||||
CzasTAR - minuty 4116 0÷59 X1
|
||||
Tpodbicia 4120 0÷30 X1
|
||||
BZC.CO 4201 0, 1 0-NIE, 1-TAK
|
||||
BZC.CWU 4202 0, 1 0-NIE, 1-TAK
|
||||
|
||||
|
||||
Mapa pamięci sterownika MR208-PC+ 3/4
|
||||
Liczniki
|
||||
Parametr Rejestr Zakres Opis
|
||||
PC.CO 4203 0÷32000 X10
|
||||
PC.CWU 4204 0÷32000 X10
|
||||
PC 4205 0÷32000 X10
|
||||
|
||||
|
||||
Konfiguracja
|
||||
Parametr Rejestr Zakres Opis
|
||||
Układ 4212 0÷5 0-U1, 1-U2, 2-U3, 3-U4, 4-U5, 5-U6,
|
||||
CzujnikTwe1 4255 0, 1 0-NIE, 1-TAK
|
||||
BufCW 4122 0, 1 0-1czujnik, 1-2czujniki
|
||||
Sygnal 4242 0, 1 0-NIE, 1-TAK
|
||||
Modbus 4241 0, 1 0-SLAVE, 1-MASTER
|
||||
Adres 4192 0÷255 X1
|
||||
SygnalKom 4249 0÷99 0-NIE, 1-LED, 2-BUZER
|
||||
|
||||
|
||||
Diagnostyka
|
||||
Parametr Rejestr Zakres Opis
|
||||
Presostat 4186 0÷99 0-OK, 1-ERR
|
||||
Przegrzew 4250 0÷999 X1
|
||||
Przechlodz 4251 0÷999 X1
|
||||
|
||||
|
||||
Stan wyjsc
|
||||
Parametr Rejestr Zakres Opis
|
||||
Pdz 4216 0, 100 0-WYL, 100-ZAL
|
||||
Pcwu 4214 0, 100 0-WYL, 100-ZAL
|
||||
Silow.ZR 4100 0, 100 0-CWU, 100-CO
|
||||
BZC.CWU 4232 0, 100 0-WYL, 100-ZAL
|
||||
BZC.CO 4231 0, 100 0-WYL, 100-ZAL
|
||||
Pbuf 4100 0, 100 0-WYL, 100-ZAL
|
||||
Pzas 4214 0, 100 0-WYL, 100-ZAL
|
||||
Pco 4215 0, 100 0-WYL, 100-ZAL
|
||||
Sprez 4101 0, 100 0-WYL, 100-ZAL
|
||||
Pccw 4213 0, 100 0-WYL, 100-ZAL
|
||||
Alarm 4099 0, 100 0-WYL, 100-ZAL
|
||||
|
||||
|
||||
|
||||
|
||||
Mapa pamięci sterownika MR208-PC+ 4/4
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Frisko sources
|
||||
|
||||
Raw vendor material, kept as found. Read a file only when a task needs it; verified knowledge
|
||||
goes to `docs/frisko/`. `.txt` files are `pdftotext -layout` extracts of the PDF with the same name.
|
||||
|
||||
| File | What | Source |
|
||||
|---|---|---|
|
||||
| `RX910-SE21-AdresyMODBUS.pdf` | **Our controller's register map** (Frisko, 2017-04-10). Addresses only, no scales | from the manufacturer, years ago |
|
||||
| `RX910-SE21_v24062015.xml` | Phone-app screen definition for **our firmware**: names, formats, min/max, edit flags | `http://frisko.pl/XML4MobileApk/` |
|
||||
| `RX910-SE21_v10112017.xml` | Same for the newer firmware (CO setpoints 4131–4133 in tenths) | same |
|
||||
| `MR208-PC+_v6_AdresyMODBUS.pdf` | Register map of the current Frisko heat-pump controller: protocol, X10/X1 scales, sign-magnitude format | frisko24.pl product page |
|
||||
| `MR208-PC+_9P_v3.pdf` | MR208-PC+ manual (2025): closest available description of heat-pump control logic | frisko24.pl product page |
|
||||
| `settings-backup-2026-10-23.json` | Our controller's 14 app-editable settings, read just before the first write test |
|
||||
| `FriskoOnline2025.apk` | Frisko phone app 1.4 (`com.frisko.frisko_mobile`), direct Modbus TCP | `http://frisko.pl/XML4MobileApk/` |
|
||||
|
||||
Retrieved 2026-10-23. Decompile the APK with `jadx` when needed; the decompiled code is not kept.
|
||||
Binary file not shown.
@@ -0,0 +1,175 @@
|
||||
Mapa pamięci sterownika RX910-SE21
|
||||
|
||||
|
||||
|
||||
|
||||
Główny
|
||||
Parametr Rejestr Opis
|
||||
DzienTyg 4006
|
||||
Godziny 4002
|
||||
Minuty 4001
|
||||
TrybLZ 4121 0 – Lato,
|
||||
1 – Zimo.
|
||||
TrypRA 4122 0 – R,
|
||||
1 – A,
|
||||
2–*
|
||||
StanCO/CW 4227 0 – pc STOP,
|
||||
1 – pcCO,
|
||||
2 – pcCW,
|
||||
3 – pcCO/CW.
|
||||
StanZB 4228 0–
|
||||
2 – zbCW,
|
||||
Instalacja 4221 0 – Instal sprawna,
|
||||
1 – Awaria instal.
|
||||
Status 4226 0 – Ferie,
|
||||
1 – Tygodn,
|
||||
2 – Party,
|
||||
3 – ROZRUCH,
|
||||
4 – STOP,
|
||||
5 – Dezynf.
|
||||
|
||||
|
||||
Menu
|
||||
Parametr Rejestr Opis
|
||||
Tryb 4125 0 - STOP, 1 - PRACA
|
||||
Uklad 4126
|
||||
|
||||
|
||||
Temperatury
|
||||
Parametr Rejestr Opis
|
||||
Tzew 4065
|
||||
TdzWe 4070
|
||||
TdzWy 4071
|
||||
TpcWy 4072
|
||||
Tco 4067
|
||||
ZadTco 4202
|
||||
Tcwu 4069
|
||||
ZadTcwu 4203
|
||||
Twew 4066
|
||||
ZadTwew 4201
|
||||
Tzsr 4199
|
||||
Presostat 4073 0 – OK, 1 - Err
|
||||
Nastawy CO
|
||||
Parametr Rejestr Opis
|
||||
T.Eko 4131
|
||||
T.Kmf 4132
|
||||
T.Fer 4133
|
||||
Krzywa 4134
|
||||
Prg 4140 0-Ferie, 1-Tygodniowy, 2-Party
|
||||
Ferie.Dni 4141
|
||||
Party.Godzin 4142
|
||||
T.Eko_CH 4244
|
||||
T.Kmf_CH 4245
|
||||
|
||||
|
||||
Nastawy CWU
|
||||
Parametr Rejestr Opis
|
||||
T.Eko 4143
|
||||
T.Kmf 4144
|
||||
Dezynf 4145 0-WYL, -ZAL, 2-AUTO
|
||||
|
||||
|
||||
Programy
|
||||
Parametr Rejestr Opis
|
||||
Program 4177 0-CO, 1-CWU, 2-CCW, 3-TAR
|
||||
Dzien 4167 0-Pn. 1-Wt,…,6-Ni
|
||||
Copy 4168 0-Pn. 1-Wt,…,6-Ni, 7-?
|
||||
Godz1 4169
|
||||
Godz2 4170
|
||||
Godz3 4171
|
||||
Godz4 4172
|
||||
Minuty1 4173
|
||||
Minuty2 4174
|
||||
Minuty3 4175
|
||||
Minuty4 4176
|
||||
Aby wyświetlić albo edytować dany program należy najpierw wybrać Program oraz Dzien.
|
||||
|
||||
|
||||
Zegar i tryb
|
||||
Parametr Rejestr Opis
|
||||
Godzina 4002
|
||||
Minuta 4001
|
||||
Dzien 4006 0-Pn. 1-Wt,…,6-Ni
|
||||
Tryb 4123 0-Lato, 1-Zima, 2-Auto
|
||||
TprogZ/L 4124
|
||||
|
||||
|
||||
Liczniki
|
||||
Parametr Rejestr Opis
|
||||
Czas pracy PC 4190 MM - minuty
|
||||
tylko dla CO 4191 LLLL - młodsza część
|
||||
(HHLLLL:MM) 4192 HH - starsza część
|
||||
Czas pracy PC 4193 MM - minuty
|
||||
tylko dla CWU 4194 LLLL - młodsza część
|
||||
(HHLLLL:MM) 4195 HH - starsza część
|
||||
Czas pracy PC 4196 MM - minuty
|
||||
dla CO i CWU 4197 LLLL - młodsza część
|
||||
(HHLLLL:MM) 4198 HH - starsza część
|
||||
Parametry CO
|
||||
Parametr Rejestr Opis
|
||||
TmaxCO 4135
|
||||
RegPok 4136 0- NIE, 1-TAK
|
||||
WspKor 4137
|
||||
TpodbCO 4138
|
||||
t_podbCO 4139
|
||||
|
||||
|
||||
Parametry CWU
|
||||
Parametr Rejestr Opis
|
||||
PriorCWU 4146 0-WYL, 1-ZAL
|
||||
t_zwlGrz 4147
|
||||
t_zalCCW 4155
|
||||
t_wylCCW 4156
|
||||
HistCWU 4148
|
||||
HistGrz 4246
|
||||
TmaxCWU 4149
|
||||
TpodbCWU 4150
|
||||
t_podbCWU 4151
|
||||
Dez.Dni 4152
|
||||
DezTemp 4153
|
||||
Dez.t_max 4154
|
||||
|
||||
|
||||
Parametry
|
||||
Parametr Rejestr Opis
|
||||
TminDz 4157
|
||||
TmaxPc 4158
|
||||
∆TmaxDz 4159
|
||||
t_zwlZalSpr 4160
|
||||
t_zwlWylPdz 4161
|
||||
t_wylSpr 4162
|
||||
t_minSpr 4163
|
||||
t_maxSpr 4164
|
||||
zwlZSt(..) 4165
|
||||
zwlZSt(II) 4166
|
||||
zwlWst(..) 4247
|
||||
zwlWst(II) 4248
|
||||
|
||||
|
||||
Konfiguracja
|
||||
Parametr Rejestr Opis
|
||||
Sygnal 4127 0-NIE, 1-TAK
|
||||
Adres 4128
|
||||
HasloLAN 4129
|
||||
|
||||
|
||||
Stan wyjsc
|
||||
Parametr Rejestr Opis
|
||||
Pompa Dz 4117 0-WYL, 100-ZAL
|
||||
Pompa BUF 4113 0-WYL, 100-ZAL
|
||||
Pompa CO 4112 0-WYL, 100-ZAL
|
||||
Pompa CWU 4118 0-WYL, 100-ZAL
|
||||
Pompa CCW 4116 0-WYL, 100-ZAL
|
||||
Sprezarka 4114 0-WYL, 33-1st, 66-2st, 100-3st
|
||||
GrzalkaCWU 4115 0-WYL, 100-ZAL
|
||||
WyCHL 4119 0-WYL, 100-ZAL
|
||||
Kalibracja
|
||||
Parametr Rejestr Opis
|
||||
Tzew 4084
|
||||
TdWe 4089
|
||||
TdWy 4090
|
||||
TpWy 4091
|
||||
Tco 4086
|
||||
Tcwu 4088
|
||||
Twew 4085
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<sterownik>
|
||||
<ster_nazwa>RX910-SE21_v10112017</ster_nazwa>
|
||||
<ster_format>MZ</ster_format>
|
||||
<menu>
|
||||
<menu_nazwa>Status</menu_nazwa>
|
||||
<menu_icon>status.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4006</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>time</param_typ>
|
||||
<param_nazwa>Czas</param_nazwa>
|
||||
<param_adres_godz>4002</param_adres_godz>
|
||||
<param_adres_min>4001</param_adres_min>
|
||||
<param_edycja>false</param_edycja>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb</param_nazwa>
|
||||
<param_adres>4121</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>tryb_lato.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_zima.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Program pracy</param_nazwa>
|
||||
<param_adres>4226</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>program_ferie.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_tygodniowy.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_party.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>praca.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>stop.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dezynf.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list</param_typ>
|
||||
<param_nazwa>Status</param_nazwa>
|
||||
<param_adres>4011</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_val>0</param_val>
|
||||
<param_text>OK</param_text>
|
||||
<param_val>2</param_val>
|
||||
<param_text>Err_Tzew</param_text>
|
||||
<param_val>3</param_val>
|
||||
<param_text>Err_Tco</param_text>
|
||||
<param_val>4</param_val>
|
||||
<param_text>Err_Twew</param_text>
|
||||
<param_val>5</param_val>
|
||||
<param_text>Err_Tcwu</param_text>
|
||||
<param_val>14</param_val>
|
||||
<param_text>Err_Tpc</param_text>
|
||||
<param_val>15</param_val>
|
||||
<param_text>Err_Tdz</param_text>
|
||||
<param_val>1003</param_val>
|
||||
<param_text>Err_Presostat</param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Temperatury</menu_nazwa>
|
||||
<menu_icon>temperatura.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp zewnętrzna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4065</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Śr temp zewnętrzna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4199</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wej. DZ</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4070</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wyj. DZ</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4071</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wyj. PC</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4072</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wewnętrzna CO</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4066</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp CO</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4067</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp CWU</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4069</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Presostat</param_nazwa>
|
||||
<param_adres>4073</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>stan_ok.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>stan_awaria.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Zegar i tryb</menu_nazwa>
|
||||
<menu_icon>zegar_tryb.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb pracy</param_nazwa>
|
||||
<param_adres>4125</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>stop.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>praca.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>time</param_typ>
|
||||
<param_nazwa>Czas</param_nazwa>
|
||||
<param_adres_godz>4002</param_adres_godz>
|
||||
<param_adres_min>4001</param_adres_min>
|
||||
<param_edycja>true</param_edycja>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4006</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb</param_nazwa>
|
||||
<param_adres>4123</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>tryb_lato.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_zima.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_auto.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp progu Z/L</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4124</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>35</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Program</menu_nazwa>
|
||||
<menu_icon>program.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Obwód</param_nazwa>
|
||||
<param_adres>4177</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>to_co1.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_cwu.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_ccw.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_taryfa_pradu.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4167</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>prg</param_typ>
|
||||
<param_nazwa>Przedział 1</param_nazwa>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_adres_godz1>4169</param_adres_godz1>
|
||||
<param_adres_min1>4173</param_adres_min1>
|
||||
<param_adres_godz2>4170</param_adres_godz2>
|
||||
<param_adres_min2>4174</param_adres_min2>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>prg</param_typ>
|
||||
<param_nazwa>Przedział 2</param_nazwa>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_adres_godz1>4171</param_adres_godz1>
|
||||
<param_adres_min1>4175</param_adres_min1>
|
||||
<param_adres_godz2>4172</param_adres_godz2>
|
||||
<param_adres_min2>4176</param_adres_min2>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Kopiuj do</param_nazwa>
|
||||
<param_adres>4168</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_brak.png</param_icon>
|
||||
<param_val>7</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Nastawy CO</menu_nazwa>
|
||||
<menu_icon>nastawy_co1.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ekonomiczna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4131</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX.X</param_format>
|
||||
<param_min>50</param_min>
|
||||
<param_max>350</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp komfortowa</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4132</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX.X</param_format>
|
||||
<param_min>50</param_min>
|
||||
<param_max>350</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ferie</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4133</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX.X</param_format>
|
||||
<param_min>50</param_min>
|
||||
<param_max>350</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Krzywa</param_nazwa>
|
||||
<param_adres>4134</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>15</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Program pracy</param_nazwa>
|
||||
<param_adres>4140</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>program_ferie.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_tygodniowy.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_party.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Ferie dni</param_nazwa>
|
||||
<param_adres>4141</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>90</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Party godziny</param_nazwa>
|
||||
<param_adres>4142</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>90</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Nastawy CWU</menu_nazwa>
|
||||
<menu_icon>nastawy_cwu.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ekonomiczna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4143</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>70</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp komfortowa</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4144</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>70</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dezynfekcja</param_nazwa>
|
||||
<param_adres>4145</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>fun_wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>fun_zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>ctr_auto.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Liczniki</menu_nazwa>
|
||||
<menu_icon>liczniki.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CO</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4192</param_adres_1>
|
||||
<param_adres_2>4191</param_adres_2>
|
||||
<param_adres_3>4190</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CWU</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4195</param_adres_1>
|
||||
<param_adres_2>4194</param_adres_2>
|
||||
<param_adres_3>4193</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CO i CWU</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4198</param_adres_1>
|
||||
<param_adres_2>4197</param_adres_2>
|
||||
<param_adres_3>4196</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Stan wyjść</menu_nazwa>
|
||||
<menu_icon>stan_wyjsc.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa DZ</param_nazwa>
|
||||
<param_adres>4117</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa BUf</param_nazwa>
|
||||
<param_adres>4113</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CO</param_nazwa>
|
||||
<param_adres>4112</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CWU</param_nazwa>
|
||||
<param_adres>4118</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CCW</param_nazwa>
|
||||
<param_adres>4116</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Sprężarka</param_nazwa>
|
||||
<param_adres>4114</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_1st.png</param_icon>
|
||||
<param_val>33</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_2st.png</param_icon>
|
||||
<param_val>66</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_3st.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Grzałka CWU</param_nazwa>
|
||||
<param_adres>4115</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
</sterownik>
|
||||
@@ -0,0 +1,633 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<sterownik>
|
||||
<ster_nazwa>RX910-SE21_v24062015</ster_nazwa>
|
||||
<ster_format>MZ</ster_format>
|
||||
<menu>
|
||||
<menu_nazwa>Status</menu_nazwa>
|
||||
<menu_icon>status.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4006</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>time</param_typ>
|
||||
<param_nazwa>Czas</param_nazwa>
|
||||
<param_adres_godz>4002</param_adres_godz>
|
||||
<param_adres_min>4001</param_adres_min>
|
||||
<param_edycja>false</param_edycja>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb</param_nazwa>
|
||||
<param_adres>4121</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>tryb_lato.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_zima.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Program pracy</param_nazwa>
|
||||
<param_adres>4226</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>program_ferie.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_tygodniowy.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_party.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>praca.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>stop.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dezynf.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list</param_typ>
|
||||
<param_nazwa>Status</param_nazwa>
|
||||
<param_adres>4011</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_val>0</param_val>
|
||||
<param_text>OK</param_text>
|
||||
<param_val>2</param_val>
|
||||
<param_text>Err_Tzew</param_text>
|
||||
<param_val>3</param_val>
|
||||
<param_text>Err_Tco</param_text>
|
||||
<param_val>4</param_val>
|
||||
<param_text>Err_Twew</param_text>
|
||||
<param_val>5</param_val>
|
||||
<param_text>Err_Tcwu</param_text>
|
||||
<param_val>14</param_val>
|
||||
<param_text>Err_Tpc</param_text>
|
||||
<param_val>15</param_val>
|
||||
<param_text>Err_Tdz</param_text>
|
||||
<param_val>1003</param_val>
|
||||
<param_text>Err_Presostat</param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Temperatury</menu_nazwa>
|
||||
<menu_icon>temperatura.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp zewnętrzna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4065</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Śr temp zewnętrzna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4199</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wej. DZ</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4070</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wyj. DZ</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4071</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wyj. PC</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4072</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp wewnętrzna CO</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4066</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp CO</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4067</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp CWU</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4069</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_format>XXX.X</param_format>
|
||||
<param_min>-300</param_min>
|
||||
<param_max>950</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Presostat</param_nazwa>
|
||||
<param_adres>4073</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>stan_ok.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>stan_awaria.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Zegar i tryb</menu_nazwa>
|
||||
<menu_icon>zegar_tryb.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb pracy</param_nazwa>
|
||||
<param_adres>4125</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>stop.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>praca.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>time</param_typ>
|
||||
<param_nazwa>Czas</param_nazwa>
|
||||
<param_adres_godz>4002</param_adres_godz>
|
||||
<param_adres_min>4001</param_adres_min>
|
||||
<param_edycja>true</param_edycja>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4006</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Tryb</param_nazwa>
|
||||
<param_adres>4123</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>tryb_lato.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_zima.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>tryb_auto.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp progu Z/L</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4124</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>35</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Program</menu_nazwa>
|
||||
<menu_icon>program.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Obwód</param_nazwa>
|
||||
<param_adres>4177</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>to_co1.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_cwu.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_ccw.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>to_taryfa_pradu.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dzień</param_nazwa>
|
||||
<param_adres>4167</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>prg</param_typ>
|
||||
<param_nazwa>Przedział 1</param_nazwa>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_adres_godz1>4169</param_adres_godz1>
|
||||
<param_adres_min1>4173</param_adres_min1>
|
||||
<param_adres_godz2>4170</param_adres_godz2>
|
||||
<param_adres_min2>4174</param_adres_min2>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>prg</param_typ>
|
||||
<param_nazwa>Przedział 2</param_nazwa>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_adres_godz1>4171</param_adres_godz1>
|
||||
<param_adres_min1>4175</param_adres_min1>
|
||||
<param_adres_godz2>4172</param_adres_godz2>
|
||||
<param_adres_min2>4176</param_adres_min2>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Kopiuj do</param_nazwa>
|
||||
<param_adres>4168</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>dt_pn.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_wt.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_sr.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_cz.png</param_icon>
|
||||
<param_val>3</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_pt.png</param_icon>
|
||||
<param_val>4</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_so.png</param_icon>
|
||||
<param_val>5</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_ni.png</param_icon>
|
||||
<param_val>6</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>dt_brak.png</param_icon>
|
||||
<param_val>7</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Nastawy CO</menu_nazwa>
|
||||
<menu_icon>nastawy_co1.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ekonomiczna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4131</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>35</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp komfortowa</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4132</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>35</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ferie</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4133</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>35</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Krzywa</param_nazwa>
|
||||
<param_adres>4134</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>15</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Program pracy</param_nazwa>
|
||||
<param_adres>4140</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>program_ferie.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_tygodniowy.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>program_party.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Ferie dni</param_nazwa>
|
||||
<param_adres>4141</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>90</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Party godziny</param_nazwa>
|
||||
<param_adres>4142</param_adres>
|
||||
<param_jedn></param_jedn>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>0</param_min>
|
||||
<param_max>90</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Nastawy CWU</menu_nazwa>
|
||||
<menu_icon>nastawy_cwu.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp ekonomiczna</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4143</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>70</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>val</param_typ>
|
||||
<param_nazwa>Temp komfortowa</param_nazwa>
|
||||
<param_jedn>°C</param_jedn>
|
||||
<param_adres>4144</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_format>XX</param_format>
|
||||
<param_min>5</param_min>
|
||||
<param_max>70</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Dezynfekcja</param_nazwa>
|
||||
<param_adres>4145</param_adres>
|
||||
<param_edycja>true</param_edycja>
|
||||
<param_icon>fun_wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>fun_zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>ctr_auto.png</param_icon>
|
||||
<param_val>2</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Liczniki</menu_nazwa>
|
||||
<menu_icon>liczniki.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CO</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4192</param_adres_1>
|
||||
<param_adres_2>4191</param_adres_2>
|
||||
<param_adres_3>4190</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CWU</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4195</param_adres_1>
|
||||
<param_adres_2>4194</param_adres_2>
|
||||
<param_adres_3>4193</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>big_val</param_typ>
|
||||
<param_nazwa>Czas pracy na CO i CWU</param_nazwa>
|
||||
<param_jedn></param_jedn>
|
||||
<param_adres_1>4198</param_adres_1>
|
||||
<param_adres_2>4197</param_adres_2>
|
||||
<param_adres_3>4196</param_adres_3>
|
||||
<param_min>0</param_min>
|
||||
<param_max>99999959</param_max>
|
||||
</parametry>
|
||||
</menu>
|
||||
<menu>
|
||||
<menu_nazwa>Stan wyjść</menu_nazwa>
|
||||
<menu_icon>stan_wyjsc.png</menu_icon>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa DZ</param_nazwa>
|
||||
<param_adres>4117</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa BUf</param_nazwa>
|
||||
<param_adres>4113</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CO</param_nazwa>
|
||||
<param_adres>4112</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CWU</param_nazwa>
|
||||
<param_adres>4118</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Pompa CCW</param_nazwa>
|
||||
<param_adres>4116</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Sprężarka</param_nazwa>
|
||||
<param_adres>4114</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_1st.png</param_icon>
|
||||
<param_val>33</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_2st.png</param_icon>
|
||||
<param_val>66</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal_3st.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
<parametry>
|
||||
<param_typ>list_ico</param_typ>
|
||||
<param_nazwa>Grzałka CWU</param_nazwa>
|
||||
<param_adres>4115</param_adres>
|
||||
<param_edycja>false</param_edycja>
|
||||
<param_icon>wyl.png</param_icon>
|
||||
<param_val>0</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>1</param_val>
|
||||
<param_text></param_text>
|
||||
<param_icon>zal.png</param_icon>
|
||||
<param_val>100</param_val>
|
||||
<param_text></param_text>
|
||||
</parametry>
|
||||
</menu>
|
||||
</sterownik>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"taken": "2026-10-23T23:23:03+0200",
|
||||
"registers": {
|
||||
"4006": {
|
||||
"name": "Dzień",
|
||||
"value": 4
|
||||
},
|
||||
"4123": {
|
||||
"name": "Tryb",
|
||||
"value": 2
|
||||
},
|
||||
"4124": {
|
||||
"name": "Temp progu Z/L",
|
||||
"value": 18
|
||||
},
|
||||
"4125": {
|
||||
"name": "Tryb pracy",
|
||||
"value": 1
|
||||
},
|
||||
"4131": {
|
||||
"name": "Temp ekonomiczna",
|
||||
"value": 18
|
||||
},
|
||||
"4132": {
|
||||
"name": "Temp komfortowa",
|
||||
"value": 19
|
||||
},
|
||||
"4133": {
|
||||
"name": "Temp ferie",
|
||||
"value": 15
|
||||
},
|
||||
"4134": {
|
||||
"name": "Krzywa",
|
||||
"value": 6
|
||||
},
|
||||
"4140": {
|
||||
"name": "Program pracy",
|
||||
"value": 1
|
||||
},
|
||||
"4141": {
|
||||
"name": "Ferie dni",
|
||||
"value": 0
|
||||
},
|
||||
"4142": {
|
||||
"name": "Party godziny",
|
||||
"value": 0
|
||||
},
|
||||
"4143": {
|
||||
"name": "Temp ekonomiczna",
|
||||
"value": 8
|
||||
},
|
||||
"4144": {
|
||||
"name": "Temp komfortowa",
|
||||
"value": 43
|
||||
},
|
||||
"4145": {
|
||||
"name": "Dezynfekcja",
|
||||
"value": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user