Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e28a25c22 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: ship
|
||||
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally release it and update the Pi. Use when a change is ready to merge or release.
|
||||
description: Take the current topic branch to master through a Gitea PR with green CI, then optionally deploy the gateway to the Pi. Use when a change is ready to merge or release.
|
||||
---
|
||||
|
||||
# Ship
|
||||
@@ -23,9 +23,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
||||
`git switch master && git pull --ff-only && git merge --no-ff <branch> -m "Merge PR #N: <title>"`,
|
||||
then `git push origin master` and delete the branch locally and on origin. Confirm CI is
|
||||
green on master. Gitea does not close an AGit PR merged by hand: ask the user to close it.
|
||||
6. Release and update only with the user's explicit approval for this one:
|
||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) tags master;
|
||||
wait until the Release run under `…/actions` is green (it publishes the images). Then on the
|
||||
Pi (`ssh <host>`, an alias while D7 is open): `cd /home/pi/home-pump && ./update.sh <version>`,
|
||||
watch `journalctl -t home-pump-gateway -f` and Grafana for one polling cycle (300 s) and
|
||||
report what you saw. Backend and frontend reach Kraków through Arcane (homelab-compose).
|
||||
6. Deploy only with the user's explicit approval for this deploy, and only for changes that
|
||||
touch the gateway: `scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor
|
||||
for features; `PI=<ssh host>` while D7 is open). Then watch
|
||||
`ssh <host> journalctl -u home-pump.service -f`
|
||||
and Grafana for one polling cycle (300 s) and report what you saw.
|
||||
@@ -6,14 +6,20 @@ on:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check (gateway, backend, frontend)
|
||||
backend:
|
||||
name: Backend tests (Maven / Java 21)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Java 21
|
||||
|
||||
- name: Show runner info
|
||||
run: |
|
||||
echo "Runner name: ${{ runner.name }}"
|
||||
echo "Runner labels: ${{ toJSON(runner.labels) }}"
|
||||
|
||||
- name: Set up Java 21 + Maven
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -22,9 +28,5 @@ jobs:
|
||||
uses: stCarolas/setup-maven@v5
|
||||
with:
|
||||
maven-version: 3.9.6
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Check
|
||||
run: scripts/check.sh
|
||||
@@ -1,56 +0,0 @@
|
||||
# A pushed tag vMAJOR.MINOR.PATCH (scripts/release.sh) publishes the three images under that name
|
||||
# and as latest. The Pi pulls the gateway with gateway/update.sh; Kraków runs backend and frontend.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
env:
|
||||
REGISTRY: git.mariuszpacek.pl
|
||||
IMAGES: git.mariuszpacek.pl/mariusz.pacek/home-pump
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# As in homelab-upgrade: the job talks to VM103's daemon through the mounted socket.
|
||||
- name: Docker CLI
|
||||
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# The owner's token with package write access (the secret homelab-upgrade uses too).
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# The Pi is arm64. Each Dockerfile builds on the runner's platform and only copies into the
|
||||
# final stage, so the arm64 image needs no emulation.
|
||||
- name: Gateway
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-gateway:${{ gitea.ref_name }},${{ env.IMAGES }}-gateway:latest
|
||||
- name: Backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-backend:${{ gitea.ref_name }},${{ env.IMAGES }}-backend:latest
|
||||
- name: Frontend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: frontend
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ env.IMAGES }}-frontend:${{ gitea.ref_name }},${{ env.IMAGES }}-frontend:latest
|
||||
+2
-1
@@ -18,7 +18,7 @@ target/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
/.java-version
|
||||
dependency-reduced-pom.xml
|
||||
/dependency-reduced-pom.xml
|
||||
/workspace.code-workspace
|
||||
/target/
|
||||
|
||||
@@ -26,6 +26,7 @@ dependency-reduced-pom.xml
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.env*
|
||||
/dist/
|
||||
|
||||
# Vite
|
||||
|
||||
@@ -6,9 +6,8 @@ Backend for controlling the heat pump in Krosno (Frisko controller, Modbus TCP)
|
||||
|
||||
- Start from `docs/roadmap.md`; stay within the current milestone unless asked.
|
||||
- Read `docs/spec.md` and a decision in `docs/decisions/` only when the task touches it.
|
||||
- Production: `pi@raspberrypi.local`, the gateway in Docker (`gateway/compose.yaml`; until it is
|
||||
installed, the old `home-pump.service`). Read-only inspection is fine; installing, updating,
|
||||
restarting or changing files on the Pi needs explicit approval each time.
|
||||
- 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.
|
||||
|
||||
+5
-7
@@ -19,8 +19,7 @@ Reasons: [docs/decisions/](docs/decisions/).
|
||||
|
||||
## Code
|
||||
|
||||
- Three parts, each with its `Dockerfile` and `compose.yaml` (ADR-010): `gateway/` and `backend/`
|
||||
in Java 21 with Maven and no framework (ADR-001), `frontend/` in React and TypeScript (ADR-008).
|
||||
- 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.
|
||||
@@ -55,8 +54,7 @@ Docs are written so an agent needs little context: read only what the task touch
|
||||
|
||||
## Configuration, secrets, releases
|
||||
|
||||
- No secrets in git. Production config lives on the Pi and next to the Kraków stacks, outside
|
||||
the images.
|
||||
- Release only from `master` with `scripts/release.sh vMAJOR.MINOR.PATCH`: it tags master and
|
||||
CI publishes the three images. On the Pi `./update.sh <version>` goes back if it is unhealthy.
|
||||
- After an update, watch the journal and Grafana for at least one polling cycle.
|
||||
- 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.
|
||||
@@ -7,19 +7,29 @@ Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a ph
|
||||
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
|
||||
|
||||
| Part | Runs | Readme |
|
||||
|---|---|---|
|
||||
| `gateway/` | Pi in Krosno, Docker: devices to InfluxDB, `/commands` | [gateway/README.md](gateway/README.md) |
|
||||
| `backend/` | Kraków, Docker: phone API | [backend/README.md](backend/README.md) |
|
||||
| `frontend/` | Kraków, Docker: web UI with nginx | [frontend/README.md](frontend/README.md) |
|
||||
|
||||
Each part has its `Dockerfile` and `compose.yaml` (ADR-010).
|
||||
|
||||
```bash
|
||||
scripts/check.sh # docs, secret scan, build and tests of all three parts
|
||||
git config core.hooksPath .githooks # once per clone: check before every push
|
||||
scripts/release.sh vMAJOR.MINOR.PATCH # tag master; CI publishes the three images in Gitea
|
||||
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)
|
||||
```
|
||||
|
||||
After a release: on the Pi `./update.sh <version>` (gateway/README.md); in Kraków Arcane runs the
|
||||
backend and frontend images.
|
||||
## Backend (phone API and web UI)
|
||||
|
||||
Settings are environment variables; the backend refuses to start without the two URLs.
|
||||
|
||||
| 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
|
||||
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
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
target
|
||||
+8
-11
@@ -1,19 +1,16 @@
|
||||
# Built on the build machine's own platform; the image only holds the jar and a JRE.
|
||||
FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21 AS build
|
||||
WORKDIR /src
|
||||
COPY pom.xml .
|
||||
RUN mvn -B -q dependency:go-offline
|
||||
COPY src src
|
||||
# Tests run in scripts/check.sh; some read contract files from gateway/, outside this context.
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
# Build the jar first: mvn -f backend/pom.xml clean package
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
USER 10001:10001
|
||||
|
||||
RUN adduser -D -u 10001 app
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
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"]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Backend
|
||||
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
Setpoint changes go only to the gateway; the user name from Traefik's `Remote-User` header (else `phone`) is sent as `X-Requested-By`.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
|
||||
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
|
||||
| `PORT` | `8080` | listening port |
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
| `GATEWAY_TOKEN` | unset | shared token for the gateway's `/commands`; without it the backend runs but `/api/setpoints` and `/api/presets/{id}` answer 503 |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # API at http://localhost:8080/api/
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
@@ -1,16 +0,0 @@
|
||||
# The backend in Kraków: the API only (ADR-010), reached by the frontend on the network home-pump
|
||||
# and published on no host port (ADR-009). Settings in .env next to this file (README).
|
||||
name: home-pump-backend
|
||||
services:
|
||||
backend:
|
||||
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-backend:${VERSION:-latest}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: Europe/Warsaw
|
||||
networks: [home-pump]
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
name: home-pump
|
||||
@@ -1,8 +1,6 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.gateway.GatewayHttpCommands;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
@@ -10,7 +8,6 @@ import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.SetpointService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
@@ -21,21 +18,14 @@ final class Backend {
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the port but does not start serving.
|
||||
*
|
||||
* @throws IllegalArgumentException when PRESETS_FILE is unusable
|
||||
*/
|
||||
/** 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()));
|
||||
var commands = new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(),
|
||||
settings.gatewayTimeout());
|
||||
var setpoints = new SetpointService(commands, PresetFile.load(settings.presetsFile()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock), setpoints);
|
||||
new HistoryService(influx, clock));
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,14 @@ public final class Main {
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Settings settings;
|
||||
ApiServer server;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
server = Backend.assemble(settings, Clock.systemUTC());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package home.backend;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout,
|
||||
String gatewayToken, Path presetsFile) {
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
@@ -16,15 +13,7 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
url(env, "INFLUX_URL"),
|
||||
(int) number(env, "PORT", 8080, 0, 65535),
|
||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)),
|
||||
optional(env, "GATEWAY_TOKEN"),
|
||||
Optional.ofNullable(optional(env, "PRESETS_FILE")).map(Path::of).orElse(null));
|
||||
}
|
||||
|
||||
/** Null when unset or blank. */
|
||||
private static String optional(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
return value == null || value.isBlank() ? null : value.strip();
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)));
|
||||
}
|
||||
|
||||
private static URI url(Map<String, String> env, String name) {
|
||||
@@ -36,12 +25,6 @@ public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gateway
|
||||
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
|
||||
}
|
||||
// The clients append their own paths to this base; a path here would be silently dropped or doubled.
|
||||
boolean hasPath = !uri.getRawPath().isEmpty() && !uri.getRawPath().equals("/");
|
||||
if (hasPath || uri.getRawQuery() != null || uri.getRawFragment() != null) {
|
||||
throw new IllegalArgumentException(name + " must be only scheme, host and port, without a path, got: "
|
||||
+ value);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.Preset;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** Reads the presets (F3) from the optional PRESETS_FILE, else the built-in list. */
|
||||
public final class PresetFile {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final TypeReference<List<Preset>> PRESETS = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private PresetFile() {
|
||||
}
|
||||
|
||||
/** @param file null for the built-in defaults; IllegalArgumentException when it is unusable */
|
||||
public static List<Preset> load(Path file) {
|
||||
String source = file == null ? "built-in presets" : "PRESETS_FILE " + file;
|
||||
try (InputStream in = file == null ? defaults() : Files.newInputStream(file)) {
|
||||
List<Preset> presets = JSON.readValue(in, PRESETS);
|
||||
if (new HashSet<>(presets.stream().map(Preset::id).toList()).size() != presets.size()) {
|
||||
throw new IllegalArgumentException("preset ids must be unique");
|
||||
}
|
||||
return presets;
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException(source + " is not usable: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream defaults() {
|
||||
return PresetFile.class.getResourceAsStream("/presets-default.json");
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
Long minutes = command.duration() == null ? null : command.duration().toMinutes();
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway /commands", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
@@ -23,8 +16,6 @@ import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
|
||||
@@ -32,68 +23,20 @@ public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
// 45.5 or "45" must not silently become 45 in a request that writes to a heat pump.
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
private static final Pattern USER_NAME = Pattern.compile("[\\w.@+-]{1,64}");
|
||||
|
||||
private static final SecurityHeaders SECURITY_HEADERS = new SecurityHeaders();
|
||||
|
||||
/** A setpoint request is a few dozen bytes; anything near this is not from our UI. */
|
||||
private static final int MAX_BODY_BYTES = 4096;
|
||||
private static final int STOP_GRACE_SECONDS = 5;
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
private final HttpServer server;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history, SetpointService setpoints) throws IOException {
|
||||
HistoryService history) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
// Everything outside the API, with the security headers too: the web UI is the frontend's (ADR-010).
|
||||
context("/", exchange -> {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
});
|
||||
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());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
private void route(String path, HttpHandler handler) {
|
||||
context(path, exchange -> {
|
||||
if (path.equals(exchange.getRequestURI().getPath())) {
|
||||
handler.handle(exchange);
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void context(String path, HttpHandler handler) {
|
||||
server.createContext(path, handler).getFilters().add(SECURITY_HEADERS);
|
||||
server.createContext("/api/history", exchange -> handleHistory(exchange, history));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -101,8 +44,7 @@ public class ApiServer {
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(STOP_GRACE_SECONDS);
|
||||
executor.shutdown();
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
public int port() {
|
||||
@@ -115,103 +57,17 @@ public class ApiServer {
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
route(path, exchange -> handle(exchange, Map.of("GET", () -> {
|
||||
server.createContext(path, exchange -> respond(exchange, () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
send(exchange, 503, error("gateway unreachable"));
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* A page on another site can make the browser send a simple POST with the user's cookies. It cannot
|
||||
* add a custom header, set a JSON content type or fake the Origin, so a write needs all three.
|
||||
*/
|
||||
private static void guarded(HttpExchange exchange, Write write) throws IOException {
|
||||
var headers = exchange.getRequestHeaders();
|
||||
String origin = headers.getFirst("Origin");
|
||||
if (origin != null && !origin.endsWith("//" + headers.getFirst("Host"))) {
|
||||
send(exchange, 403, error("cross-site request"));
|
||||
} else if (!WRITE_MARKER.equals(headers.getFirst("X-Requested-With"))) {
|
||||
send(exchange, 403, error("missing X-Requested-With: " + WRITE_MARKER));
|
||||
} else if (!isJson(headers.getFirst("Content-Type"))) {
|
||||
send(exchange, 415, error("expected Content-Type: application/json"));
|
||||
} else {
|
||||
byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
send(exchange, 413, error("request body over " + MAX_BODY_BYTES + " bytes"));
|
||||
} else {
|
||||
write.run(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isJson(String contentType) {
|
||||
return contentType != null && contentType.split(";")[0].trim().equalsIgnoreCase("application/json");
|
||||
}
|
||||
|
||||
private static void getSetpoints(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, setpoints.current());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postSetpoint(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, SetpointRequest.class);
|
||||
if (request == null || request.setpoint() == null || request.value() == null) {
|
||||
throw new IllegalArgumentException("setpoint and value are required");
|
||||
}
|
||||
var result = setpoints.apply(request.setpoint(), request.value(), request.hours(), user(exchange));
|
||||
send(exchange, httpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"setpoint\", \"value\", \"hours\"?}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var result = setpoints.applyPreset(id, user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static int httpStatus(Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case FAILED, PARTIAL -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static String user(HttpExchange exchange) {
|
||||
String name = exchange.getRequestHeaders().getFirst(USER_HEADER);
|
||||
return name != null && USER_NAME.matcher(name).matches() ? name : DEFAULT_USER;
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
respond(exchange, () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, history.history(query.get("series"), query.get("range")));
|
||||
@@ -220,33 +76,20 @@ public class ApiServer {
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("history unavailable"));
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private interface Write {
|
||||
void run(byte[] body) throws IOException;
|
||||
}
|
||||
|
||||
private static void handle(HttpExchange exchange, Map<String, Action> byMethod) throws IOException {
|
||||
private static void respond(HttpExchange exchange, Action action) throws IOException {
|
||||
try (exchange) {
|
||||
Action action = byMethod.get(exchange.getRequestMethod());
|
||||
if (action == null) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException e) {
|
||||
// A gateway payload the client could not map (duplicate device, missing status): answer, do not drop.
|
||||
System.err.println("home-pump-backend: " + exchange.getRequestURI().getPath() + ": " + e);
|
||||
if (exchange.getResponseCode() == -1) {
|
||||
send(exchange, 502, error("bad gateway response"));
|
||||
}
|
||||
}
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.Filter;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Sets the browser hardening headers on every response, whatever handler answers. */
|
||||
final class SecurityHeaders extends Filter {
|
||||
|
||||
private static final String POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; "
|
||||
+ "img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; "
|
||||
+ "form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
@Override
|
||||
public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
|
||||
var headers = exchange.getResponseHeaders();
|
||||
headers.set("Content-Security-Policy", POLICY);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("Referrer-Policy", "no-referrer");
|
||||
chain.doFilter(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "security headers";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
@@ -37,31 +35,16 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
}
|
||||
long stepMinutes = stepMinutes(from, to, maxPoints);
|
||||
Map<Long, Double> summed = new TreeMap<>();
|
||||
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
var parts = SeriesSources.of(series).stream()
|
||||
.map(source -> executor.submit(() -> part(source, from, to, stepMinutes)))
|
||||
.toList();
|
||||
// Summed in source order, so the result does not depend on which answer comes first.
|
||||
for (var part : parts) {
|
||||
// A part without data is absent, not zero, so a bucket is kept if any part has it.
|
||||
part.get().forEach((t, v) -> summed.merge(t, v, Double::sum));
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
throw e.getCause() instanceof IOException io ? io : new IOException(e.getCause());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
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 Map<Long, Double> part(Source source, Instant from, Instant to, long stepMinutes) throws IOException {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
return InfluxResponse.parse(fetch(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -9,7 +8,6 @@ import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
@@ -17,10 +15,6 @@ import java.util.stream.Stream;
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
private record Inverter(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -31,30 +25,23 @@ public class EnergyService {
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Inverter deye = inverter(snapshot, "deye");
|
||||
Inverter fronius = inverter(snapshot, "fronius");
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
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"));
|
||||
List<Source> sources = List.of(deye.source(), fronius.source());
|
||||
return new EnergyView(oldest(sources), sources,
|
||||
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 Inverter inverter(GatewaySnapshot snapshot, String name) {
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, name, name);
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, name), reading, clock.instant());
|
||||
Source source = new Source(name, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Inverter(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
private static Instant oldest(List<Source> sources) {
|
||||
return sources.stream().map(Source::time).flatMap(Stream::ofNullable).min(Instant::compareTo).orElse(null);
|
||||
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. */
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The only way the backend writes anything: through the gateway, which checks every command. */
|
||||
public interface GatewayCommands {
|
||||
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.Pumps;
|
||||
@@ -13,7 +12,6 @@ import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static home.backend.application.SnapshotFields.on;
|
||||
import static home.backend.application.SnapshotFields.whole;
|
||||
@@ -33,18 +31,15 @@ public class HeatPumpService {
|
||||
}
|
||||
|
||||
public HeatPumpView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Optional<Measurement> reading = SnapshotFields.find(snapshot, "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()));
|
||||
boolean stale = SnapshotFields.stale(SnapshotFields.device(snapshot, "heat-pump"), reading, clock.instant());
|
||||
return view(reading.map(Measurement::time).orElse(null), stale,
|
||||
reading.map(Measurement::fields).orElse(Map.of()));
|
||||
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, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
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"),
|
||||
@@ -57,23 +52,6 @@ public class HeatPumpService {
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
private static String alarmText(Integer code) {
|
||||
if (code == null || code == 0) {
|
||||
return null;
|
||||
}
|
||||
return switch (code) {
|
||||
case 2 -> "Usterka czujnika temperatury zewnętrznej";
|
||||
case 3 -> "Usterka czujnika temperatury CO";
|
||||
case 4 -> "Usterka czujnika temperatury wewnętrznej";
|
||||
case 5 -> "Usterka czujnika temperatury CWU";
|
||||
case 14 -> "Usterka czujnika temperatury wyjścia pompy";
|
||||
case 15 -> "Usterka czujnika temperatury dolnego źródła";
|
||||
case 1003 -> "Zadziałał presostat (ciśnienie)";
|
||||
default -> "Kod usterki " + code;
|
||||
};
|
||||
}
|
||||
|
||||
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
|
||||
private static Double tenths(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
public SetpointService(GatewayCommands gateway, List<Preset> presets) {
|
||||
this.gateway = gateway;
|
||||
this.presets = List.copyOf(presets);
|
||||
}
|
||||
|
||||
public SetpointsView current() throws IOException {
|
||||
SetpointState state = gateway.setpoints();
|
||||
return new SetpointsView(state.setpoints(), state.restores(), presets);
|
||||
}
|
||||
|
||||
/** @param hours how long to keep the value, null until changed back */
|
||||
public CommandResult apply(String setpoint, int value, Double hours, String user) throws IOException {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, user)));
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, String user) {
|
||||
try {
|
||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
||||
String problem = problem(change, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
}
|
||||
if (!known.get().allows(change.value())) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Duration duration(Double hours) {
|
||||
if (hours == null) {
|
||||
return null;
|
||||
}
|
||||
long minutes = Math.round(hours * 60);
|
||||
if (minutes < 1 || minutes > Command.MAX_HOURS * 60L) {
|
||||
throw new IllegalArgumentException("hours must be from one minute to " + Command.MAX_HOURS + " hours");
|
||||
}
|
||||
return Duration.ofMinutes(minutes);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -16,19 +14,6 @@ final class SnapshotFields {
|
||||
private SnapshotFields() {
|
||||
}
|
||||
|
||||
/** Twice the 300 s the UI treats as one polling cycle; older than that a reading is not live. */
|
||||
private static final Duration MAX_AGE = Duration.ofSeconds(600);
|
||||
|
||||
static Optional<DeviceSnapshot> device(GatewaySnapshot snapshot, String name) {
|
||||
return snapshot.devices().stream().filter(d -> d.name().equals(name)).findFirst();
|
||||
}
|
||||
|
||||
/** A reading is stale when the device link is down, it is missing, or it is older than twice a cycle. */
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** The gateway's verdict on a command, passed on to the phone unchanged. */
|
||||
public record CommandResult(Status status, String message, Instant restoreAt) {
|
||||
|
||||
/** REJECTED: refused before any write. FAILED: the write or its read-back failed. */
|
||||
public enum Status { APPLIED, REJECTED, FAILED }
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Values that come only from a stale source are null. {@code time} is the oldest source
|
||||
* time (null when none was read); {@code sources} lists Deye and Fronius with their own time and freshness.
|
||||
* − charge. Null when the source device is not read.
|
||||
*/
|
||||
public record EnergyView(Instant time, List<Source> sources, Double pvPowerW, Double gridPowerW,
|
||||
Double batteryPowerW, Double batterySocPct, Double loadPowerW, Today today) {
|
||||
|
||||
/** One inverter; time is null and stale true when the gateway has no reading of it. */
|
||||
public record Source(String name, Instant time, boolean stale) {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -2,13 +2,9 @@ package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* What the heat pump does now (F1). Without a reading from the gateway {@code time} and the values are
|
||||
* null; {@code stale} is true when the device is down or the reading is older than 2 × 300 s.
|
||||
*/
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
|
||||
Targets targets, Setpoints setpoints) {
|
||||
/** 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 }
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
||||
public record Preset(String id, String name, List<Change> changes) {
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
|
||||
public Change {
|
||||
if (setpoint == null || setpoint.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset change needs a setpoint");
|
||||
}
|
||||
if (hours != null && (hours <= 0 || hours > Command.MAX_HOURS)) {
|
||||
throw new IllegalArgumentException("preset hours must be above 0 and at most " + Command.MAX_HOURS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
}
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What happened to each change of a preset, one entry per change in preset order. Changes run in
|
||||
* order and stop at the first that is not applied; the rest are NOT_ATTEMPTED.
|
||||
*
|
||||
* <p>{@code status}: APPLIED when every change was; PARTIAL when some were and some were not;
|
||||
* otherwise FAILED if any change failed or went unanswered (the pump may have changed), else
|
||||
* REJECTED (nothing was written).
|
||||
*/
|
||||
public record PresetResult(Status status, List<ChangeResult> results) {
|
||||
|
||||
public enum Status { APPLIED, REJECTED, FAILED, PARTIAL }
|
||||
|
||||
/** UNKNOWN: the gateway did not answer, so the change may or may not have reached the pump. */
|
||||
public enum ChangeStatus { APPLIED, REJECTED, FAILED, NOT_ATTEMPTED, UNKNOWN }
|
||||
|
||||
public record ChangeResult(ChangeStatus status, String message, Instant restoreAt) {
|
||||
|
||||
public static ChangeResult of(CommandResult verdict) {
|
||||
return new ChangeResult(ChangeStatus.valueOf(verdict.status().name()), verdict.message(),
|
||||
verdict.restoreAt());
|
||||
}
|
||||
|
||||
public static ChangeResult notAttempted() {
|
||||
return new ChangeResult(ChangeStatus.NOT_ATTEMPTED, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static PresetResult of(List<ChangeResult> results) {
|
||||
boolean applied = results.stream().anyMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean all = results.stream().allMatch(r -> r.status() == ChangeStatus.APPLIED);
|
||||
boolean maybeWritten = results.stream()
|
||||
.anyMatch(r -> r.status() == ChangeStatus.FAILED || r.status() == ChangeStatus.UNKNOWN);
|
||||
Status status = all ? Status.APPLIED
|
||||
: applied ? Status.PARTIAL
|
||||
: maybeWritten ? Status.FAILED
|
||||
: Status.REJECTED;
|
||||
return new PresetResult(status, results);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** One of the four writable setpoints as the gateway reports it; {@code value} is null until it has been read. */
|
||||
public record Setpoint(String name, int register, int min, int max, int step, Integer value) {
|
||||
|
||||
public boolean allows(int candidate) {
|
||||
return candidate >= min && candidate <= max && (candidate - min) % step == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the gateway knows about the setpoints: their limits, values and the pending restores. */
|
||||
public record SetpointState(List<Setpoint> setpoints, List<Restore> restores) {
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The setpoints screen: gateway state plus the presets from the backend config. */
|
||||
public record SetpointsView(List<Setpoint> setpoints, List<Restore> restores, List<Preset> presets) {
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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; }
|
||||
@@ -2,13 +2,11 @@ package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
@@ -22,8 +20,6 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -40,9 +36,6 @@ class BackendTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
/** Just after the readings in the snapshot fixture, so they count as fresh. */
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-10-23T22:27:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
@@ -53,42 +46,12 @@ class BackendTest {
|
||||
gateway = stub(Map.of(
|
||||
"/health", "src/test/resources/gateway/health-all-up.json",
|
||||
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
|
||||
gateway.createContext("/commands", this::commands);
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2),
|
||||
"s3cret-token", null);
|
||||
backend = Backend.assemble(settings, CLOCK);
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
backend.start();
|
||||
}
|
||||
|
||||
private record Command(String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Command> commandsSeen = new ArrayList<>();
|
||||
|
||||
/** The gateway's /commands, answering with the gateway's own contract examples. */
|
||||
private void commands(HttpExchange exchange) throws IOException {
|
||||
boolean post = exchange.getRequestMethod().equals("POST");
|
||||
if (post) {
|
||||
commandsSeen.add(new Command(exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
}
|
||||
byte[] body = Files.readAllBytes(Path.of("../gateway/src/test/resources/commands/"
|
||||
+ (post ? "response-applied.json" : "setpoints.json")));
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String path, String body) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path))
|
||||
.header("Remote-User", "anna").header("Content-Type", "application/json")
|
||||
.header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
backend.stop();
|
||||
@@ -178,64 +141,16 @@ class BackendTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesSetpointsWithTheBuiltInPresets() throws Exception {
|
||||
JsonNode view = JSON.readTree(get("/api/setpoints").body());
|
||||
void servesTheRealWebUi() throws Exception {
|
||||
HttpResponse<String> index = get("/");
|
||||
HttpResponse<String> script = get("/app.js");
|
||||
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints.json"))), view);
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostGoesToTheGatewayWithTokenAndUser() throws Exception {
|
||||
var response = post("/api/setpoints",
|
||||
Files.readString(Path.of("src/test/resources/api/setpoints-request.json")));
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/setpoints-applied.json"))),
|
||||
JSON.readTree(response.body()));
|
||||
assertEquals(1, commandsSeen.size());
|
||||
assertEquals("Bearer s3cret-token", commandsSeen.getFirst().authorization());
|
||||
assertEquals("anna", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("../gateway/src/test/resources/commands/request-boost.json"))),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetsComeFromPresetsFile(@TempDir Path dir) throws Exception {
|
||||
Path file = Files.writeString(dir.resolve("presets.json"),
|
||||
"[{\"id\":\"bath\",\"name\":\"Kąpiel\",\"changes\":[{\"setpoint\":\"cwu_economy\",\"value\":48,\"hours\":1}]}]");
|
||||
var custom = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), "s3cret-token", file), CLOCK);
|
||||
custom.start();
|
||||
try {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + custom.port() + "/api/presets/bath"))
|
||||
.header("Content-Type", "application/json").header("X-Requested-With", "home-pump")
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("phone", commandsSeen.getFirst().requestedBy());
|
||||
assertEquals(JSON.readTree("{\"setpoint\":\"cwu_economy\",\"value\":48,\"minutes\":60}"),
|
||||
JSON.readTree(commandsSeen.getFirst().body()));
|
||||
} finally {
|
||||
custom.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenSetpointsAreUnavailableButLiveStateStays() throws Exception {
|
||||
var noToken = Backend.assemble(new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2),
|
||||
Duration.ofSeconds(2), null, null), CLOCK);
|
||||
noToken.start();
|
||||
try {
|
||||
var base = "http://127.0.0.1:" + noToken.port();
|
||||
assertEquals(503, client.send(HttpRequest.newBuilder(URI.create(base + "/api/setpoints")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertEquals(200, client.send(HttpRequest.newBuilder(URI.create(base + "/api/heat-pump")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
assertTrue(commandsSeen.isEmpty());
|
||||
} finally {
|
||||
noToken.stop();
|
||||
}
|
||||
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
|
||||
@@ -248,14 +163,6 @@ class BackendTest {
|
||||
assertTrue(e.getMessage().contains("INFLUX_URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void urlsMayEndWithASingleSlash() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/", "INFLUX_URL", "https://influx.example/"));
|
||||
|
||||
assertEquals(URI.create("http://gw:8080/"), settings.gatewayUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
@@ -266,33 +173,12 @@ class BackendTest {
|
||||
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenAndPresetsFileAreOptional() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
var none = Settings.fromEnvironment(urls);
|
||||
var env = new HashMap<>(urls);
|
||||
env.put("GATEWAY_TOKEN", " s3cret-token ");
|
||||
env.put("PRESETS_FILE", "/etc/home-pump/presets.json");
|
||||
var set = Settings.fromEnvironment(env);
|
||||
env.put("GATEWAY_TOKEN", " ");
|
||||
env.put("PRESETS_FILE", "");
|
||||
|
||||
assertEquals(null, none.gatewayToken());
|
||||
assertEquals(null, none.presetsFile());
|
||||
assertEquals("s3cret-token", set.gatewayToken());
|
||||
assertEquals(Path.of("/etc/home-pump/presets.json"), set.presetsFile());
|
||||
assertEquals(null, Settings.fromEnvironment(env).gatewayToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectBadValues() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
for (Map<String, String> bad : List.of(
|
||||
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080/api", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example/influx/"),
|
||||
Map.of("GATEWAY_URL", "http://gw:8080?x=1", "INFLUX_URL", "https://influx.example"))) {
|
||||
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")) {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package home.backend.adapters.config;
|
||||
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PresetFileTest {
|
||||
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private Path file(String json) throws IOException {
|
||||
return Files.writeString(dir.resolve("presets.json"), json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsAreBoostAndAway() {
|
||||
var presets = PresetFile.load(null);
|
||||
|
||||
assertEquals(List.of(
|
||||
new Preset("cwu-now", "Ciepła woda teraz", List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileReplacesTheDefaults() throws Exception {
|
||||
var presets = PresetFile.load(file("""
|
||||
[{"id": "bath", "name": "Kąpiel", "changes": [{"setpoint": "cwu_economy", "value": 48, "hours": 1.5}]}]
|
||||
"""));
|
||||
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableFilesAreRefusedWithTheirName() throws Exception {
|
||||
for (String json : List.of(
|
||||
"not json",
|
||||
"{}",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": []}]",
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> PresetFile.load(file), json);
|
||||
assertTrue(e.getMessage().contains("PRESETS_FILE"), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileIsRefused() {
|
||||
assertThrows(IllegalArgumentException.class, () -> PresetFile.load(dir.resolve("absent.json")));
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Against a stub gateway on localhost; the bodies are the gateway's own examples (the contract). */
|
||||
class GatewayHttpCommandsTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT = Path.of("../gateway/src/test/resources/commands");
|
||||
|
||||
private record Seen(String method, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
answerCommands();
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private int status;
|
||||
private String body = "{}";
|
||||
|
||||
private void serve(int status, String body) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
private void answerCommands() {
|
||||
server.createContext("/commands", exchange -> {
|
||||
seen.add(new Seen(exchange.getRequestMethod(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
return new GatewayHttpCommands(URI.create("http://127.0.0.1:" + server.getAddress().getPort()), token,
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
private static String contract(String name) throws IOException {
|
||||
return Files.readString(CONTRACT.resolve(name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBoostWithTokenUserAndMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 45, Duration.ofHours(2), "anna"));
|
||||
|
||||
assertEquals(Status.APPLIED, result.status());
|
||||
assertEquals(Instant.parse("2026-10-24T12:00:00Z"), result.restoreAt());
|
||||
assertEquals(1, seen.size());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, "phone"));
|
||||
|
||||
assertEquals(contractJson("request-away.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectionIsAVerdictNotAnError() throws Exception {
|
||||
serve(422, contract("response-rejected.json"));
|
||||
|
||||
var result = commands("s3cret-token").apply(new Command("cwu_economy", 60, null, "phone"));
|
||||
|
||||
assertEquals(Status.REJECTED, result.status());
|
||||
assertEquals("cwu_economy allows 5–48 in steps of 1, not 60", result.message());
|
||||
assertNull(result.restoreAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIsAVerdict() throws Exception {
|
||||
serve(502, "{\"status\":\"FAILED\",\"message\":\"read back 8, expected 45\",\"restoreAt\":null}");
|
||||
|
||||
assertEquals(Status.FAILED, commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")).status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsSetpointsWithRestores() throws Exception {
|
||||
serve(200, contract("setpoints.json"));
|
||||
|
||||
var state = commands("s3cret-token").setpoints();
|
||||
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(4, state.setpoints().size());
|
||||
assertEquals(48, state.setpoints().get(2).max());
|
||||
assertEquals(1, state.restores().size());
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
+ "\"value\":null}],\"restores\":[]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().setpoints().getFirst().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherStatusesAreNoVerdict() throws Exception {
|
||||
for (int status : List.of(401, 404, 500, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints(), "GET " + status);
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")), "POST " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreadableAnswerIsNoVerdict() {
|
||||
serve(200, "<html>proxy</html>");
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> commands("s3cret-token").apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTokenNothingIsSent() {
|
||||
serve(200, "{}");
|
||||
var commands = commands(null);
|
||||
|
||||
assertThrows(IOException.class, () -> commands.setpoints());
|
||||
assertThrows(IOException.class, () -> commands.apply(new Command("cwu_economy", 45, null, "phone")));
|
||||
assertTrue(seen.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIsAnIOException() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").setpoints());
|
||||
}
|
||||
|
||||
/** A contract file by name, or JSON text as is, parsed so that formatting does not matter. */
|
||||
private static JsonNode contractJson(String nameOrJson) throws IOException {
|
||||
return JSON.readTree(nameOrJson.endsWith(".json") ? contract(nameOrJson) : nameOrJson);
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,7 @@ package home.backend.adapters.http;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
@@ -36,13 +27,11 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ApiServerTest {
|
||||
@@ -54,11 +43,6 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private boolean commandsDown;
|
||||
private int answersBeforeDrop;
|
||||
private ApiServer server;
|
||||
|
||||
@BeforeEach
|
||||
@@ -68,9 +52,6 @@ class ApiServerTest {
|
||||
if (gatewayDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
if (gatewayBug != null) {
|
||||
throw gatewayBug;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
HistoryRepository history = (series, from, to, max) -> {
|
||||
@@ -81,39 +62,9 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (commandsDown || sent.size() >= answersBeforeDrop) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
sent.add(command);
|
||||
return switch (verdict) {
|
||||
case APPLIED -> new CommandResult(verdict, "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z",
|
||||
command.duration() == null ? null : Instant.parse("2026-10-24T12:00:00Z"));
|
||||
case REJECTED -> new CommandResult(verdict, "cwu_economy allows 5–48 in steps of 1, not 60", null);
|
||||
case FAILED -> new CommandResult(verdict, "read back 8, expected 45", null);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return new SetpointState(List.of(new Setpoint("co_economy", 4131, 5, 25, 1, 18),
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)));
|
||||
new HealthService(gateway, clock), new HistoryService(history, clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -140,7 +91,7 @@ class ApiServerTest {
|
||||
return new GatewaySnapshot(List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
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",
|
||||
@@ -180,28 +131,10 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement deye = devices.get(1).measurements().get(0);
|
||||
devices.set(1, new DeviceSnapshot("deye", true, old, null,
|
||||
List.of(new Measurement("home", "deye", deye.fields(), old))));
|
||||
snapshot = new GatewaySnapshot(devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
@@ -240,26 +173,6 @@ class ApiServerTest {
|
||||
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedQueryIs400NotADroppedConnection() throws Exception {
|
||||
for (String path : List.of("/api/history?series=%", "/api/history?series=%zz&range=24h")) {
|
||||
assertEquals(400, rawGet(path), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void unusableGatewayPayloadIs502() throws Exception {
|
||||
for (RuntimeException bug : List.of(new IllegalStateException("Duplicate key deye"),
|
||||
new NullPointerException("status"))) {
|
||||
gatewayBug = bug;
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/health")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(502, response.statusCode(), path);
|
||||
assertEquals("bad gateway response", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
@@ -267,173 +180,6 @@ class ApiServerTest {
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
|
||||
}
|
||||
|
||||
private static final String[] BROWSER_HEADERS = {"Content-Type", "application/json", "X-Requested-With", "home-pump"};
|
||||
|
||||
/** A POST as the web UI sends it; extra headers come after the defaults. */
|
||||
private HttpResponse<String> post(String path, String body, String... headers) throws Exception {
|
||||
var all = new ArrayList<>(List.of(BROWSER_HEADERS));
|
||||
all.addAll(List.of(headers));
|
||||
return rawPost(path, body, all.toArray(String[]::new));
|
||||
}
|
||||
|
||||
private HttpResponse<String> rawPost(String path, String body, String... headers) throws Exception {
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (headers.length > 0) {
|
||||
request.headers(headers);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static final String BOOST = "{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":2}";
|
||||
|
||||
@Test
|
||||
void setpointsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/setpoints", "setpoints");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliedBoostMatchesExampleAndRestoresAfterTwoHours() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoints-applied"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
HttpResponse<String> response = post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(422, response.statusCode());
|
||||
assertEquals(example("setpoints-rejected"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedWriteIs502() throws Exception {
|
||||
verdict = Status.FAILED;
|
||||
|
||||
assertEquals(502, post("/api/setpoints", BOOST).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedUserIsPassedOnAndOddNamesFallBack() throws Exception {
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna.k@example.pl");
|
||||
post("/api/setpoints", BOOST, "Remote-User", "anna smith");
|
||||
post("/api/setpoints", BOOST);
|
||||
|
||||
assertEquals(List.of("anna.k@example.pl", "phone", "phone"), sent.stream().map(Command::requestedBy).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badSetpointRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"setpoint\":\"cwu_economy\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":\"45\"}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":0}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":-1}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"hours\":72.5}",
|
||||
"{\"setpoint\":\"cwu_economy\",\"value\":45,\"minutes\":5}")) {
|
||||
assertEquals(400, post("/api/setpoints", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", "");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
assertEquals(example("setpoints-preset-applied").get("results").size(), body.get("results").size());
|
||||
assertEquals("APPLIED", body.get("status").asText());
|
||||
assertEquals(List.of("cwu_comfort", "co_comfort"), sent.stream().map(Command::setpoint).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayOnSetpointsIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutJsonContentTypeAre415() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(415, rawPost(path, BOOST, "X-Requested-With", "home-pump", "Content-Type", "text/plain")
|
||||
.statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonContentTypeMayCarryACharset() throws Exception {
|
||||
assertEquals(200, rawPost("/api/setpoints", BOOST, "X-Requested-With", "home-pump",
|
||||
"Content-Type", "application/json; charset=utf-8").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesWithoutTheCustomHeaderAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, BOOST, "Content-Type", "application/json",
|
||||
"X-Requested-With", "XMLHttpRequest").statusCode(), path);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromAnotherOriginAre403() throws Exception {
|
||||
for (String path : List.of("/api/setpoints", "/api/presets/away")) {
|
||||
for (String origin : List.of("https://evil.example", "null", "http://localhost:1")) {
|
||||
assertEquals(403, post(path, BOOST, "Origin", origin).statusCode(), origin);
|
||||
}
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFromTheSameOriginAreAccepted() throws Exception {
|
||||
assertEquals(200, post("/api/setpoints", BOOST, "Origin", "http://localhost:" + server.port()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setpointMethodsAreChecked() throws Exception {
|
||||
assertEquals(405, post("/api/history", "").statusCode());
|
||||
assertEquals(405, get("/api/presets/away").statusCode());
|
||||
HttpRequest put = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/setpoints"))
|
||||
.PUT(HttpRequest.BodyPublishers.noBody()).build();
|
||||
assertEquals(405, client.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiAcceptsOnlyGet() throws Exception {
|
||||
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
|
||||
@@ -442,64 +188,50 @@ class ApiServerTest {
|
||||
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyResponseCarriesTheSecurityHeaders() throws Exception {
|
||||
var paths = List.of("/", "/index.html", "/missing.css", "/api/health", "/api/nothing", "/api/history");
|
||||
for (String path : paths) {
|
||||
assertSecurityHeaders(get(path), path);
|
||||
}
|
||||
assertSecurityHeaders(post("/api/health", ""), "405");
|
||||
assertSecurityHeaders(post("/api/setpoints", "{"), "400");
|
||||
assertSecurityHeaders(rawPost("/api/setpoints", BOOST), "403");
|
||||
}
|
||||
|
||||
private static void assertSecurityHeaders(HttpResponse<String> response, String what) {
|
||||
var headers = response.headers();
|
||||
assertEquals("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
|
||||
+ "font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
|
||||
headers.firstValue("Content-Security-Policy").orElse(null), what);
|
||||
assertEquals("nosniff", headers.firstValue("X-Content-Type-Options").orElse(null), what);
|
||||
assertEquals("no-referrer", headers.firstValue("Referrer-Policy").orElse(null), what);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiRoutesMatchTheExactPathOnly() throws Exception {
|
||||
for (String path : List.of("/api/heat-pumpX", "/api/heat-pump/", "/api/heat-pump/x", "/api/energy2",
|
||||
"/api/healthz", "/api/history2?series=cwu_temp", "/api/setpointsX")) {
|
||||
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);
|
||||
}
|
||||
assertEquals(404, post("/api/setpointsX", BOOST).statusCode());
|
||||
assertEquals(200, get("/api/heat-pump?x=1").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopClosesThePort() {
|
||||
server.stop();
|
||||
|
||||
assertThrows(IOException.class, () -> get("/api/health"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestBodiesOver4KbAre413AndSendNothing() throws Exception {
|
||||
String padding = " ".repeat(4096 - BOOST.length());
|
||||
assertEquals(200, post("/api/setpoints", BOOST + padding).statusCode());
|
||||
sent.clear();
|
||||
|
||||
assertEquals(413, post("/api/setpoints", BOOST + padding + " ").statusCode());
|
||||
assertEquals(413, post("/api/presets/away", "x".repeat(5000)).statusCode());
|
||||
assertEquals(0, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void answersOnlyTheApi() throws Exception {
|
||||
// The web UI is the frontend container's (ADR-010); the backend serves no files.
|
||||
for (String path : List.of("/", "/index.html", "/assets/index.js", "/api", "/api/")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-38
@@ -18,14 +18,9 @@ import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -38,36 +33,22 @@ class InfluxHistoryRepositoryTest {
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private HttpServer server;
|
||||
private final List<Map<String, String>> requests = Collections.synchronizedList(new ArrayList<>());
|
||||
private volatile CountDownLatch rendezvous = new CountDownLatch(0);
|
||||
private final AtomicInteger metTogether = new AtomicInteger();
|
||||
private final List<Map<String, String>> requests = new ArrayList<>();
|
||||
private final Map<String, String> bodyByField = new HashMap<>();
|
||||
private int status = 200;
|
||||
private String failingField = "";
|
||||
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
// Concurrent handlers: the repository asks for the parts of a series at the same time.
|
||||
server.setExecutor(Executors.newCachedThreadPool());
|
||||
server.createContext("/query", exchange -> {
|
||||
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
|
||||
requests.add(params);
|
||||
rendezvous.countDown();
|
||||
try {
|
||||
if (rendezvous.await(5, TimeUnit.SECONDS)) {
|
||||
metTogether.incrementAndGet();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
String body = bodyByField.entrySet().stream()
|
||||
.filter(e -> params.get("q").contains("\"" + e.getKey() + "\""))
|
||||
.map(Map.Entry::getValue).findFirst().orElse(defaultBody);
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
boolean fails = !failingField.isEmpty() && params.get("q").contains("\"" + failingField + "\"");
|
||||
exchange.sendResponseHeaders(fails ? 500 : status, bytes.length);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
@@ -125,23 +106,6 @@ class InfluxHistoryRepositoryTest {
|
||||
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForTheThreePvPartsAtTheSameTime() throws IOException {
|
||||
rendezvous = new CountDownLatch(3);
|
||||
|
||||
repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
|
||||
|
||||
// A handler passes the latch only when all three requests are in flight together.
|
||||
assertEquals(3, metTogether.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneFailingPartFailsTheWholeSeries() {
|
||||
failingField = "pv2_power_w";
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sortsPointsByTime() throws IOException {
|
||||
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -76,22 +75,6 @@ class ServicesTest {
|
||||
assertEquals(8, view.setpoints().cwuEconomy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpAlarmCodeRaisesTheAlarmAndIsNamedInPolish() throws IOException {
|
||||
HeatPumpView sensor = heatPump(Map.of("alarm", 0.0, "alarm_code", 3.0));
|
||||
HeatPumpView pressure = heatPump(Map.of("alarm_code", 1003.0));
|
||||
HeatPumpView unknown = heatPump(Map.of("alarm_code", 77.0));
|
||||
HeatPumpView healthy = heatPump(Map.of("alarm", 0.0, "alarm_code", 0.0));
|
||||
|
||||
assertTrue(sensor.alarm());
|
||||
assertEquals(3, sensor.alarmCode());
|
||||
assertEquals("Usterka czujnika temperatury CO", sensor.alarmText());
|
||||
assertEquals("Zadziałał presostat (ciśnienie)", pressure.alarmText());
|
||||
assertEquals("Kod usterki 77", unknown.alarmText());
|
||||
assertFalse(healthy.alarm());
|
||||
assertNull(healthy.alarmText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMissingFieldsAreNull() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of());
|
||||
@@ -102,8 +85,6 @@ class ServicesTest {
|
||||
assertNull(view.targets().cwu());
|
||||
assertNull(view.setpoints().coComfort());
|
||||
assertFalse(view.alarm());
|
||||
assertNull(view.alarmCode());
|
||||
assertNull(view.alarmText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,41 +105,7 @@ class ServicesTest {
|
||||
HeatPumpView view = service.current();
|
||||
|
||||
assertNull(view.state());
|
||||
assertNull(view.time());
|
||||
assertTrue(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutAReadingHasNoTimeAndIsStale() throws IOException {
|
||||
HeatPumpView view = new HeatPumpService(gateway(), CLOCK).current();
|
||||
|
||||
assertNull(view.time());
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.state());
|
||||
assertNull(view.temperatures().outdoor());
|
||||
assertNull(view.setpoints().cwuComfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPumpModeOutOfRangeHasNoState() throws IOException {
|
||||
assertNull(heatPump(Map.of("pump_mode", 4.0)).state());
|
||||
assertNull(heatPump(Map.of("pump_mode", -1.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpIsFreshOnlyWhileUpAndNotOlderThanTenMinutes() throws IOException {
|
||||
assertFalse(heatPump(Map.of("pump_mode", 0.0)).stale());
|
||||
assertTrue(heatPumpRead(true, NOW.minusSeconds(601)).stale());
|
||||
assertFalse(heatPumpRead(true, NOW.minusSeconds(600)).stale());
|
||||
HeatPumpView down = heatPumpRead(false, READ);
|
||||
assertTrue(down.stale());
|
||||
assertEquals(READ, down.time());
|
||||
}
|
||||
|
||||
private static HeatPumpView heatPumpRead(boolean up, Instant time) throws IOException {
|
||||
var reading = new Measurement("Dom_Pompa", "heat_pump", Map.of("pump_mode", 0.0), time);
|
||||
return new HeatPumpService(gateway(new DeviceSnapshot("heat-pump", up, time, null, List.of(reading))),
|
||||
CLOCK).current();
|
||||
assertEquals(NOW, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -206,57 +153,12 @@ class ServicesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutSourcesHasNoTimeAndBothSourcesStale() throws IOException {
|
||||
void energyWithoutSourcesIsNullAtClockTime() throws IOException {
|
||||
EnergyView view = energy();
|
||||
|
||||
assertNull(view.pvPowerW());
|
||||
assertNull(view.today().pvKwh());
|
||||
assertNull(view.time());
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
|
||||
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithStaleDeyeKeepsOnlyFreshFroniusAndTheOldestTime() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
|
||||
EnergyView view = energy(deyeRead(true, old), device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(old, view.time());
|
||||
assertEquals(List.of(new Source("deye", old, true), new Source("fronius", READ, false)), view.sources());
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.batteryPowerW());
|
||||
assertNull(view.batterySocPct());
|
||||
assertNull(view.loadPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithStaleFroniusKeepsDeyeAlone() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var fronius = new DeviceSnapshot("fronius", true, old, null,
|
||||
List.of(new Measurement("home", "fronius", FRONIUS, old)));
|
||||
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE), fronius);
|
||||
|
||||
assertEquals(1500.0, view.pvPowerW());
|
||||
assertEquals(10.0, view.today().pvKwh());
|
||||
assertEquals(80.0, view.batterySocPct());
|
||||
assertEquals(old, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energySourceWhoseLinkIsDownIsStaleEvenWithAFreshReading() throws IOException {
|
||||
EnergyView view = energy(deyeRead(false, READ));
|
||||
|
||||
assertTrue(view.sources().get(0).stale());
|
||||
assertNull(view.gridPowerW());
|
||||
assertEquals(READ, view.time());
|
||||
assertEquals(NOW, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final SetpointState STATE = new SetpointState(List.of(
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)), List.<Restore>of());
|
||||
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
|
||||
private final List<Object> script = new ArrayList<>();
|
||||
private boolean down;
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
Object next = script.isEmpty() ? Status.APPLIED : script.removeFirst();
|
||||
if (next instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
sent.add(command);
|
||||
return new CommandResult((Status) next, command.setpoint(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@Test
|
||||
void viewJoinsGatewayStateAndPresets() throws Exception {
|
||||
var view = service.current();
|
||||
|
||||
assertEquals(STATE.setpoints(), view.setpoints());
|
||||
assertEquals(presets, view.presets());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hoursBecomeAWholeNumberOfMinutes() throws Exception {
|
||||
service.apply("cwu_economy", 45, 1.5, "anna");
|
||||
service.apply("cwu_comfort", 10, null, "anna");
|
||||
|
||||
assertEquals(new Command("cwu_economy", 45, Duration.ofMinutes(90), "anna"), sent.get(0));
|
||||
assertEquals(new Command("cwu_comfort", 10, null, "anna"), sent.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void durationUnderAMinuteIsRefusedBeforeSending() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 0.001, "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, -1.0, "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.apply("cwu_economy", 45, 72.5, "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void seventyTwoHoursIsTheLongestBoost() throws Exception {
|
||||
service.apply("cwu_economy", 45, 72.0, "anna");
|
||||
|
||||
assertEquals(Duration.ofHours(72), sent.getFirst().duration());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
|
||||
sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("cwu_economy"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
private static List<ChangeStatus> statuses(PresetResult result) {
|
||||
return result.results().stream().map(ChangeResult::status).toList();
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
assertEquals(1, sent.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIsAnIOException() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"time": "2026-10-23T21:00:00Z",
|
||||
"sources": [
|
||||
{ "name": "deye", "time": "2026-10-23T21:00:00Z", "stale": true },
|
||||
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
|
||||
],
|
||||
"pvPowerW": 0.0,
|
||||
"gridPowerW": null,
|
||||
"batteryPowerW": null,
|
||||
"batterySocPct": null,
|
||||
"loadPowerW": null,
|
||||
"today": { "pvKwh": 4.4, "gridBoughtKwh": null, "gridSoldKwh": null, "loadKwh": null }
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"sources": [
|
||||
{ "name": "deye", "time": "2026-10-23T22:26:00Z", "stale": false },
|
||||
{ "name": "fronius", "time": "2026-10-23T22:26:00Z", "stale": false }
|
||||
],
|
||||
"pvPowerW": 0.0,
|
||||
"gridPowerW": 645.0,
|
||||
"batteryPowerW": -24.0,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"time": null,
|
||||
"stale": true,
|
||||
"state": null,
|
||||
"compressorPercent": null,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"alarmCode": null,
|
||||
"alarmText": null,
|
||||
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
|
||||
"temperatures": {
|
||||
"outdoor": null, "indoor": null, "coBuffer": null, "cwu": null,
|
||||
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
||||
},
|
||||
"targets": { "room": null, "co": null, "cwu": null },
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"state": "IDLE",
|
||||
"compressorPercent": 0,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"alarmCode": null,
|
||||
"alarmText": null,
|
||||
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
|
||||
"temperatures": {
|
||||
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": "2026-10-24T12:00:00Z" }
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"status": "APPLIED",
|
||||
"results": [
|
||||
{ "status": "APPLIED", "message": "cwu_comfort 45 → 10", "restoreAt": null },
|
||||
{ "status": "APPLIED", "message": "co_comfort 19 → 15", "restoreAt": null }
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"status": "PARTIAL",
|
||||
"results": [
|
||||
{ "status": "APPLIED", "message": "cwu_economy 8 → 45, restore at 2026-10-24T12:00:00Z", "restoreAt": null },
|
||||
{ "status": "UNKNOWN", "message": "no answer from the gateway", "restoreAt": null }
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "status": "REJECTED", "message": "cwu_economy allows 5–48 in steps of 1, not 60", "restoreAt": null }
|
||||
@@ -1 +0,0 @@
|
||||
{ "setpoint": "cwu_economy", "value": 45, "hours": 2 }
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"setpoints": [
|
||||
{ "name": "co_economy", "register": 4131, "min": 5, "max": 25, "step": 1, "value": 18 },
|
||||
{ "name": "co_comfort", "register": 4132, "min": 5, "max": 25, "step": 1, "value": 19 },
|
||||
{ "name": "cwu_economy", "register": 4143, "min": 5, "max": 48, "step": 1, "value": 8 },
|
||||
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1, "value": 45 }
|
||||
],
|
||||
"restores": [
|
||||
{ "setpoint": "cwu_economy", "register": 4143, "originalValue": 8, "appliedValue": 45, "due": "2026-10-24T12:00:00Z" }
|
||||
],
|
||||
"presets": [
|
||||
{
|
||||
"id": "cwu-now",
|
||||
"name": "Ciepła woda teraz",
|
||||
"changes": [ { "setpoint": "cwu_economy", "value": 45, "hours": 2.0 } ]
|
||||
},
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -16,15 +16,14 @@ the gateway already shows the stack is enough (ADR-007).
|
||||
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
|
||||
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
|
||||
the contract between backend and UI.
|
||||
- **Web UI**: React, TypeScript and Vite in `frontend/`, Tailwind for styling, Recharts for
|
||||
charts; the overview animates an SVG layer over a picture of the installation. Polish texts.
|
||||
Changed on 2026-10-24 at the user's request: the plain-JS UI was too weak to grow. Served by
|
||||
its own nginx container, not by the backend (ADR-010).
|
||||
- **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
|
||||
|
||||
- A JavaScript toolchain to keep up to date (`package-lock.json`); CI sets up Node as well.
|
||||
- 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# ADR-009: Web UI reachable from Krosno only, without a login for now
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
M3 needed a login method before the backend goes behind Traefik in Kraków (ADR-008 left it
|
||||
open). Keycloak runs there but no stack uses it yet. The first users are in the house in
|
||||
Krosno, on its network.
|
||||
|
||||
## Decision
|
||||
|
||||
- No login in the app or in Traefik for now. The backend's router gets an `ipwhitelist`
|
||||
middleware that admits only Krosno: its public address or the WireGuard range, whichever path
|
||||
the phones take; settled when the route is set up.
|
||||
- A `headers` middleware on the same router clears a client's `Remote-User`, so the name sent as
|
||||
`X-Requested-By` with a setpoint change is never one a client made up; without a login it is
|
||||
always `phone`.
|
||||
- The container publishes no host port; only Traefik reaches it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Anyone on the Krosno network can change setpoints once writes are enabled (#53). The limits
|
||||
and timed restores (ADR-006) still apply.
|
||||
- Not usable away from home. Before that, choose a login (Keycloak forward-auth or Traefik
|
||||
basic auth with `headerField: Remote-User`) in a new ADR that supersedes this one.
|
||||
- If Krosno's public address changes, the whitelist must follow; the WireGuard range does not.
|
||||
@@ -1,31 +0,0 @@
|
||||
# ADR-010: Gateway, backend and frontend as three images, each with its compose
|
||||
|
||||
Status: accepted · 2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
The code had grown into a gateway at the root and a backend that also built and served the
|
||||
web UI. The Pi ran a jar under systemd, copied by a deploy script; Kraków runs Docker stacks
|
||||
from homelab-compose through Arcane. The user wanted the parts separate and each deployable the
|
||||
same way.
|
||||
|
||||
## Decision
|
||||
|
||||
- Three folders, `gateway/`, `backend/`, `frontend/`, each with a `Dockerfile` and a
|
||||
`compose.yaml`. The backend serves only `/api/`; nginx in the frontend image serves the UI
|
||||
and passes `/api/` to the backend, so they share one origin (CSP and the Origin check hold).
|
||||
- A pushed tag `vMAJOR.MINOR.PATCH` (`scripts/release.sh`) makes CI publish the three images in
|
||||
the Gitea registry; the gateway also for arm64. The Dockerfiles build on the runner's own
|
||||
platform and only copy into the final stage, so no emulation is needed.
|
||||
- The gateway runs in Docker on the Pi (Docker installed there, the OS untouched): host network
|
||||
for Modbus, `/home/pi/home-pump` mounted at the same path, logs to journald. `update.sh` on
|
||||
the Pi pulls a release with its `compose.yaml` and goes back to the previous one if `/health`
|
||||
fails or the container restarts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No jar copying: the Pi and Kraków run the same published images; a version is a git tag.
|
||||
- The Pi needs Docker and, if the packages are private, a registry login: one-time steps.
|
||||
- CI needs Node and a `REGISTRY_TOKEN` secret with package write access.
|
||||
- Supersedes the systemd unit and `scripts/deploy-gateway.sh`; ADR-006 (gateway on the Pi)
|
||||
stands.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Frisko app and map knowledge for the RX910-SE21
|
||||
|
||||
What the vendor app (`FriskoOnline2025.apk`, decompiled with androguard, nothing run), the XML maps
|
||||
and the PDFs say. **Everything here is unverified on the device** unless
|
||||
[rx910-se21.md](rx910-se21.md) says so. Source tags: `PDF` = `RX910-SE21-AdresyMODBUS`, `XML` =
|
||||
`RX910-SE21_v24062015.xml`, `MR208` = MR208-PC+ manual/map (another model: hints only), `APK` =
|
||||
class in `com.frisko.frisko_mobile`.
|
||||
|
||||
## Key finding: the app has no control logic
|
||||
|
||||
The app is a generic Modbus viewer: every screen, scale, value name and icon comes from the XML
|
||||
(`XmlHandler`, `ListDataParam`). It contains no register numbers except 4011 (`Comm_thread.STATUS_REG`),
|
||||
base 4000 (`Frisko_Sterownik.ADR_BAZOWY`, only for its local array index; the wire address is the raw
|
||||
register) and the XML tag set. It never reads 4221, 4222–4225, 4227, 4228, 4119 or 4129. Meanings
|
||||
below therefore come from the PDF, the XML and the MR208 manual, not from code.
|
||||
|
||||
## Alarm: register 4011 (the app) vs 4221 (the PDF)
|
||||
|
||||
| Register | Source | Meaning |
|
||||
|---|---|---|
|
||||
| 4221 "Instalacja" | PDF | 0 = installation OK, 1 = installation fault. Binary, no bits, no codes. |
|
||||
| 4011 "Status" | XML (Status screen) | Fault code, not a bit field: 0 OK, 2 Err_Tzew, 3 Err_Tco, 4 Err_Twew, 5 Err_Tcwu, 14 Err_Tpc, 15 Err_Tdz, 1003 Err_Presostat |
|
||||
| 4073 "Presostat" | XML, PDF | 0 OK, 1 error (pressure switch input) |
|
||||
|
||||
- The app polls 4011 always, and on any non-zero value (even one not in the XML list) raises a
|
||||
notification "Awaria sterownika: <number>" with sound and vibration; it repeats only when the code
|
||||
changes (`Comm_thread.update_regs`, `status_error`). It never reads 4221.
|
||||
- Likely mapping of names (inference from naming and the MR208 manual): `Tzew` outdoor 4065, `Tco`
|
||||
buffer 4067, `Twew` indoor 4066, `Tcwu` 4069, `Tpc` pump outlet 4072, `Tdz` ground source
|
||||
(4070/4071); codes 2–5, 14, 15 = sensor fault (MR208 "ErrTemp": pump off), 1003 = pressure switch
|
||||
(MR208 "ErrPres": pump off; three trips in one cycle latch it until service clears it).
|
||||
- MR208 also reports TmaxPC, TminDZ, dTmaxDZ shutdowns; SE21 may put them in 4011 or 4221
|
||||
(unknown). 4011 is not in the PDF map. Next: log a read-only FC 3 of 4011 beside 4221 and 4073.
|
||||
|
||||
## Registers 4119, 4222–4225, 4228
|
||||
|
||||
| Reg | What is known | Source |
|
||||
|---|---|---|
|
||||
| 4119 `WyCHL` | Output, "0 OFF, 100 ON" like 4112–4118. "CHL" probably means cooling (the gateway calls it `output_cooling`; a guess). Not in the XML, so the app never shows it. The −14308 read means the register is not implemented on this firmware | PDF; read per rx910-se21.md |
|
||||
| 4222–4225 | Not in the PDF, XML or APK. MR208 uses 4225 for TFer (a different layout, do not transfer). Gateway fixtures show 0 | none |
|
||||
| 4228 `StanZB` | PDF text is cut: "0 –", "2 – zbCW" (value 1 and the text of 0 are missing from the PDF extract, also with `-raw`). "ZB" is not explained; not in the XML | PDF |
|
||||
| 4227 `StanCO/CW` | 0 pc STOP, 1 pcCO, 2 pcCW, 3 pcCO/CW. Not in the XML; the app shows output icons instead | PDF |
|
||||
| 4226 `Status` | 0 Ferie, 1 Tygodn (weekly), 2 Party, 3 ROZRUCH (start-up), 4 STOP, 5 Dezynf. Same list in the XML "Program pracy" with icons `program_ferie`, `program_tygodniowy`, `program_party`, `praca`, `stop`, `dezynf` | PDF, XML |
|
||||
|
||||
4226 is the effective state; 4140 is the chosen program (4226 can say STOP while 4140 says weekly).
|
||||
|
||||
Open: the gateway test fixture (real output, PR #40) shows 4226 = 0 while the settings backup has
|
||||
4140 = 1 (weekly) and rx910-se21.md observed the weekly program. If 4226 really reads 0 then, the
|
||||
PDF numbering may be shifted by one. Check by reading 4226 and 4140 together, then changing nothing.
|
||||
|
||||
## Modes and programs
|
||||
|
||||
| Reg | Values | Source |
|
||||
|---|---|---|
|
||||
| 4121 `TrybLZ` | Effective season: 0 summer, 1 winter. Read-only on the app's Status screen | PDF, XML |
|
||||
| 4122 `TrypRA` | 0 R, 1 A, 2 *. Not in the XML. MR208 shows "Zima(A)" on its main screen: season plus A (automatic) or R (manual) | PDF, MR208 manual |
|
||||
| 4123 `Tryb` | Season setting: 0 Lato, 1 Zima, 2 Auto (editable; backup value 2). Auto switches at 4124 `Temp progu Z/L` (5–35 °C whole, backup 18) | XML, PDF, backup |
|
||||
| 4125 `Tryb pracy` | 0 STOP, 1 PRACA (editable; backup 1) | XML, PDF |
|
||||
| 4140 `Prg` | 0 Ferie, 1 Tygodniowy, 2 Party (editable) | XML, PDF |
|
||||
| 4141 Ferie days | 0–90 (XML). MR208: 0 = until switched back by hand | XML, MR208 manual |
|
||||
| 4142 Party hours | 0–90 (XML). MR208: 0 = until switched back; counts down to the end of Party | XML, MR208 manual |
|
||||
|
||||
MR208 semantics (SE21 likely the same, unverified): Ferie keeps CO at the ferie target (4133) and CWU
|
||||
at economy or off; Party keeps comfort for CO and CWU; weekly intervals are comfort, the rest economy.
|
||||
|
||||
## Economy or comfort period
|
||||
|
||||
- The app does not decide this. It shows the XML values only; the XML has no "period" register.
|
||||
- The controller decides: weekly program intervals (4169–4176) give comfort, everything else economy
|
||||
(MR208 manual, "Programy"). Ferie and Party override it as above.
|
||||
- The effective targets 4201 (room), 4203 (CWU) are the answer. Inference for a UI: compare 4201
|
||||
with 4131 (economy), 4132 (comfort), 4133 (ferie), and 4203 with 4143 / 4144. Ambiguous when two
|
||||
setpoints are equal. 4202 (CO supply target) comes from the heating curve (4134) and is not a setpoint.
|
||||
|
||||
## "Heating CWU / CO" in the app
|
||||
|
||||
The app shows no such state. Its "Stan wyjść" screen lists outputs 4117 Pompa DZ, 4113 BUF, 4112 CO,
|
||||
4118 CWU, 4116 CCW, 4114 Sprężarka (0/33/66/100), 4115 Grzałka CWU, each as an icon for 0, 1 or 100
|
||||
(XML). Heating CO or CWU means output 4112 or 4118 on. 4227 is the controller's own summary of the
|
||||
same thing (PDF); the app ignores it.
|
||||
|
||||
## How the app writes (read-only knowledge for a later milestone)
|
||||
|
||||
| Item | Finding | Source |
|
||||
|---|---|---|
|
||||
| Function codes | FC 3 read, FC 6 write one register. FC 16 exists in the code (`WRITE_REG_MULTI = 16`, `MDBwriteMulti`) but no UI path uses it | `Frisko_MDBRozkaz`, `Frisko_ModbusTCPSystem` |
|
||||
| Unlock or password | None. No other frame is sent; `HasloLAN` 4129 (PDF) is never used. Matches the FC 6 test on 4143 | APK, rx910-se21.md |
|
||||
| Unit id | The "slave" number set per controller in the app, in the MBAP unit byte | `Comm_thread`, `MDBsendData` |
|
||||
| Scales | Value = register / 10 for format `XXX.X`; whole for `XX`. `ster_format` MZ = sign-magnitude: a negative write n is sent as 0x8000 + \|n\| | XML, `JavaToSterownik` |
|
||||
| Range check | Min and max from the XML only, on the phone | XML |
|
||||
| Queue | Writes go to the front of the queue (`AddPriorRozkaz`), ahead of polling | `Frisko_ModbusTCPSystem` |
|
||||
| Confirmation | The reply (function, transaction id, address) is checked; no explicit read-back. The screen shows the new value after the next poll of that register | `MDBreadODP` |
|
||||
| Failure | Up to 3 attempts per request, 500 ms between them; then the socket is closed and re-opened | `MBD_ERROR`, `ERROR_TIME_OUT` |
|
||||
| Delay after write | 200 ms after FC 6, 300 ms after FC 16 | `run()` |
|
||||
| Program selector | Circuit 4177 and day 4167 are plain writes, then the interval registers are polled; no explicit wait. MR208 map prescribes 500 ms between selecting and reading or writing | APK, MR208 map |
|
||||
|
||||
The XML schema also has `menu_set_adr` / `menu_set_val` (write a fixed value on opening a menu);
|
||||
the SE21 XML does not use it.
|
||||
|
||||
## Request pacing
|
||||
|
||||
| Item | Value | Source |
|
||||
|---|---|---|
|
||||
| Pause after each request | 200 ms (300 ms after FC 16); poll thread starts after 250 ms | `Frisko_ModbusTCPSystem.run` |
|
||||
| Poll list empty | 200 ms, then rebuilt: continuous polling, about 5 requests/s while a screen is open | `MDB_TIME_BREAK` |
|
||||
| Timeout | 5000 ms default, user setting (2–5 digits) | `MDB_TIME_OUT`, `SterSettingsActivity` |
|
||||
| Block read | Up to 28 registers per request, gaps up to 5 registers are read through | `MBD_SIZE_OPTYMAL`, `MBD_BREAK_OPTYMAL` |
|
||||
| What is polled | Only the registers on the open screen, plus 4011 | `REG_DATA_STATUS`, `Comm_thread.set_reg` |
|
||||
| Connections | One socket held open, up to 50 reconnect attempts | `disconnect_retries` |
|
||||
|
||||
The vendor documents no limit. The app's 200 ms pause is the only known pacing; our 36 registers in
|
||||
5 blocks took 2.5 s (rx910-se21.md), so a 10 s cycle is far below the app's load.
|
||||
|
||||
## Open questions
|
||||
|
||||
- What are 4222–4225, and the missing values of 4228? (a read-only log over a heating cycle)
|
||||
- Does 4011 exist and move on this firmware? Its relation to 4221.
|
||||
- Is 4226 numbered as the PDF says (see above)?
|
||||
- What does 4119 read when it is implemented, and is the register used at all?
|
||||
- Meaning of 4122 value 2 ("*") and of 4126 `Uklad` (no values given).
|
||||
@@ -28,7 +28,7 @@ unless marked otherwise.
|
||||
| Setpoints (F3) | 4131/4132 CO economy/comfort (5–35), 4143/4144 CWU economy/comfort (5–70) | whole °C |
|
||||
| Protection | 4158 TmaxPc = 52 °C, 4149 TmaxCWU = 55, 4148 HistCWU = 5, 4146 PriorCWU = 1, 4147 heater delay = 20 | read 2026-10-23 |
|
||||
|
||||
Unknown: 4119 `WyCHL` reads −14308. Vendor-app findings, all unverified: [app-knowledge.md](app-knowledge.md).
|
||||
Unknown: 4119 `WyCHL` reads −14308.
|
||||
|
||||
## Weekly program
|
||||
|
||||
|
||||
+13
-15
@@ -15,29 +15,27 @@ Done when: a week on the Pi without manual action, Grafana unchanged. Existing m
|
||||
their names and fields; new data goes into new measurements (ADR-005). The heat pump stays at
|
||||
300 s polls while the Frisko app is still in use (MK01 takes one client).
|
||||
|
||||
- [x] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
|
||||
- [x] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
|
||||
- [x] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
|
||||
- [ ] 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`
|
||||
- [x] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
|
||||
- [x] Deye and Fronius into `electricity.deye`, `electricity.fronius`
|
||||
- [x] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
|
||||
- [x] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
|
||||
- [x] HTTP: `/health` per device, `/snapshot`
|
||||
- [x] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
- [ ] 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)
|
||||
- [x] Three parts with their images and compose (ADR-010); releases by tag, `update.sh` on the Pi
|
||||
- [ ] On the Pi, each step approved: D7, Docker, `gateway.json`, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, first `./update.sh`, then retire both
|
||||
`rpi-measurements` and the old `home-pump.service`
|
||||
- [ ] 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/`; Traefik route, Krosno only and no login for now (ADR-009). Backend, frontend and their images are on master (ADR-008 proposed, ADR-010); open: `REGISTRY_TOKEN`, first release, the stack in homelab-compose (Arcane) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6. On master and wired, off until `commands.enabled` (gateway/README.md: Enabling commands): command service with restores, FC6, token-protected `/commands`, backend `/api/setpoints` and presets, UI tab Ustawienia | Father boosts hot water from his phone, value restored on time |
|
||||
| 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 |
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,14 +0,0 @@
|
||||
# Built on the build machine's own platform; the image only holds the static files and nginx.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
# Tests run in scripts/check.sh; they read the API contract from backend/, outside this context.
|
||||
RUN npm run build
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:1.29-alpine
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=build /src/dist /usr/share/nginx/html
|
||||
ENV BACKEND_URL=http://backend:8080
|
||||
EXPOSE 8080
|
||||
@@ -1,15 +0,0 @@
|
||||
# Frontend
|
||||
|
||||
The web UI (ADR-008, ADR-010): React, TypeScript, Vite, Tailwind and Recharts, Polish texts. In
|
||||
production nginx serves the build and passes `/api/` to the backend, so both share one origin.
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci
|
||||
npm run dev # http://localhost:5173, /api to BACKEND (default http://localhost:8080)
|
||||
npm test # Vitest; reads the API contract from backend/src/test/resources/api
|
||||
npm run build # into dist/
|
||||
docker compose -f frontend/compose.yaml up --build # after the backend's; .env sets HOST and KROSNO_SOURCES
|
||||
```
|
||||
|
||||
The overview picture is `research/ui/house-cutaway.webp`; `python3 scripts/house-assets.py` makes
|
||||
the sizes and the pipe masks in `src/assets/` from it.
|
||||
@@ -1,32 +0,0 @@
|
||||
# The web UI in Kraków (ADR-010): nginx serves the build and passes /api to the backend on the
|
||||
# network home-pump (start backend/compose.yaml first). Traefik routes to it; .env next to this
|
||||
# file sets HOST and KROSNO_SOURCES.
|
||||
name: home-pump-frontend
|
||||
services:
|
||||
frontend:
|
||||
image: git.mariuszpacek.pl/mariusz.pacek/home-pump-frontend:${VERSION:-latest}
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
networks: [home-pump, proxy]
|
||||
labels:
|
||||
com.getarcaneapp.arcane.updater: "false"
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: proxy
|
||||
traefik.http.routers.home-pump.rule: Host(`${HOST:?the public host name}`)
|
||||
traefik.http.routers.home-pump.entrypoints: web-secured
|
||||
traefik.http.routers.home-pump.tls: "true"
|
||||
traefik.http.routers.home-pump.tls.certresolver: http
|
||||
traefik.http.routers.home-pump.service: home-pump
|
||||
traefik.http.services.home-pump.loadbalancer.server.port: "8080"
|
||||
# ADR-009: only Krosno may open it, and no client may name the user who changes a setpoint.
|
||||
traefik.http.middlewares.home-pump-krosno.ipwhitelist.sourcerange: ${KROSNO_SOURCES:?Krosno's address or range}
|
||||
traefik.http.middlewares.home-pump-no-user.headers.customrequestheaders.Remote-User: ""
|
||||
traefik.http.routers.home-pump.middlewares: home-pump-krosno,home-pump-no-user
|
||||
|
||||
networks:
|
||||
home-pump:
|
||||
external: true
|
||||
proxy:
|
||||
external: true
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#0e2a21">
|
||||
<title>Dom Krosno</title>
|
||||
<link rel="icon" href="data:,">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
# The web UI and /api in one origin, so the backend's Origin check and the CSP hold (ADR-010).
|
||||
# BACKEND_URL is filled in at start by the nginx image's envsubst step.
|
||||
|
||||
# Vite names assets after their content, so they never change under the same name.
|
||||
map $uri $cache_control {
|
||||
~^/assets/ "public, max-age=31536000, immutable";
|
||||
default "no-cache";
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_tokens off;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# The same hardening the backend sends with the API (SecurityHeaders.java).
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Cache-Control $cache_control;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass ${BACKEND_URL};
|
||||
# With the port: the backend compares Origin with Host before a write.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
-3442
File diff suppressed because it is too large.
Load diff
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "home-pump-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"recharts": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.8",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Builds the installation picture's assets from research/ui/house-cutaway.webp (1536 x 1024).
|
||||
|
||||
Writes into src/assets: the picture at two widths, and one mask per pipe circuit, found by the
|
||||
pipes' orange and blue colours inside the circuit's region. Schematic.tsx makes a masked
|
||||
circuit flow while its pump runs and greys it out while it stands.
|
||||
|
||||
Usage, from frontend/: python3 scripts/house-assets.py (needs Pillow and NumPy)
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
SOURCE = Path("../research/ui/house-cutaway.webp")
|
||||
OUT = Path("src/assets")
|
||||
|
||||
# Regions as (x0, y0, x1, y1) in the 1536 x 1024 picture.
|
||||
CIRCUITS = {
|
||||
"ground": (150, 585, 832, 1024), # boreholes and the brine pipes under the house
|
||||
"boiler-room": (595, 355, 832, 585), # heat pump to the CWU tank and the CO buffer
|
||||
"floor": (880, 595, 1350, 690), # underfloor heating in the living room
|
||||
}
|
||||
|
||||
|
||||
def pipes(rgb: np.ndarray) -> np.ndarray:
|
||||
r, g, b = (rgb[..., i].astype(int) for i in range(3))
|
||||
orange = (r > 190) & (g > 70) & (g < 170) & (b < 90) & (r - b > 120)
|
||||
blue = (b > 150) & (r < 110) & (b - r > 70) & (g > 60) & (g < 190)
|
||||
return orange | blue
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picture = Image.open(SOURCE).convert("RGB")
|
||||
assert picture.size == (1536, 1024), picture.size
|
||||
for width in (1536, 768):
|
||||
picture.resize((width, width * 2 // 3), Image.LANCZOS).save(OUT / f"house-{width}.webp", quality=80)
|
||||
found = pipes(np.asarray(picture))
|
||||
for name, (x0, y0, x1, y1) in CIRCUITS.items():
|
||||
mask = np.zeros_like(found)
|
||||
mask[y0:y1, x0:x1] = found[y0:y1, x0:x1]
|
||||
# A little wider and softer than the pipe, so the flow covers its edges.
|
||||
image = Image.fromarray((mask * 255).astype("uint8")).filter(ImageFilter.MaxFilter(3))
|
||||
image.filter(ImageFilter.GaussianBlur(0.8)).save(OUT / f"mask-{name}.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { celsius, hour, longDate } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Energy } from "./Energy";
|
||||
import { History } from "./History";
|
||||
import { Overview } from "./Overview";
|
||||
import { usePoll } from "./poll";
|
||||
import { Settings } from "./Settings";
|
||||
import { Status } from "./Status";
|
||||
import { useTheme, type Theme } from "./theme";
|
||||
import type { HeatPump } from "./types";
|
||||
import { Dot } from "./ui";
|
||||
|
||||
const SCREENS = {
|
||||
overview: { label: "Podgląd", icon: "home", view: Overview },
|
||||
energy: { label: "Energia", icon: "bolt", view: Energy },
|
||||
history: { label: "Historia", icon: "chart", view: History },
|
||||
settings: { label: "Ustawienia", icon: "sliders", view: Settings },
|
||||
status: { label: "Stan", icon: "check", view: Status },
|
||||
} satisfies Record<string, { label: string; icon: IconName; view: () => unknown }>;
|
||||
type ScreenId = keyof typeof SCREENS;
|
||||
|
||||
const fromHash = (): ScreenId => {
|
||||
const id = location.hash.slice(1);
|
||||
return id in SCREENS ? (id as ScreenId) : "overview";
|
||||
};
|
||||
|
||||
function useClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
const THEMES: Record<Theme, string> = { auto: "Auto", light: "Jasny", dark: "Ciemny" };
|
||||
|
||||
function ThemeSwitch({ className }: { className: string }) {
|
||||
const [theme, setTheme] = useTheme();
|
||||
return (
|
||||
<div role="group" aria-label="Motyw" className={`flex gap-1 rounded-xl p-1 text-sm ${className}`}>
|
||||
{Object.entries(THEMES).map(([id, label]) => (
|
||||
<button key={id} aria-pressed={id === theme} onClick={() => setTheme(id as Theme)}
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 font-semibold opacity-70 aria-pressed:bg-white aria-pressed:text-side aria-pressed:opacity-100">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Weather, clock and whether the house answers, at the foot of the side bar on a large screen.
|
||||
function SideFooter() {
|
||||
const { data, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const now = useClock();
|
||||
return (
|
||||
<div className="mt-auto space-y-4 px-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon name="sun" className="size-9 text-amber-400" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">{celsius(data?.temperatures.outdoor)}</p>
|
||||
<p className="text-sm text-white/60">Krosno</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white/80">{longDate(now)} · {hour(now)}</p>
|
||||
<ThemeSwitch className="bg-white/10" />
|
||||
<div className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<Dot tone={offline ? "bad" : "ok"} live={!offline} />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold">{offline ? "Brak połączenia" : "System online"}</p>
|
||||
<p className="text-white/60">{data?.time ? `Ostatni odczyt: ${hour(new Date(data.time))}` : "Czekam na odczyt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [screen, setScreen] = useState<ScreenId>(fromHash);
|
||||
useEffect(() => {
|
||||
const onHash = () => setScreen(fromHash());
|
||||
addEventListener("hashchange", onHash);
|
||||
return () => removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
const show = (id: ScreenId) => {
|
||||
location.hash = id;
|
||||
setScreen(id);
|
||||
scrollTo(0, 0);
|
||||
};
|
||||
const View = SCREENS[screen].view;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh lg:pl-64">
|
||||
<nav aria-label="Ekrany"
|
||||
className="fixed inset-x-0 bottom-0 z-10 flex border-t border-edge bg-panel pb-[env(safe-area-inset-bottom)] lg:inset-y-0 lg:left-0 lg:right-auto lg:w-64 lg:flex-col lg:gap-1 lg:border-0 lg:bg-side lg:px-4 lg:py-6 lg:text-white">
|
||||
<div className="mb-8 hidden items-center gap-3 px-2 lg:flex">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand/20 text-brand"><Icon name="leaf" className="size-6" /></span>
|
||||
<div>
|
||||
<p className="text-lg font-bold leading-tight">Dom Krosno</p>
|
||||
<p className="text-xs text-white/60">Pompa ciepła i energia</p>
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(SCREENS).map(([id, s]) => (
|
||||
<button key={id} onClick={() => show(id as ScreenId)} aria-current={id === screen ? "page" : undefined}
|
||||
className="flex min-h-16 flex-1 flex-col items-center justify-center gap-0.5 text-[0.7rem] font-semibold text-soft aria-[current=page]:text-brand aria-[current=page]:shadow-[inset_0_3px_0_var(--color-brand)] lg:min-h-12 lg:flex-none lg:flex-row lg:justify-start lg:gap-3 lg:rounded-xl lg:px-4 lg:text-base lg:text-white/75 lg:hover:bg-white/5 lg:aria-[current=page]:bg-side-hi lg:aria-[current=page]:text-white lg:aria-[current=page]:shadow-none">
|
||||
<Icon name={s.icon} className="size-6" />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="hidden flex-1 lg:flex lg:flex-col"><SideFooter /></div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-[1500px] px-4 pt-5 pb-28 sm:px-6 lg:px-8 lg:pt-8 lg:pb-10">
|
||||
<View />
|
||||
<ThemeSwitch className="mx-auto mt-8 max-w-xs border border-edge bg-panel text-ink lg:hidden" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { Cell, Pie, PieChart } from "recharts";
|
||||
import { kwh, percent, power, when } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Energy as EnergyData } from "./types";
|
||||
import { Banner, Card, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
const INVERTERS = { deye: "Falownik Deye", fronius: "Falownik Fronius" } as const;
|
||||
|
||||
/** Inverters without a fresh reading, in plain words. A source the backend does not list is missing too. */
|
||||
export function silentInverters(e: EnergyData) {
|
||||
return Object.entries(INVERTERS).flatMap(([id, label]) => {
|
||||
const source = e.sources?.find((s) => s.name === id);
|
||||
if (source && !source.stale && source.time) return [];
|
||||
return [{ label, since: source?.time ? `brak odczytu od ${when(source.time)}` : "brak odczytu" }];
|
||||
});
|
||||
}
|
||||
|
||||
const flowing = (watts: number | null) => watts != null && Math.abs(watts) >= FLOWING_W;
|
||||
const magnitude = (watts: number | null) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
// The SDM630 and Deye sign: + the house takes from the grid, − it gives back.
|
||||
export function gridText(watts: number | null) {
|
||||
if (watts == null) return "—";
|
||||
if (!flowing(watts)) return "bez wymiany";
|
||||
return watts > 0 ? "pobór" : "oddawanie";
|
||||
}
|
||||
|
||||
function batteryText(watts: number | null) {
|
||||
if (watts == null) return "—";
|
||||
if (!flowing(watts)) return "bez przepływu";
|
||||
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
|
||||
}
|
||||
|
||||
// Nodes sit around the house; dots run along a line from the source to the sink of each flow.
|
||||
const NODES: Record<"pv" | "grid" | "house" | "battery", { x: number; y: number; label: string; icon: IconName; colour: string }> = {
|
||||
pv: { x: 90, y: 70, label: "Słońce (PV)", icon: "sun", colour: "#f59e0b" },
|
||||
grid: { x: 510, y: 70, label: "Sieć", icon: "plug", colour: "#3b82f6" },
|
||||
house: { x: 300, y: 170, label: "Dom", icon: "home", colour: "#10b981" },
|
||||
battery: { x: 300, y: 300, label: "Bateria", icon: "battery", colour: "#8b5cf6" },
|
||||
};
|
||||
|
||||
function Flow({ from, to, watts }: { from: keyof typeof NODES; to: keyof typeof NODES; watts: number | null }) {
|
||||
const a = NODES[from], b = NODES[to];
|
||||
const on = flowing(watts);
|
||||
const [s, e] = on && watts! < 0 ? [b, a] : [a, b];
|
||||
const d = `M${s.x} ${s.y} L${e.x} ${e.y}`;
|
||||
// Faster dots for more power: 2.5 s per run at 10 W, down to 0.6 s at 5 kW and more.
|
||||
const speed = on ? Math.max(0.6, 2.5 - (Math.min(Math.abs(watts!), 5000) / 5000) * 1.9) : 0;
|
||||
return (
|
||||
<g data-flow={`${from}-${to}`} data-on={on}>
|
||||
<path d={d} stroke={on ? s.colour : "var(--color-off)"} strokeWidth={on ? 5 : 3} strokeLinecap="round" opacity={on ? 0.35 : 0.6} />
|
||||
{on && <path d={d} className="flow" stroke={s.colour} strokeWidth={5} style={{ stroke: s.colour, animationDuration: `${speed}s` }} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({ id, value, note }: { id: keyof typeof NODES; value: string; note?: string }) {
|
||||
const { x, y, label, icon, colour } = NODES[id];
|
||||
return (
|
||||
<g>
|
||||
<rect x={x - 78} y={y - 40} width={156} height={80} rx={18} className="fill-panel" stroke="var(--color-edge)" strokeWidth={1.5} />
|
||||
<g transform={`translate(${x - 64} ${y - 26})`} color={colour}><Icon name={icon} size={28} /></g>
|
||||
<text x={x - 28} y={y - 10} fontSize={14} className="scene-soft">{label}</text>
|
||||
<text x={x - 28} y={y + 16} fontSize={22} fontWeight={700} className="scene-text">{value}</text>
|
||||
{note && <text x={x - 28} y={y + 33} fontSize={12} className="scene-soft">{note}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPicture({ e }: { e: EnergyData }) {
|
||||
return (
|
||||
<svg viewBox="0 0 600 350" className="h-auto w-full" role="img" aria-label="Przepływ energii w domu">
|
||||
<Flow from="pv" to="house" watts={e.pvPowerW} />
|
||||
<Flow from="grid" to="house" watts={e.gridPowerW} />
|
||||
<Flow from="battery" to="house" watts={e.batteryPowerW} />
|
||||
<Node id="pv" value={power(e.pvPowerW)} />
|
||||
<Node id="grid" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Node id="house" value={power(e.loadPowerW)} note="zużycie" />
|
||||
<Node id="battery" value={percent(e.batterySocPct)} note={flowing(e.batteryPowerW) ? (e.batteryPowerW! > 0 ? "rozładowuje" : "ładuje") : undefined} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Today's use split by where it came from; the battery is charged from both, so "own" is what the grid did not give.
|
||||
export function EnergyToday({ e }: { e: EnergyData | undefined }) {
|
||||
const today = e?.today;
|
||||
const load = today?.loadKwh ?? null;
|
||||
const bought = today?.gridBoughtKwh ?? null;
|
||||
const own = load != null && bought != null ? Math.max(0, load - bought) : null;
|
||||
const parts = [
|
||||
{ name: "Z PV i baterii", value: own ?? 0, colour: "#10b981" },
|
||||
{ name: "Z sieci", value: bought ?? 0, colour: "#3b82f6" },
|
||||
];
|
||||
const partial = e ? silentInverters(e).length > 0 : false;
|
||||
return (
|
||||
<Card title="Energia – dziś" aside={partial ? <Pill tone="warn">Tylko część danych</Pill> : undefined}>
|
||||
{!e ? <Loading /> : (
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<div className="relative size-36 shrink-0">
|
||||
<PieChart width={144} height={144}>
|
||||
<Pie data={parts} dataKey="value" innerRadius={50} outerRadius={68} startAngle={90} endAngle={-270}
|
||||
stroke="none" isAnimationActive={false}>
|
||||
{parts.map((p) => <Cell key={p.name} fill={p.colour} />)}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
<div className="absolute inset-0 grid place-items-center text-center">
|
||||
<div><p className="text-xl font-bold tabular-nums">{kwh(load)}</p><p className="text-xs text-soft">zużycie</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-44 flex-1 text-sm">
|
||||
{parts.map((p) => (
|
||||
<Row key={p.name} label={<><span className="inline-block size-2.5 rounded-full" style={{ background: p.colour }} />{p.name}</>}
|
||||
value={kwh(p.name === "Z sieci" ? bought : own)} />
|
||||
))}
|
||||
<Row label="Produkcja PV" value={kwh(today?.pvKwh)} />
|
||||
<Row label="Oddano do sieci" value={kwh(today?.gridSoldKwh)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon, label, value, note }: { icon: IconName; label: string; value: string; note?: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-10 place-items-center rounded-xl bg-brand-soft text-brand"><Icon name={icon} /></span>
|
||||
<div>
|
||||
<p className="text-sm text-soft">{label}</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{value}</p>
|
||||
{note && <p className="text-xs text-soft">{note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Energy() {
|
||||
const { data: e, offline } = usePoll<EnergyData>("api/energy");
|
||||
const title = <PageTitle title="Energia" subtitle="Falowniki Deye i Fronius, licznik domu" />;
|
||||
if (!e) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const missing = silentInverters(e);
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<Freshness offline={offline} time={e.time} />
|
||||
{missing.length > 0 && <Banner tone="warn">{missing.map((m) => <div key={m.label}>{m.label}: {m.since}</div>)}</Banner>}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Energia teraz" className="lg:col-span-2"><FlowPicture e={e} /></Card>
|
||||
<EnergyToday e={e} />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:col-span-3 lg:grid-cols-4">
|
||||
<Stat icon="sun" label="Produkcja PV" value={power(e.pvPowerW)} note={missing.length ? "tylko część: brakuje danych z falownika" : "Deye + Fronius"} />
|
||||
<Stat icon="home" label="Zużycie domu" value={power(e.loadPowerW)} />
|
||||
<Stat icon="plug" label="Sieć" value={power(magnitude(e.gridPowerW))} note={gridText(e.gridPowerW)} />
|
||||
<Stat icon="battery" label="Bateria" value={percent(e.batterySocPct)} note={batteryText(e.batteryPowerW)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { number } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import { TimeChart, toRows, type Range } from "./TimeChart";
|
||||
import type { History as HistoryData } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
outdoor_temp: ["Temperatura na zewnątrz", "#3b82f6"],
|
||||
indoor_temp: ["Temperatura w domu", "#10b981"],
|
||||
co_buffer_temp: ["Bufor CO", "#ef4444"],
|
||||
cwu_temp: ["Ciepła woda (CWU)", "#f59e0b"],
|
||||
ground_in_temp: ["Gruntowe: do pompy", "#22a6c9"],
|
||||
ground_out_temp: ["Gruntowe: z pompy", "#2f7fe0"],
|
||||
pump_outlet_temp: ["Wyjście pompy ciepła", "#f26b1d"],
|
||||
grid_power: ["Moc z sieci (+ pobór, − oddawanie)", "#3b82f6"],
|
||||
pv_power: ["Produkcja PV", "#f59e0b"],
|
||||
load_power: ["Zużycie domu", "#10b981"],
|
||||
battery_soc: ["Bateria (naładowanie)", "#8b5cf6"],
|
||||
} as const;
|
||||
type SeriesId = keyof typeof SERIES;
|
||||
|
||||
const RANGES: Record<Range, string> = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
function Chart({ series, range }: { series: SeriesId; range: Range }) {
|
||||
// One poller per choice, so an answer is never shown for another one.
|
||||
const { data, offline } = usePoll<HistoryData>(`api/history?series=${series}&range=${range}`);
|
||||
if (!data) return <><Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
const values = data.points.map((p) => p.v);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
const [name, colour] = SERIES[series];
|
||||
return (
|
||||
<>
|
||||
{offline && <Freshness offline />}
|
||||
{data.points.length < 2 ? <p className="py-8 text-soft">Za mało danych w tym okresie.</p> : (
|
||||
<>
|
||||
<TimeChart rows={toRows({ [series]: data.points })} lines={[{ key: series, name, colour }]}
|
||||
unit={data.unit} range={range} height={320} area />
|
||||
<p className="mt-2 text-sm text-soft">
|
||||
Najmniej {number(Math.min(...values), digits)} {data.unit} · najwięcej {number(Math.max(...values), digits)} {data.unit}.
|
||||
Dotknij wykresu, aby zobaczyć wartość.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function History() {
|
||||
const [series, setSeries] = useState<SeriesId>("cwu_temp");
|
||||
const [range, setRange] = useState<Range>("24h");
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="Historia" subtitle="Dane z bazy pomiarów" />
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<select value={series} onChange={(e) => setSeries(e.target.value as SeriesId)} aria-label="Wielkość"
|
||||
className="min-h-12 flex-1 rounded-xl border-2 border-edge bg-panel px-3 font-semibold">
|
||||
{Object.entries(SERIES).map(([id, [name]]) => <option key={id} value={id}>{name}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2 rounded-xl bg-page p-1">
|
||||
{Object.entries(RANGES).map(([id, name]) => (
|
||||
<button key={id} onClick={() => setRange(id as Range)} aria-pressed={id === range}
|
||||
className="min-h-10 rounded-lg px-4 font-semibold text-soft aria-pressed:bg-panel aria-pressed:text-brand aria-pressed:shadow-sm">
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Chart series={series} range={range} />
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { example, serve } from "./fixtures";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
const overview = async () => (await import("./Overview")).Overview;
|
||||
const fresh = (hp: HeatPump) => ({ ...hp, time: new Date().toISOString() });
|
||||
const history = { series: "x", unit: "°C", points: [] };
|
||||
|
||||
function serveHeatPump(hp: HeatPump) {
|
||||
return serve({ "api/heat-pump": hp, "api/energy": example("energy"), "api/history": history });
|
||||
}
|
||||
|
||||
const flowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-circuit][data-on=true]")].map((p) => p.getAttribute("data-circuit"));
|
||||
const glowing = (container: HTMLElement) =>
|
||||
[...container.querySelectorAll("[data-glow][data-on=true]")].map((p) => p.getAttribute("data-glow"));
|
||||
|
||||
describe("Overview", () => {
|
||||
it("draws a resting pump with nothing flowing", async () => {
|
||||
serveHeatPump(fresh(example("heat-pump")));
|
||||
const Overview = await overview();
|
||||
const { container } = render(<Overview />);
|
||||
|
||||
expect(await screen.findAllByText("Praca prawidłowa")).not.toHaveLength(0);
|
||||
expect(flowing(container)).toEqual([]);
|
||||
expect(glowing(container)).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes the pipes of the running pumps flow", async () => {
|
||||
const hp = fresh(example<HeatPump>("heat-pump"));
|
||||
serveHeatPump({ ...hp, state: "HEATING_CWU", compressorPercent: 60, pumps: { ...hp.pumps, cwu: true, ground: true } });
|
||||
const Overview = await overview();
|
||||
const { container } = render(<Overview />);
|
||||
|
||||
expect(await screen.findAllByText("Grzeje wodę (CWU)")).not.toHaveLength(0);
|
||||
expect(flowing(container)).toEqual(["ground", "boiler-room"]);
|
||||
expect(glowing(container)).toEqual(["heat-pump", "cwu"]);
|
||||
});
|
||||
|
||||
it("shows the alarm in words", async () => {
|
||||
serveHeatPump({ ...fresh(example("heat-pump")), alarm: true, alarmCode: 5, alarmText: "Usterka czujnika temperatury CWU" });
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("ALARM pompy ciepła. Usterka czujnika temperatury CWU.");
|
||||
});
|
||||
|
||||
it("claims nothing before the first reading", async () => {
|
||||
serveHeatPump(example("heat-pump-unread"));
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByText("Brak odczytu z pompy")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Praca prawidłowa")).toBeNull();
|
||||
});
|
||||
|
||||
it("warns when the reading is old", async () => {
|
||||
serveHeatPump(example("heat-pump")); // its time is long past
|
||||
const Overview = await overview();
|
||||
render(<Overview />);
|
||||
|
||||
expect(await screen.findByText(/Dane są nieaktualne/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { celsius, degrees, hour, longDate, percent, power } from "./format";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { usePoll } from "./poll";
|
||||
import { Schematic } from "./Schematic";
|
||||
import { Legend, TimeChart, toRows, type Line } from "./TimeChart";
|
||||
import type { Energy, HeatPump, History, PumpState } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Pill, Row } from "./ui";
|
||||
import { EnergyToday, gridText } from "./Energy";
|
||||
|
||||
const HEADLINES: Record<PumpState, string> = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
HEATING_CWU: "Grzeje wodę (CWU)",
|
||||
HEATING_CO: "Grzeje dom (CO)",
|
||||
HEATING_BOTH: "Grzeje dom i wodę",
|
||||
};
|
||||
|
||||
export const PUMP_NAMES = {
|
||||
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
} as const;
|
||||
|
||||
const HISTORY_MS = 5 * 60000; // history moves slowly; the backend asks InfluxDB for every request
|
||||
|
||||
const TEMPERATURE_LINES: Line[] = [
|
||||
{ key: "cwu_temp", name: "CWU", colour: "#f59e0b" },
|
||||
{ key: "co_buffer_temp", name: "Bufor CO", colour: "#ef4444" },
|
||||
{ key: "indoor_temp", name: "W domu", colour: "#10b981" },
|
||||
{ key: "outdoor_temp", name: "Na zewnątrz", colour: "#3b82f6", dashed: true },
|
||||
];
|
||||
|
||||
const onOff = (on: boolean | null, yes: string, no: string) => (on == null ? "—" : on ? yes : no);
|
||||
|
||||
function StatusPill({ hp }: { hp: HeatPump }) {
|
||||
if (hp.alarm) return <Pill tone="bad" live>Alarm</Pill>;
|
||||
if (hp.state == null) return <Pill tone="off">Stan nieznany</Pill>;
|
||||
return hp.state === "IDLE" ? <Pill tone="ok">Praca prawidłowa</Pill> : <Pill tone="hot" live>{HEADLINES[hp.state]}</Pill>;
|
||||
}
|
||||
|
||||
function Tile({ icon, title, value, note, status, children }: {
|
||||
icon: IconName; title: string; value: ReactNode; note: string; status: ReactNode; children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-soft text-brand">
|
||||
<Icon name={icon} className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{title}</p>
|
||||
<p className="text-2xl font-bold tabular-nums tracking-tight">{value}</p>
|
||||
<p className="text-xs text-soft">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-3">{status}</div>
|
||||
<div className="mt-auto text-sm [&>div]:text-sm">{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const running = (on: boolean | null, yes = "Pracuje") =>
|
||||
on == null ? <Pill tone="off">Brak odczytu</Pill> : on ? <Pill tone="ok" live>{yes}</Pill> : <Pill tone="off">Stoi</Pill>;
|
||||
|
||||
function Temperatures({ hp }: { hp: HeatPump }) {
|
||||
const t = hp.temperatures;
|
||||
const rows: [IconName, string, number | null, string][] = [
|
||||
["thermometer", "Na zewnątrz", t.outdoor, "text-cold"],
|
||||
["thermometer", "Średnia zewnętrzna", t.outdoorAvg, "text-cold"],
|
||||
["home", "W domu", t.indoor, "text-brand"],
|
||||
["heat", "Bufor CO", t.coBuffer, "text-hot"],
|
||||
["drop", "Ciepła woda (CWU)", t.cwu, "text-hot"],
|
||||
["fan", "Wyjście pompy", t.pumpOutlet, "text-hot"],
|
||||
];
|
||||
return (
|
||||
<Card title="Temperatury">
|
||||
{rows.map(([icon, label, value, colour]) => (
|
||||
<Row key={label} label={label} value={celsius(value)} icon={<span className={colour}><Icon name={icon} /></span>} />
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Every moving part as a chip under the picture: what runs now is green and pulses.
|
||||
function Devices({ hp }: { hp: HeatPump }) {
|
||||
const compressor = hp.compressorPercent == null ? null : hp.compressorPercent > 0;
|
||||
const items: [string, boolean | null, string][] = [
|
||||
["Sprężarka", compressor, compressor ? percent(hp.compressorPercent) : "stoi"],
|
||||
["Grzałka", hp.heaterOn, onOff(hp.heaterOn, "włączona", "wyłączona")],
|
||||
...Object.entries(PUMP_NAMES).map(([key, name]): [string, boolean | null, string] => {
|
||||
const on = hp.pumps[key as keyof typeof PUMP_NAMES];
|
||||
return [name, on, onOff(on, "pracuje", "stoi")];
|
||||
}),
|
||||
];
|
||||
return (
|
||||
<ul aria-label="Urządzenia" className="mt-3 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{items.map(([name, on, text]) => (
|
||||
<li key={name} title={`${name}: ${text}`}
|
||||
className={`flex items-center gap-2 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs sm:text-sm ${on ? "border-brand/30 bg-brand-soft" : "border-edge text-soft"}`}>
|
||||
<Dot tone={on ? "ok" : "off"} live={!!on} />
|
||||
<span>{name}</span>
|
||||
{/* On a phone the colour says it; the word needs the room of a larger screen. */}
|
||||
<b className={`hidden sm:inline ${on ? "text-brand" : "text-soft"}`}>{text}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Targets({ hp }: { hp: HeatPump }) {
|
||||
const s = hp.setpoints;
|
||||
return (
|
||||
<Card title="Cele i nastawy">
|
||||
<Row label="Pokój, cel teraz" value={celsius(hp.targets.room)} />
|
||||
<Row label="Woda grzewcza CO, cel" value={celsius(hp.targets.co)} />
|
||||
<Row label="Ciepła woda CWU, cel" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="CWU oszczędnie · komfort" value={`${degrees(s.cwuEconomy)} · ${degrees(s.cwuComfort)}`} />
|
||||
<Row label="CO oszczędnie · komfort" value={`${degrees(s.coEconomy)} · ${degrees(s.coComfort)}`} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function useTemperatureHistory() {
|
||||
const cwu = usePoll<History>("api/history?series=cwu_temp&range=24h", HISTORY_MS);
|
||||
const co = usePoll<History>("api/history?series=co_buffer_temp&range=24h", HISTORY_MS);
|
||||
const indoor = usePoll<History>("api/history?series=indoor_temp&range=24h", HISTORY_MS);
|
||||
const outdoor = usePoll<History>("api/history?series=outdoor_temp&range=24h", HISTORY_MS);
|
||||
const all = [cwu, co, indoor, outdoor].map((p) => p.data);
|
||||
if (all.some((d) => !d)) return null;
|
||||
return toRows(Object.fromEntries(all.map((d) => [d!.series, d!.points])));
|
||||
}
|
||||
|
||||
function TemperatureChart() {
|
||||
const rows = useTemperatureHistory();
|
||||
return (
|
||||
<Card title="Temperatury – ostatnie 24 h" className="lg:col-span-2">
|
||||
<Legend lines={TEMPERATURE_LINES} />
|
||||
{rows == null ? <Loading /> : rows.length < 2
|
||||
? <p className="text-soft">Za mało danych w tym okresie.</p>
|
||||
: <TimeChart rows={rows} lines={TEMPERATURE_LINES} unit="°C" range="24h" />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NoReading() {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xl font-bold">Brak odczytu z pompy</p>
|
||||
<p className="mt-1 text-soft">Dom jeszcze nie odczytał pompy ciepła. Odczyt pojawi się tutaj, gdy będzie dostępny.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { data: hp, offline } = usePoll<HeatPump>("api/heat-pump");
|
||||
const { data: e } = usePoll<Energy>("api/energy");
|
||||
const now = hp?.time ? new Date(hp.time) : null;
|
||||
const title = (
|
||||
<PageTitle title="Dom"
|
||||
subtitle={now ? `Pompa ciepła i energia · odczyt z ${hour(now)}, ${longDate(now)}` : "Pompa ciepła i energia"} />
|
||||
);
|
||||
if (!hp) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<Freshness offline={offline} time={hp.time} stale={hp.stale} />
|
||||
{/* alarmText is one of the backend's fixed Polish sentences for register 4011, never user input. */}
|
||||
{hp.alarm && (
|
||||
<div role="alert" className="mb-4 flex items-start gap-3 rounded-xl border-2 border-alarm bg-red-50 px-4 py-3 font-semibold text-alarm dark:bg-red-950/60 dark:text-red-200">
|
||||
<Icon name="alert" className="size-6 shrink-0" />
|
||||
<span>ALARM pompy ciepła. {hp.alarmText ? `${hp.alarmText}. ` : ""}Sprawdź sterownik w domu.</span>
|
||||
</div>
|
||||
)}
|
||||
{hp.time == null ? <NoReading /> : (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card title="Schemat instalacji" aside={<StatusPill hp={hp} />} className="lg:col-span-2 lg:row-span-2">
|
||||
<Schematic hp={hp} pvPowerW={e?.pvPowerW} />
|
||||
<Devices hp={hp} />
|
||||
<p className="mt-3 text-xs text-soft">
|
||||
Rury płyną, gdy pracuje pompa obiegu, a stojące są szare. Kolektor słoneczny nie ma czujnika, więc nie wiemy, czy jego obieg pracuje.
|
||||
</p>
|
||||
</Card>
|
||||
<Temperatures hp={hp} />
|
||||
<Targets hp={hp} />
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:col-span-3 xl:grid-cols-5">
|
||||
<Tile icon="fan" title="Pompa ciepła" note="sprężarka"
|
||||
value={hp.compressorPercent == null ? "—" : `${hp.compressorPercent} %`}
|
||||
status={running(hp.compressorPercent == null ? null : hp.compressorPercent > 0)}>
|
||||
<Row label="Wyjście" value={celsius(hp.temperatures.pumpOutlet)} />
|
||||
<Row label="Grzałka" value={onOff(hp.heaterOn, "włączona", "wyłączona")} />
|
||||
</Tile>
|
||||
<Tile icon="drop" title="Zbiornik CWU" note="ciepła woda" value={celsius(hp.temperatures.cwu)}
|
||||
status={running(hp.pumps.cwu, "Grzeje wodę")}>
|
||||
<Row label="Cel teraz" value={celsius(hp.targets.cwu)} />
|
||||
<Row label="Cyrkulacja" value={onOff(hp.pumps.circulation, "pracuje", "stoi")} />
|
||||
</Tile>
|
||||
<Tile icon="heat" title="Ogrzewanie CO" note="bufor" value={celsius(hp.temperatures.coBuffer)}
|
||||
status={running(hp.pumps.co, "Grzeje dom")}>
|
||||
<Row label="Cel bufora" value={celsius(hp.targets.co)} />
|
||||
<Row label="Pokój" value={celsius(hp.temperatures.indoor)} />
|
||||
</Tile>
|
||||
<Tile icon="borehole" title="Odwierty" note="do pompy" value={celsius(hp.temperatures.groundIn)}
|
||||
status={running(hp.pumps.ground)}>
|
||||
<Row label="Z pompy" value={celsius(hp.temperatures.groundOut)} />
|
||||
<Row label="Różnica" value={hp.temperatures.groundIn != null && hp.temperatures.groundOut != null
|
||||
? celsius(hp.temperatures.groundIn - hp.temperatures.groundOut) : "—"} />
|
||||
</Tile>
|
||||
<Tile icon="sun" title="Prąd teraz" note="produkcja PV" value={power(e?.pvPowerW)}
|
||||
status={e ? <Pill tone={(e.gridPowerW ?? 0) < 0 ? "ok" : "off"}>Sieć: {gridText(e.gridPowerW)}</Pill> : <Pill tone="off">Brak danych</Pill>}>
|
||||
<Row label="Zużycie domu" value={power(e?.loadPowerW)} />
|
||||
<Row label="Bateria" value={percent(e?.batterySocPct)} />
|
||||
</Tile>
|
||||
</div>
|
||||
<TemperatureChart />
|
||||
<EnergyToday e={e} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import house1536 from "./assets/house-1536.webp";
|
||||
import house768 from "./assets/house-768.webp";
|
||||
import maskBoilerRoom from "./assets/mask-boiler-room.png";
|
||||
import maskFloor from "./assets/mask-floor.png";
|
||||
import maskGround from "./assets/mask-ground.png";
|
||||
import { celsius, number, percent, power } from "./format";
|
||||
import type { HeatPump } from "./types";
|
||||
|
||||
// The installation as it is now, over the house picture (research/ui/house-cutaway.webp,
|
||||
// assets from scripts/house-assets.py). Coordinates are pixels of the 1536 × 1024 picture.
|
||||
// A pipe circuit flows while its pump runs and is greyed out while it stands; the solar
|
||||
// collector has no sensor, so it is never animated.
|
||||
|
||||
const W = 1536, H = 1024;
|
||||
|
||||
const CIRCUITS = {
|
||||
ground: { mask: maskGround, name: "Odwierty (pompa gruntowa)" },
|
||||
"boiler-room": { mask: maskBoilerRoom, name: "Kotłownia (pompy CWU i bufora)" },
|
||||
floor: { mask: maskFloor, name: "Podłogówka (pompa CO)" },
|
||||
} as const;
|
||||
type Circuit = keyof typeof CIRCUITS;
|
||||
|
||||
function Pipes({ id, on }: { id: Circuit; on: boolean }) {
|
||||
return (
|
||||
<g data-circuit={id} data-on={on}>
|
||||
<title>{`${CIRCUITS[id].name}: ${on ? "płynie" : "stoi"}`}</title>
|
||||
<mask id={`mask-${id}`} maskUnits="userSpaceOnUse" x="0" y="0" width={W} height={H}>
|
||||
<image href={CIRCUITS[id].mask} width={W} height={H} />
|
||||
</mask>
|
||||
{on
|
||||
? <g mask={`url(#mask-${id})`}><rect className="shimmer" x={-120} width={W + 240} height={H} fill="url(#stripes)" /></g>
|
||||
: <rect width={W} height={H} mask={`url(#mask-${id})`} className="fill-[#a3acb3]" opacity="0.8" />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// A pulsing outline around a device that works now.
|
||||
function Glow({ x, y, w, h, colour, on, id }: { x: number; y: number; w: number; h: number; colour: string; on: boolean; id: string }) {
|
||||
return (
|
||||
<rect data-glow={id} data-on={on} x={x} y={y} width={w} height={h} rx={14} fill="none"
|
||||
stroke={colour} strokeWidth={on ? 6 : 0} className={on ? "glow" : ""} filter="url(#soft)" />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A label with a value, joined by a thin line to the thing it describes; the box grows to fit
|
||||
* its text once the browser has measured it. Hidden on a phone.
|
||||
*/
|
||||
function Callout({ x, y, title, value, to, tone = "var(--color-ink)" }: {
|
||||
x: number; y: number; title: string; value: ReactNode; to?: [number, number]; tone?: string;
|
||||
}) {
|
||||
const texts = useRef<SVGGElement>(null);
|
||||
const [w, setW] = useState(220);
|
||||
useLayoutEffect(() => {
|
||||
const box = texts.current?.getBBox?.(); // absent in tests without layout
|
||||
if (box) setW(Math.ceil(box.width) + 40);
|
||||
});
|
||||
const h = 92;
|
||||
return (
|
||||
<g className="label">
|
||||
{to && <>
|
||||
<line x1={Math.min(Math.max(to[0], x + 24), x + w - 24)} y1={to[1] < y ? y : y + h} x2={to[0]} y2={to[1]}
|
||||
stroke="#fff" strokeWidth={3} opacity={0.9} />
|
||||
<circle cx={to[0]} cy={to[1]} r={7} fill="#fff" stroke={tone} strokeWidth={3} />
|
||||
</>}
|
||||
<rect x={x} y={y} width={w} height={h} rx={18} className="fill-panel" opacity={0.94} filter="url(#shadow)" />
|
||||
<g ref={texts}>
|
||||
<text x={x + 20} y={y + 36} fontSize={24} fontWeight={600} className="scene-soft">{title}</text>
|
||||
<text x={x + 20} y={y + 74} fontSize={32} fontWeight={700} fill={tone}>{value}</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const target = (value: number | null) => <tspan fontSize={22} className="scene-soft">{` cel ${number(value, 0)}°`}</tspan>;
|
||||
|
||||
export function Schematic({ hp, pvPowerW }: { hp: HeatPump; pvPowerW: number | null | undefined }) {
|
||||
const { temperatures: t, targets, pumps } = hp;
|
||||
const ground = !!pumps.ground, buffer = !!pumps.buffer, cwu = !!pumps.cwu, co = !!pumps.co;
|
||||
const compressor = (hp.compressorPercent ?? 0) > 0;
|
||||
const sunny = (pvPowerW ?? 0) >= 10;
|
||||
|
||||
return (
|
||||
<div className="scene relative overflow-hidden rounded-xl bg-white">
|
||||
<img src={house1536} srcSet={`${house768} 768w, ${house1536} 1536w`} sizes="(min-width: 1024px) 60vw, 100vw"
|
||||
width={W} height={H} alt="" className="block h-auto w-full dark:brightness-90" />
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 h-full w-full select-none" role="img"
|
||||
aria-label="Schemat instalacji: odwierty, pompa ciepła w kotłowni, zbiornik CWU, bufor CO, podłogówka, kolektor i fotowoltaika">
|
||||
<defs>
|
||||
<pattern id="stripes" width="64" height="64" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
|
||||
<rect width="64" height="64" fill="#fff" opacity="0.15" />
|
||||
<rect width="18" height="64" fill="#fff" opacity="0.95" />
|
||||
</pattern>
|
||||
<linearGradient id="sweep" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0.35" stopColor="#fff" stopOpacity="0" />
|
||||
<stop offset="0.5" stopColor="#fff" stopOpacity="0.55" />
|
||||
<stop offset="0.65" stopColor="#fff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="4" /></filter>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="140%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" floodOpacity="0.18" />
|
||||
</filter>
|
||||
<clipPath id="pv"><polygon points="985,188 1226,193 1369,328 1100,319" /></clipPath>
|
||||
</defs>
|
||||
|
||||
<Pipes id="ground" on={ground} />
|
||||
<Pipes id="boiler-room" on={cwu || buffer} />
|
||||
<Pipes id="floor" on={co} />
|
||||
|
||||
{/* Photovoltaics: a light sweeps over the panels while they produce */}
|
||||
{sunny && <g clipPath="url(#pv)" data-pv="on"><rect className="sweep" x={985} y={180} width={400} height={160} fill="url(#sweep)" /></g>}
|
||||
|
||||
<Glow id="heat-pump" x={506} y={422} w={102} h={188} colour="#10b981" on={compressor} />
|
||||
<Glow id="cwu" x={639} y={402} w={90} h={216} colour="#f97316" on={cwu} />
|
||||
<Glow id="buffer" x={746} y={489} w={70} h={134} colour="#ef4444" on={buffer} />
|
||||
{compressor && <circle className="beat" cx={575} cy={462} r={6} fill="#22c55e" />}
|
||||
{hp.heaterOn && <rect className="glow" x={518} y={560} width={78} height={14} rx={7} fill="#ef4444" />}
|
||||
|
||||
{/* Warmth rising from the floor while the CO pump runs */}
|
||||
{co && [960, 1080, 1200, 1300].map((x, i) => (
|
||||
<path key={x} className="rise" style={{ animationDelay: `${i * 0.6}s` }} fill="none" stroke="#f97316"
|
||||
strokeWidth={5} strokeLinecap="round" opacity="0" d={`M${x} 600 c-12-14 12-24 0-38 c-12-14 12-24 0-38`} />
|
||||
))}
|
||||
|
||||
<Callout x={36} y={40} title="Na zewnątrz" value={celsius(t.outdoor)} />
|
||||
<Callout x={210} y={400} title="Pompa ciepła" to={[512, 470]} tone={compressor ? "var(--color-brand)" : undefined}
|
||||
value={compressor ? `sprężarka ${percent(hp.compressorPercent)}` : "stoi"} />
|
||||
<Callout x={540} y={215} title="Ciepła woda (CWU)" to={[684, 410]} tone={cwu ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.cwu)}{target(targets.cwu)}</>} />
|
||||
<Callout x={660} y={730} title="Bufor CO" to={[781, 617]} tone={buffer ? "#dc2626" : undefined}
|
||||
value={<>{celsius(t.coBuffer)}{target(targets.co)}</>} />
|
||||
<Callout x={380} y={850} title="Odwierty: do pompy · z pompy" to={[365, 880]}
|
||||
value={`${celsius(t.groundIn)} · ${celsius(t.groundOut)}`} />
|
||||
<Callout x={1000} y={370} title="W domu" tone={co ? "#ea580c" : undefined}
|
||||
value={<>{celsius(t.indoor)}{target(targets.room)}</>} />
|
||||
<Callout x={1250} y={50} title="Fotowoltaika" to={[1230, 230]} tone={sunny ? "#d97706" : undefined} value={power(pvPowerW)} />
|
||||
<Callout x={470} y={95} title="Kolektor słoneczny" to={[790, 190]}
|
||||
value={<tspan fontSize={24} className="scene-soft">bez czujnika</tspan>} />
|
||||
|
||||
{hp.alarm && (
|
||||
<g className="glow">
|
||||
<rect x={492} y={392} width={130} height={40} rx={20} fill="var(--color-alarm)" />
|
||||
<text x={557} y={420} fontSize={24} fontWeight={700} textAnchor="middle" fill="#fff">ALARM</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { answer, example, serve } from "./fixtures";
|
||||
import type { Setpoints } from "./types";
|
||||
|
||||
// A fresh module per test: the poller keeps its answers between screens, not between tests.
|
||||
const settings = async () => (await import("./Settings")).Settings;
|
||||
|
||||
// A sentence split by <b> into several text nodes.
|
||||
const paragraph = (text: string) => (_: string, el: Element | null) => el?.tagName === "P" && el.textContent === text;
|
||||
|
||||
const posts = (fetch: ReturnType<typeof serve>) => fetch.mock.calls.filter(([, init]) => init?.method === "POST");
|
||||
|
||||
describe("Settings", () => {
|
||||
it("writes only after the summary is confirmed", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
expect(screen.getByText("Ustawię 9 °C. Za 2 godz. wrócimy do 8 °C.")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ setpoint: "cwu_economy", value: 9, hours: 2 });
|
||||
expect(await screen.findByText(/^Zapisano\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never claims nothing changed when the answer is lost, and does not retry", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => Promise.reject(new TypeError("network")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Mniej: Ogrzewanie domu, komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Nie wiadomo, czy zmiana doszła do pompy");
|
||||
expect(posts(fetch)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says nothing changed when the pump refuses", async () => {
|
||||
serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-rejected"), 422));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByLabelText("Więcej: Ciepła woda, komfort"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zapisz" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, zapisz" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.");
|
||||
});
|
||||
|
||||
it("shows what a preset changes and sends it only on yes, reporting each change", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-partial"), 502));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wyjazd" }));
|
||||
expect(screen.getByText(paragraph("Ciepła woda, komfort: 10 °C do odwołania."))).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Tak, ustaw" }));
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/away");
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Tylko część zmian jest zapisana.");
|
||||
expect(alert).toHaveTextContent("Ciepła woda, komfort: zapisano");
|
||||
expect(alert).toHaveTextContent("Ogrzewanie domu, komfort: brak odpowiedzi, nie wiadomo, czy doszło do pompy");
|
||||
});
|
||||
|
||||
it("does not offer a change built on a missing reading", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints[2].value = null;
|
||||
serve({ "api/setpoints": data });
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
expect(await screen.findByLabelText("Więcej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda, oszczędnie")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz (brak odczytu)" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,244 +0,0 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { postJson, WriteError } from "./api";
|
||||
import { degrees, duration, when } from "./format";
|
||||
import { reload, usePoll } from "./poll";
|
||||
import type { Change, CommandResult, LineStatus, Setpoint, Setpoints } from "./types";
|
||||
import { Card, Freshness, Loading, PageTitle } from "./ui";
|
||||
|
||||
const URL = "api/setpoints";
|
||||
const NAMES: Record<string, string> = {
|
||||
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" },
|
||||
];
|
||||
// A write that loses its answer may still have reached the pump, so never claim that nothing changed.
|
||||
export const WRITE_LOST = "Dom nie odpowiada. Nie wiadomo, czy zmiana doszła do pompy: sprawdź wartości, gdy połączenie wróci.";
|
||||
const ERROR_TEXT: Record<number, string> = {
|
||||
400: "Nieprawidłowa wartość. Nic nie zostało zmienione.",
|
||||
404: "Nie ma takiego ustawienia. Nic nie zostało zmienione.",
|
||||
};
|
||||
const RESULT_TEXT: Record<string, string> = {
|
||||
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.",
|
||||
PARTIAL: "Tylko część zmian jest zapisana. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
const LINE_TEXT: Record<LineStatus, string> = {
|
||||
APPLIED: "zapisano",
|
||||
REJECTED: "pompa nie przyjęła zmiany",
|
||||
FAILED: "nie wiadomo, czy doszło do pompy",
|
||||
UNKNOWN: "brak odpowiedzi, nie wiadomo, czy doszło do pompy",
|
||||
NOT_ATTEMPTED: "nie wysłano",
|
||||
};
|
||||
|
||||
type Result = (CommandResult & { changes?: Change[]; text?: string }) | null;
|
||||
|
||||
const name = (id: string) => NAMES[id] ?? id;
|
||||
const find = (data: Setpoints, id: string) => data.setpoints.find((s) => s.name === id)!;
|
||||
// The gateway has no reading until its first poll succeeds; never show or build on a made-up 0 °C.
|
||||
const unread = (s: Setpoint | undefined) => s?.value == null;
|
||||
const presetReady = (data: Setpoints, changes: Change[]) => changes.every((c) => !unread(find(data, c.setpoint)));
|
||||
|
||||
// A new temporary change keeps the value from before the first one (ADR-006), so that is what returns.
|
||||
const returnValue = (data: Setpoints, id: string) =>
|
||||
data.restores.find((r) => r.setpoint === id)?.originalValue ?? find(data, id).value;
|
||||
|
||||
function Button({ primary = false, big = false, children, ...props }: {
|
||||
primary?: boolean; big?: boolean; children: ReactNode; onClick: () => void; disabled?: boolean;
|
||||
"aria-label"?: string; "aria-pressed"?: boolean;
|
||||
}) {
|
||||
const look = primary
|
||||
? "border-brand bg-brand text-white dark:text-side"
|
||||
: "border-brand bg-panel text-brand aria-pressed:bg-brand aria-pressed:text-white dark:aria-pressed:text-side";
|
||||
return (
|
||||
<button {...props}
|
||||
className={`flex-1 rounded-xl border-2 px-3 font-bold transition active:scale-[0.98] disabled:opacity-40 ${look} ${big ? "min-h-16 text-lg" : "min-h-12"}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultBox({ result }: { result: Result }) {
|
||||
if (!result) return null;
|
||||
const ok = result.status === "APPLIED";
|
||||
const tone = ok ? "border-brand bg-brand-soft text-brand" : "border-alarm bg-red-50 text-alarm dark:bg-red-950/60 dark:text-red-200";
|
||||
let body: ReactNode;
|
||||
if (result.results) {
|
||||
body = (
|
||||
<>
|
||||
{ok ? "Zapisano." : RESULT_TEXT[result.status]}
|
||||
<ul className="mt-1 list-disc pl-5 font-medium">
|
||||
{result.results.map((x, i) => (
|
||||
<li key={i}>
|
||||
{result.changes?.[i] ? name(result.changes[i].setpoint) : `Zmiana ${i + 1}`}: {LINE_TEXT[x.status]}
|
||||
{x.status === "APPLIED" && x.restoreAt ? `, wróci o ${when(x.restoreAt)}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
} else if (ok) {
|
||||
body = <>Zapisano.{result.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(result.restoreAt)}.` : ""}</>;
|
||||
} else {
|
||||
body = result.text ?? RESULT_TEXT[result.status];
|
||||
}
|
||||
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
|
||||
}
|
||||
|
||||
function changeSentence(data: Setpoints, c: Change) {
|
||||
const back = degrees(returnValue(data, c.setpoint));
|
||||
const tail = c.hours == null ? "do odwołania" : `przez ${duration(c.hours)}, potem wróci ${back}`;
|
||||
return <>{name(c.setpoint)}: <b>{degrees(c.value)}</b> {tail}.</>;
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { data, offline } = usePoll<Setpoints>(URL);
|
||||
// The one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
|
||||
const [draft, setDraft] = useState<{ name: string; value: number } | null>(null);
|
||||
const [hours, setHours] = useState<number | null>(2);
|
||||
const [presetId, setPresetId] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<Result>(null);
|
||||
|
||||
// A refresh can lose the reading while a change is half-made; drop what was built on it.
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (draft && unread(find(data, draft.name))) { setDraft(null); setConfirming(false); }
|
||||
const preset = data.presets.find((p) => p.id === presetId);
|
||||
if (preset && !presetReady(data, preset.changes)) setPresetId(null);
|
||||
}, [data, draft, presetId]);
|
||||
|
||||
const title = <PageTitle title="Ustawienia" subtitle="Temperatury zadane pompy ciepła" />;
|
||||
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
|
||||
const cancel = () => { setDraft(null); setPresetId(null); setConfirming(false); setResult(null); };
|
||||
|
||||
async function send(url: string, payload: unknown, changes?: Change[]) {
|
||||
setBusy(true);
|
||||
let answer: Result;
|
||||
try {
|
||||
answer = await postJson(url, payload);
|
||||
if (changes) answer.changes = changes;
|
||||
if (answer.status === "APPLIED") setDraft(null);
|
||||
} catch (error) {
|
||||
const status = error instanceof WriteError ? error.status : 0;
|
||||
answer = { status: "FAILED", text: ERROR_TEXT[status] ?? WRITE_LOST };
|
||||
}
|
||||
setResult(answer);
|
||||
setBusy(false);
|
||||
setPresetId(null);
|
||||
setConfirming(false);
|
||||
window.scrollTo(0, 0);
|
||||
reload(URL);
|
||||
}
|
||||
|
||||
function step(s: Setpoint, direction: number) {
|
||||
if (unread(s)) return;
|
||||
setResult(null);
|
||||
const from = draft?.name === s.name ? draft.value : s.value!;
|
||||
const value = Math.min(s.max, Math.max(s.min, from + direction * s.step));
|
||||
setDraft(value === s.value ? null : { name: s.name, value });
|
||||
setConfirming(false);
|
||||
}
|
||||
|
||||
const preset = data.presets.find((p) => p.id === presetId);
|
||||
if (preset) {
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<ResultBox result={result} />
|
||||
<Card title={preset.name} className="max-w-2xl">
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c)}</p>)}
|
||||
<p className="my-3">Potwierdzasz?</p>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => send(`api/presets/${preset.id}`, undefined, preset.changes)} disabled={busy}>Tak, ustaw</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const current = draft ? find(data, draft.name) : null;
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
{offline && <Freshness offline />}
|
||||
<ResultBox result={result} />
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2">
|
||||
<div className="grid gap-4">
|
||||
{data.restores.length > 0 && (
|
||||
<Card title="Zmiany czasowe">
|
||||
{data.restores.map((r) => (
|
||||
<p key={r.setpoint} className="my-1">
|
||||
{name(r.setpoint)}: <b>{degrees(r.appliedValue)}</b> do {when(r.due)}, potem <b>{degrees(r.originalValue)}</b>.
|
||||
</p>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Card title="Szybkie ustawienia">
|
||||
<div className="grid gap-3">
|
||||
{data.presets.map((p) => {
|
||||
const ready = presetReady(data, p.changes);
|
||||
return (
|
||||
<Button key={p.id} big onClick={() => { setResult(null); setPresetId(p.id); }} disabled={busy || !ready}>
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card title="Nastawy">
|
||||
{data.setpoints.map((s) => {
|
||||
const shown = draft?.name === s.name ? draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
return (
|
||||
<div key={s.name} className={`border-t border-edge py-3 first:border-t-0 ${shown === s.value ? "" : "border-l-4 border-l-brand pl-3"}`}>
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<div className="my-2 flex items-center gap-3">
|
||||
<button aria-label={`Mniej: ${label}`} onClick={() => step(s, -1)} disabled={busy || unread(s) || shown! <= s.min}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">−</button>
|
||||
<output className="flex-1 text-center text-3xl font-bold tabular-nums">{unread(s) ? "brak odczytu" : degrees(shown)}</output>
|
||||
<button aria-label={`Więcej: ${label}`} onClick={() => step(s, 1)} disabled={busy || unread(s) || shown! >= s.max}
|
||||
className="size-16 rounded-xl border-2 border-brand bg-panel text-3xl font-bold text-brand disabled:opacity-40">+</button>
|
||||
</div>
|
||||
<span className="text-sm text-soft">od {degrees(s.min)} do {degrees(s.max)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{draft && current && (
|
||||
<div className="mt-2 border-t-2 border-brand pt-3">
|
||||
<p className="text-lg font-semibold">{name(draft.name)}: z {degrees(current.value)} na <b>{degrees(draft.value)}</b></p>
|
||||
<p className="my-2">Na jak długo?</p>
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
{DURATIONS.map((o) => (
|
||||
<Button key={o.label} aria-pressed={o.hours === hours} disabled={busy}
|
||||
onClick={() => { setHours(o.hours); setConfirming(false); }}>{o.label}</Button>
|
||||
))}
|
||||
</div>
|
||||
{confirming && (
|
||||
<p className="my-3 text-lg font-semibold">
|
||||
{hours
|
||||
? `Ustawię ${degrees(draft.value)}. Za ${duration(hours)} wrócimy do ${degrees(returnValue(data, draft.name))}.`
|
||||
: `Ustawię ${degrees(draft.value)} do odwołania. Poprzednia wartość (${degrees(returnValue(data, draft.name))}) nie wróci sama.`}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={cancel} disabled={busy}>Anuluj</Button>
|
||||
{confirming
|
||||
? <Button primary disabled={busy} onClick={() => send(URL, { setpoint: draft.name, value: draft.value, hours: hours ?? undefined })}>Tak, zapisz</Button>
|
||||
: <Button primary disabled={busy} onClick={() => setConfirming(true)}>Zapisz</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ago } from "./format";
|
||||
import { usePoll } from "./poll";
|
||||
import type { Health } from "./types";
|
||||
import { Card, Dot, Freshness, Loading, PageTitle, Row } from "./ui";
|
||||
|
||||
const DEVICE_NAMES: Record<string, string> = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
"house-meter": "Licznik domu",
|
||||
bme280: "Czujnik pogody",
|
||||
deye: "Falownik Deye (bateria, PV)",
|
||||
fronius: "Falownik Fronius (PV)",
|
||||
};
|
||||
|
||||
export function Status() {
|
||||
const { data, offline } = usePoll<Health>("api/health");
|
||||
const title = <PageTitle title="Stan" subtitle="Połączenie z domem i urządzenia" />;
|
||||
if (!data) return <>{title}<Freshness offline={offline} />{!offline && <Loading />}</>;
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
{offline && <Freshness offline time={data.time} />}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Połączenie z domem">
|
||||
<p className="flex items-center gap-3 text-xl font-bold">
|
||||
<Dot tone={data.gatewayReachable ? "ok" : "bad"} live={data.gatewayReachable} />
|
||||
{data.gatewayReachable ? "Dom odpowiada" : "Dom nie odpowiada"}
|
||||
</p>
|
||||
</Card>
|
||||
<Card title="Urządzenia">
|
||||
{data.devices.length === 0 ? <p className="text-soft">Lista pojawi się, gdy dom odpowie.</p>
|
||||
: data.devices.map((d) => {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
return (
|
||||
<Row key={d.name} icon={<Dot tone={d.up ? "ok" : "bad"} />}
|
||||
label={<span><span className="text-ink">{DEVICE_NAMES[d.name] ?? d.name}</span><br />
|
||||
<span className="text-xs">{d.up ? last : `nie odpowiada, ${last}`}</span></span>}
|
||||
value={<span className={d.up ? "text-brand" : "text-alarm"}>{d.up ? "działa" : "brak"}</span>} />
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { dayAndHour, hour, number, warsaw } from "./format";
|
||||
import type { Point } from "./types";
|
||||
|
||||
export type Range = "24h" | "7d";
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
const GAP_FACTOR = 3; // of the median step between points
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
|
||||
export interface Row {
|
||||
t: number;
|
||||
[key: string]: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per time, one column per series. A gap much longer than the usual step is an outage:
|
||||
* a null row is put there, so the line stops instead of drawing a ramp that was never measured.
|
||||
*/
|
||||
export function toRows(series: Record<string, Point[]>): Row[] {
|
||||
const rows = new Map<number, Row>();
|
||||
for (const [key, points] of Object.entries(series)) {
|
||||
for (const p of points) {
|
||||
const t = Date.parse(p.t);
|
||||
const row = rows.get(t) ?? { t };
|
||||
row[key] = p.v;
|
||||
rows.set(t, row);
|
||||
}
|
||||
}
|
||||
const sorted = [...rows.values()].sort((a, b) => a.t - b.t);
|
||||
const steps = sorted.slice(1).map((r, i) => r.t - sorted[i].t).sort((a, b) => a - b);
|
||||
const limit = GAP_FACTOR * (steps[Math.floor(steps.length / 2)] ?? 0);
|
||||
return sorted.flatMap((row, i) => (i && row.t - sorted[i - 1].t > limit ? [{ t: row.t - 1 }, row] : [row]));
|
||||
}
|
||||
|
||||
// Warsaw midnights (or every 6th full Warsaw hour) inside the range, so the axis reads like a clock.
|
||||
export function timeTicks(t0: number, t1: number, range: Range): number[] {
|
||||
const ticks = [];
|
||||
for (let t = Math.ceil(t0 / HOUR_MS) * HOUR_MS; t <= t1; t += HOUR_MS) {
|
||||
const h = warsaw(t).hour;
|
||||
if (range === "7d" ? h === 0 : h % HOURS_BETWEEN_TICKS === 0) ticks.push(t);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export interface Line {
|
||||
key: string;
|
||||
name: string;
|
||||
colour: string;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
export function TimeChart({ rows, lines, unit, range, height = 260, area = false }: {
|
||||
rows: Row[]; lines: Line[]; unit: string; range: Range; height?: number; area?: boolean;
|
||||
}) {
|
||||
const times = rows.map((r) => r.t);
|
||||
const ticks = timeTicks(Math.min(...times), Math.max(...times), range);
|
||||
const digits = unit === "°C" ? 1 : 0;
|
||||
const tick = (t: number) => (range === "7d" ? DAY_NAMES[warsaw(t + 1).weekday] : hour(new Date(t)));
|
||||
return (
|
||||
<div style={{ height }} className="w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -8 }}>
|
||||
<defs>
|
||||
{lines.map((l) => (
|
||||
<linearGradient key={l.key} id={`fill-${l.key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor={l.colour} stopOpacity={0.35} />
|
||||
<stop offset="1" stopColor={l.colour} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid stroke="var(--color-edge)" vertical={false} />
|
||||
<XAxis dataKey="t" type="number" scale="time" domain={["dataMin", "dataMax"]} ticks={ticks}
|
||||
tickFormatter={tick} stroke="var(--color-soft)" fontSize={12} tickLine={false} />
|
||||
<YAxis stroke="var(--color-soft)" fontSize={12} tickLine={false} axisLine={false} width={48}
|
||||
domain={["auto", "auto"]} tickFormatter={(v: number) => number(v, 0)} />
|
||||
<Tooltip
|
||||
labelFormatter={(t) => dayAndHour(new Date(Number(t)))}
|
||||
formatter={(v, name) => [`${number(Number(v), digits)} ${unit}`, name]}
|
||||
contentStyle={{ background: "var(--color-panel)", border: "1px solid var(--color-edge)", borderRadius: 12 }}
|
||||
/>
|
||||
{lines.map((l) => (area
|
||||
? <Area key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
|
||||
fill={`url(#fill-${l.key})`} dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
: <Line key={l.key} dataKey={l.key} name={l.name} stroke={l.colour} strokeWidth={2.5}
|
||||
strokeDasharray={l.dashed ? "5 5" : undefined} dot={false} isAnimationActive={false} connectNulls={false} />
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Legend({ lines }: { lines: Line[] }) {
|
||||
return (
|
||||
<ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-soft">
|
||||
{lines.map((l) => (
|
||||
<li key={l.key} className="flex items-center gap-1.5">
|
||||
<svg width="18" height="6" aria-hidden="true">
|
||||
<line x1="1" y1="3" x2="17" y2="3" stroke={l.colour} strokeWidth="3" strokeLinecap="round"
|
||||
strokeDasharray={l.dashed ? "4 3" : undefined} />
|
||||
</svg>
|
||||
{l.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { CommandResult } from "./types";
|
||||
|
||||
// A request that gets no answer in time is given up, so a screen never waits for ever.
|
||||
const READ_TIMEOUT_MS = 15000;
|
||||
const WRITE_TIMEOUT_MS = 20000;
|
||||
|
||||
export async function getJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export class WriteError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`write failed: ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, ...} answer, also for a
|
||||
// refusal (422) or a failed write (502); rejects with the HTTP status on 400, 404 and 503,
|
||||
// and with status 0 when the network fails or the answer does not come in time.
|
||||
export async function postJson(url: string, body?: unknown): Promise<CommandResult> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
// The custom header is what the backend uses to tell this page from another site's form.
|
||||
headers: { "Content-Type": "application/json", "X-Requested-With": "home-pump" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: AbortSignal.timeout(WRITE_TIMEOUT_MS),
|
||||
}).catch(() => {
|
||||
throw new WriteError(0);
|
||||
});
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new WriteError(response.status);
|
||||
return answer;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Loaded 100 of 269 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user