Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d9487f0c | ||
|
|
64ffd035c5 | ||
|
|
87d111398c | ||
|
|
24a141a377 | ||
|
|
a1d6a5c8a6 | ||
|
|
d1f053079a | ||
|
|
8b07b9ba0e | ||
|
|
aaf08d4f5c | ||
|
|
b0c0872775 | ||
|
|
f128b03ae5 | ||
|
|
9f9e4e4f50 | ||
|
|
4b26028d01 | ||
|
|
655e84be8d | ||
|
|
4cb8bfd27b | ||
|
|
bdea2a177d | ||
|
|
d8b06e2a89 | ||
|
|
1405695da5 | ||
|
|
ea2862ae04 | ||
|
|
317950cb75 | ||
|
|
8423d723a1 | ||
|
|
cb93f24365 | ||
|
|
f181d0bbe3 | ||
|
|
f57c3adc58 | ||
|
|
56a457085e | ||
|
|
6dcbb5b8e4 | ||
|
|
ace189f5e2 | ||
|
|
d356fb2a2c | ||
|
|
0fb139192d | ||
|
|
40823683be | ||
|
|
1f43987db5 | ||
|
|
e88c28899f | ||
|
|
d5ab64aa61 | ||
|
|
01c5e1efe5 | ||
|
|
0819ed8261 | ||
|
|
338befaffa | ||
|
|
378f4f6623 | ||
|
|
82670eb4a1 | ||
|
|
d07be384a1 | ||
|
|
860269a353 | ||
|
|
e4481eeeae | ||
|
|
e1e50eb7c2 | ||
|
|
c7d88e6c0b | ||
|
|
186c1984c0 | ||
|
|
83d9e1ecfc | ||
|
|
cbf507fa18 | ||
|
|
a99f2b677a | ||
|
|
928915d059 | ||
|
|
c0c46973e8 | ||
|
|
64e1e6ffec | ||
|
|
b288c98106 | ||
|
|
e5e13cedb5 | ||
|
|
a594512f33 | ||
|
|
a5ba9e8756 | ||
|
|
619c3ee990 | ||
|
|
8111405326 | ||
|
|
965dfae644 | ||
|
|
2da7246fe4 | ||
|
|
a4dc88cbbc | ||
|
|
a01c507a6e | ||
|
|
a8ef638fcd | ||
|
|
8396372943 | ||
|
|
25f76ea084 | ||
|
|
8146296061 | ||
|
|
c21094b4f4 | ||
|
|
3b6d073b1f | ||
|
|
63c552dfd7 | ||
|
|
de19424a7b | ||
|
|
03b4ee1a48 | ||
|
|
355dd76555 | ||
|
|
b6997560d2 | ||
|
|
09042c6338 | ||
|
|
f9fbff4eea | ||
|
|
7e50d1203b | ||
|
|
934330e157 | ||
|
|
2e273f05c2 | ||
|
|
9a48442220 | ||
|
|
b3038aeef7 | ||
|
|
9e997e43f3 | ||
|
|
8ae6cea536 | ||
|
|
604ef4e6d9 | ||
|
|
b0735471b4 | ||
|
|
6f50f5b7b9 | ||
|
|
bad67b9538 | ||
|
|
ce00d0e81b | ||
|
|
0eebba072e | ||
|
|
ea83255b91 | ||
|
|
ba8631b3c2 | ||
|
|
6cae491b1c | ||
|
|
83333a19bf | ||
|
|
0072017966 | ||
|
|
35b3f8e1e0 | ||
|
|
7a17bbbcdb | ||
|
|
f0c974c82a | ||
|
|
d557f98df6 | ||
|
|
9f33f5a94c | ||
|
|
798f98b69d | ||
|
|
e65cb5837f | ||
|
|
0b3197ac55 | ||
|
|
5513c09c51 | ||
|
|
697b68ec39 | ||
|
|
645bbc4b88 | ||
|
|
2d7899ebc8 | ||
|
|
c34a9da0d7 | ||
|
|
a8f6384e04 | ||
|
|
3c68dc3621 |
No files matched your search
@@ -24,7 +24,8 @@ Repo rules: `CONTRIBUTING.md`. Gitea: https://git.mariuszpacek.pl/mariusz.pacek/
|
||||
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;
|
||||
`scripts/release.sh vMAJOR.MINOR.PATCH` (patch for fixes, minor for features) waits for green
|
||||
CI on master's head (no local check run), then 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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# One job: check.sh builds the three parts in parallel itself. The runner runs one job at a time
|
||||
# and its actions cache server is unreachable from job containers (ETIMEDOUT), so separate jobs
|
||||
# or setup-java/setup-node caches would only add waiting.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
|
||||
@@ -13,8 +13,20 @@ env:
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Images
|
||||
name: Image (${{ matrix.part }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# 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.
|
||||
- part: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
- part: backend
|
||||
platforms: linux/amd64
|
||||
- part: frontend
|
||||
platforms: linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -31,26 +43,14 @@ jobs:
|
||||
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
|
||||
# The registry cache keeps the Maven and npm layers between releases.
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: gateway
|
||||
platforms: linux/arm64,linux/amd64
|
||||
context: ${{ matrix.part }}
|
||||
build-args: VERSION=${{ gitea.ref_name }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
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
|
||||
tags: ${{ env.IMAGES }}-${{ matrix.part }}:${{ gitea.ref_name }},${{ env.IMAGES }}-${{ matrix.part }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGES }}-${{ matrix.part }}:buildcache,mode=max
|
||||
@@ -8,6 +8,9 @@ COPY src src
|
||||
RUN mvn -B -q package -Dmaven.test.skip=true
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# The release tag, shown in /api/health; the release workflow passes it.
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=$VERSION
|
||||
USER 10001:10001
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/target/home-pump-backend-1.0.0.jar app.jar
|
||||
|
||||
+62
-1
@@ -3,6 +3,43 @@
|
||||
The phone API in Kraków (ADR-008): live state from the gateway, history from InfluxDB, setpoint
|
||||
changes passed to the gateway. JSON under `/api/` only; the web UI is the frontend's (ADR-010).
|
||||
The records in `home.backend.domain` and the examples in `src/test/resources/api/` are the contract.
|
||||
`GET /api/energy/details` lists the Deye, Fronius and house meter values in labelled groups (a stale device has none); the label, unit and place of every field are the catalog in `EnergyDetailsService`: a new field is one line there.
|
||||
`GET /api/pi-sensor` is the BME280 sensor on the Raspberry Pi (the living room), example `src/test/resources/api/pi-sensor.json`:
|
||||
`time`, `stale`, `temperature` (°C), `humidity` (%), `pressure` (hPa). When it is stale (device down, no reading or older than 10 min)
|
||||
the three values are null (`pi-sensor-stale.json`).
|
||||
|
||||
`GET /api/stats?range=7d|30d` (example `src/test/resources/api/stats.json`; 400 otherwise, 502 when InfluxDB is down) is the heat pump per local
|
||||
day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` with `date`, `compressorHours`, `compressorStarts`, `cwuHours`, `coHours`,
|
||||
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
`mode` (`touEnabled` with a Polish sentence), `schedule` (six slots, each from its start to the next one, the last wraps past midnight),
|
||||
`limits`, and `capacityKwh` = capacity in Ah x a nominal 51.2 V, since the inverter reports Ah only. When stale they are null (`schedule` empty).
|
||||
|
||||
`/api/battery` also has `reserve` (`pct`, `floorPct`: the backup reserve of the app and the lowest the user may set) and `editable`:
|
||||
true when the gateway answers its `/battery/reserve`, i.e. it has battery commands on and `GATEWAY_TOKEN` is right; else `reserve` is null
|
||||
and `editable` false. `POST /api/battery/schedule` (examples `battery-schedule-request.json`, `battery-failed.json`) takes
|
||||
`{"enabled"?, "slots": [{"slot": 1-6, "from"?: "HH:MM", "socPct"?, "gridCharge"?}]}` and `POST /api/battery/reserve` takes `{"pct": 60}`.
|
||||
Same Origin, header and JSON rules as the other writes; `Remote-User` goes on as the requester. The gateway's verdict comes back as
|
||||
`{status, message, read}`: 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon, 502 failed (`read` is what the
|
||||
inverter holds), 400 bad body, 503 gateway unreachable or without battery commands (ADR-013).
|
||||
|
||||
`GET /api/audit?range=7d` (`24h`, `7d` default, `30d`; 400 otherwise, 502 when InfluxDB fails) is the change history, example
|
||||
`src/test/resources/api/audit.json`: the gateway's audit rows of the heat pump (`Dom_Pompa.setpoint_audit`) and the battery
|
||||
(`electricity.deye_settings_audit`), read only, as `entries` newest first: `time`, `source` (`heat_pump`, `battery`), `what` (Polish label;
|
||||
the Deye registers are named in `DeyeSettingLabels`), `oldValue`/`newValue` (whole numbers, null when not recorded), `unit` (`°C`, `%`,
|
||||
`HHMM`, `0/1`), `who` (`Remote-User` of the request; a restore is "automatycznie (przywrócenie)") and `status` (the gateway's: APPLIED, FAILED, REJECTED, CONFLICT, INVALID).
|
||||
|
||||
`GET /api/weather` (example `src/test/resources/api/weather.json`) is the forecast of Open-Meteo for the house and the IMGW warnings that name
|
||||
Krosno (TERYT 1862 or the powiat 1807): `today`, `tomorrow`, `dayAfter` (`date`, `weatherCode` WMO, Polish `description`, `tMax`, `tMin`,
|
||||
`precipitationMm`, `sunshineHours`, `pvForecastKwh`), `now.temperature`, `warnings` (`event`, `level` 1-3, `from`, `to`, `text`; the ones that
|
||||
ended are dropped). `pvFactor` is kWh per MJ/m^2: the Deye plus Fronius energy of the past 14 complete days (from InfluxDB, daily maximum of
|
||||
`pv_today_kwh` and `energy_day_wh`) over the forecast radiation of the same days, so no tilt or azimuth is needed; `pvForecastKwh` is the factor
|
||||
times the day's radiation, and both are null with fewer than 5 days that have both values. The forecast is kept 30 min and the warnings 10 min;
|
||||
when a refresh fails the older answer is served with `stale` true. 503 only when there never was a forecast. The sources are public and need no key.
|
||||
|
||||
Settings are environment variables (`.env` next to `compose.yaml`); the backend refuses to start
|
||||
without the two URLs.
|
||||
@@ -16,7 +53,9 @@ Setpoint changes go only to the gateway; the user name from Traefik's `Remote-Us
|
||||
| `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 |
|
||||
| `WEATHER_LAT`, `WEATHER_LON` | `49.684`, `21.770` | where the house is (Krosno, ul. Szczepańcowa), for the forecast |
|
||||
| `WEATHER_TERYT` | `1862,1807` | comma-separated TERYT powiat codes whose IMGW warnings count |
|
||||
| `PRESETS_FILE` | built-in | JSON file replacing the presets: `[{"id", "name", "changes": [{"setpoint", "value", "hours"}]}]`, `hours` null means until changed; a bad file stops the start. A preset may also have `"until": "chosen"` (then no `hours`): the user picks the end, see below |
|
||||
|
||||
```bash
|
||||
mvn -f backend/pom.xml clean package
|
||||
@@ -25,6 +64,28 @@ GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
docker compose -f backend/compose.yaml up --build # with backend/.env; network home-pump
|
||||
```
|
||||
|
||||
`POST /api/presets/{id}` takes no body for an ordinary preset. A preset with `"until": "chosen"` (the built-in "Wyjazd")
|
||||
needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `src/test/resources/api/preset-away-request.json`):
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
extra poll of every device and passes its answer through, example `src/test/resources/api/refresh.json`
|
||||
(`requested`, and `skipped` with `retryInSeconds` for devices polled within the last minute). Read-only; 503 when
|
||||
`GATEWAY_TOKEN` is not set or the gateway is unreachable.
|
||||
|
||||
`POST /api/setpoints/{name}/restore` (same Origin, header and JSON rules as the other writes; send `{}`) ends the
|
||||
pending change of that setpoint now and answers with the gateway's verdict, example
|
||||
`src/test/resources/api/setpoint-restored.json`: 200 restored, 404 nothing pending, 409 the value was changed
|
||||
meanwhile (left alone, the restore is dropped), 429 too soon after the last write, 502 failed (the restore stays
|
||||
pending), 503 gateway unreachable.
|
||||
|
||||
Publish no host port in production: only the frontend, behind Traefik, may reach the backend. It
|
||||
trusts `Remote-User` and has no login of its own, so a direct port would bypass Traefik's access
|
||||
rules (ADR-009).
|
||||
|
||||
`/api/health` also carries `backendVersion` (the `VERSION` the image was built with, `dev` locally) and
|
||||
`gatewayVersion` (from the gateway's `/health`; null when it is unreachable or too old to report it).
|
||||
@@ -4,20 +4,39 @@ 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.InfluxAuditRepository;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxPvEnergy;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.adapters.weather.ImgwClient;
|
||||
import home.backend.adapters.weather.OpenMeteoClient;
|
||||
import home.backend.adapters.weather.WeatherSettings;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private static final Duration WEATHER_TIMEOUT = Duration.ofSeconds(10);
|
||||
/** Warnings are optional, so a slow IMGW must not hold the forecast. */
|
||||
private static final Duration WARNINGS_TIMEOUT = Duration.ofSeconds(3);
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
@@ -28,14 +47,35 @@ final class Backend {
|
||||
*/
|
||||
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 influxSettings = new InfluxSettings(settings.influxUrl(), settings.influxTimeout());
|
||||
var influx = new InfluxHistoryRepository(influxSettings);
|
||||
var commands = commands(settings);
|
||||
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 EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, Objects.requireNonNullElse(System.getenv("VERSION"), "dev")),
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
weather(influxSettings, clock), commands);
|
||||
}
|
||||
|
||||
private static WeatherService weather(InfluxSettings influx, Clock clock) {
|
||||
var place = WeatherSettings.fromEnvironment(System.getenv());
|
||||
return new WeatherService(
|
||||
new OpenMeteoClient(URI.create("https://api.open-meteo.com"), place, WEATHER_TIMEOUT),
|
||||
new ImgwClient(URI.create("https://danepubliczne.imgw.pl"), place, WARNINGS_TIMEOUT),
|
||||
new InfluxPvEnergy(influx), clock);
|
||||
}
|
||||
|
||||
/** Keeps the gateway from restoring our setpoint changes while we are up (ADR-011). */
|
||||
static LeaseRenewer leaseRenewer(Settings settings) {
|
||||
return new LeaseRenewer(commands(settings));
|
||||
}
|
||||
|
||||
private static GatewayHttpCommands commands(Settings settings) {
|
||||
return new GatewayHttpCommands(settings.gatewayUrl(), settings.gatewayToken(), settings.gatewayTimeout());
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,14 @@ public final class Main {
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
if (settings.gatewayToken() == null) {
|
||||
System.err.println("home-pump-backend: GATEWAY_TOKEN is not set, setpoint changes are disabled");
|
||||
} else {
|
||||
var renewer = Backend.leaseRenewer(settings);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(renewer::close));
|
||||
renewer.start();
|
||||
}
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.util.stream.Collectors;
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(List<DeviceHealth> devices) {
|
||||
private record Health(String version, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
@@ -61,7 +61,7 @@ public class GatewayHttpClient implements GatewayClient {
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
return new GatewaySnapshot(health.version(), devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
|
||||
@@ -3,10 +3,18 @@ package home.backend.adapters.gateway;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -15,23 +23,54 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Sends commands to the gateway's {@code /commands} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** Calls the gateway's {@code /commands}, {@code /battery} and {@code /refresh} with the shared token (S6). */
|
||||
public class GatewayHttpCommands implements GatewayCommands, GatewayBattery, GatewayRefresh {
|
||||
|
||||
/** The gateway answers a verdict with 200 (applied), 422 (rejected) or 502 (failed). */
|
||||
private static final Set<Integer> VERDICTS = Set.of(200, 422, 502);
|
||||
|
||||
/** Restore now: 200 restored, 404 nothing pending, 409 changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> RESTORE_VERDICTS = Set.of(200, 404, 409, 429, 502);
|
||||
|
||||
/** Battery change: 200 applied, 422 rejected, 409 stale or changed meanwhile, 429 too soon, 502 failed. */
|
||||
private static final Set<Integer> BATTERY_VERDICTS = Set.of(200, 422, 409, 429, 502);
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes) {
|
||||
private record ScheduleRequest(Boolean enabled, List<SlotRequest> slots) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record SlotRequest(int slot, Integer start, Integer soc, Boolean gridCharge) {
|
||||
}
|
||||
|
||||
private record ReserveRequest(int pct) {
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record Request(String setpoint, int value, Long minutes, Instant until) {
|
||||
}
|
||||
|
||||
private record RestoreRequest(String setpoint) {
|
||||
}
|
||||
|
||||
private record Renewal(int renewed) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI endpoint;
|
||||
private final URI renewEndpoint;
|
||||
private final URI restoreEndpoint;
|
||||
private final URI refreshEndpoint;
|
||||
private final URI reserveEndpoint;
|
||||
private final URI scheduleEndpoint;
|
||||
private final String token;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
@@ -39,6 +78,11 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
/** @param token null when not configured: every call then fails without touching the network */
|
||||
public GatewayHttpCommands(URI baseUri, String token, Duration timeout) {
|
||||
this.endpoint = baseUri.resolve("/commands");
|
||||
this.renewEndpoint = baseUri.resolve("/commands/renew");
|
||||
this.restoreEndpoint = baseUri.resolve("/commands/restore");
|
||||
this.refreshEndpoint = baseUri.resolve("/refresh");
|
||||
this.reserveEndpoint = baseUri.resolve("/battery/reserve");
|
||||
this.scheduleEndpoint = baseUri.resolve("/battery/schedule");
|
||||
this.token = token;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
@@ -47,35 +91,84 @@ public class GatewayHttpCommands implements GatewayCommands {
|
||||
@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));
|
||||
byte[] body = JSON.writeValueAsBytes(new Request(command.setpoint(), command.value(), minutes, command.heldUntil()));
|
||||
HttpRequest.Builder request = request().header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", command.requestedBy())
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, VERDICTS), CommandResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
byte[] body = JSON.writeValueAsBytes(new RestoreRequest(setpoint));
|
||||
var request = request(restoreEndpoint).header("Content-Type", "application/json")
|
||||
.header("X-Requested-By", requestedBy).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
return JSON.readValue(send(request, RESTORE_VERDICTS), RestoreResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
return JSON.readValue(send(request().GET(), Set.of(200)), SetpointState.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
var request = request(renewEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), Renewal.class).renewed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RefreshResult refresh() throws IOException {
|
||||
var request = request(refreshEndpoint).POST(HttpRequest.BodyPublishers.noBody());
|
||||
return JSON.readValue(send(request, Set.of(200)), RefreshResult.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
return JSON.readValue(send(request(reserveEndpoint).GET(), Set.of(200)), Reserve.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||
var slots = change.slots().stream().map(slot -> new SlotRequest(slot.slot(),
|
||||
slot.from() == null ? null : slot.from().getHour() * 100 + slot.from().getMinute(),
|
||||
slot.socPct(), slot.gridCharge())).toList();
|
||||
return battery(scheduleEndpoint, new ScheduleRequest(change.enabled(), slots), requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||
return battery(reserveEndpoint, new ReserveRequest(pct), requestedBy);
|
||||
}
|
||||
|
||||
private BatteryResult battery(URI uri, Object body, String requestedBy) throws IOException {
|
||||
var request = request(uri).header("Content-Type", "application/json").header("X-Requested-By", requestedBy)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(JSON.writeValueAsBytes(body)));
|
||||
return JSON.readValue(send(request, BATTERY_VERDICTS), BatteryResult.class);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request() throws IOException {
|
||||
return request(endpoint);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(URI uri) throws IOException {
|
||||
if (token == null) {
|
||||
throw new IOException("GATEWAY_TOKEN is not set, commands are disabled");
|
||||
}
|
||||
return HttpRequest.newBuilder(endpoint).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
return HttpRequest.newBuilder(uri).timeout(timeout).header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest.Builder request, Set<Integer> accepted) throws IOException {
|
||||
try {
|
||||
var response = http.send(request.build(), HttpResponse.BodyHandlers.ofByteArray());
|
||||
var built = request.build();
|
||||
var response = http.send(built, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (!accepted.contains(response.statusCode())) {
|
||||
throw new IOException("gateway /commands answered " + response.statusCode());
|
||||
throw new IOException("gateway " + built.uri().getPath() + " answered " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway /commands", e);
|
||||
throw new IOException("interrupted while calling the gateway", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,27 @@ 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.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -37,12 +49,30 @@ public class ApiServer {
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT)
|
||||
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
|
||||
|
||||
private record ReserveRequest(Integer pct) {
|
||||
}
|
||||
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
values = Map.of();
|
||||
} else if (values.containsValue(null)) {
|
||||
throw new IllegalArgumentException("a value must be a number");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final String UNREACHABLE = "gateway unreachable";
|
||||
private static final String UNAVAILABLE = "battery changes are not available: gateway unreachable or not enabled";
|
||||
private static final String WRITE_MARKER = "home-pump";
|
||||
private static final String PRESETS_PATH = "/api/presets/";
|
||||
private static final String SETPOINTS_PATH = "/api/setpoints/";
|
||||
private static final Pattern RESTORE_PATH = Pattern.compile("/api/setpoints/([a-z_]{1,32})/restore");
|
||||
/** The user name Traefik's auth forwards; anything odd falls back to the default. */
|
||||
private static final String USER_HEADER = "Remote-User";
|
||||
private static final String DEFAULT_USER = "phone";
|
||||
@@ -58,8 +88,11 @@ public class ApiServer {
|
||||
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 {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||
GatewayRefresh refresh) 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).
|
||||
@@ -70,13 +103,36 @@ public class ApiServer {
|
||||
});
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/energy/details", query -> energyDetails.current());
|
||||
api("/api/battery", query -> battery.current());
|
||||
route("/api/battery/schedule", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postSchedule(exchange, body, battery)))));
|
||||
route("/api/battery/reserve", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postReserve(exchange, body, battery)))));
|
||||
route("/api/refresh", exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postRefresh(exchange, refresh)))));
|
||||
api("/api/pi-sensor", query -> piSensor.current());
|
||||
api("/api/weather", query -> weather.current());
|
||||
api("/api/health", query -> health.current());
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
context(SETPOINTS_PATH, exchange -> {
|
||||
var restore = RESTORE_PATH.matcher(exchange.getRequestURI().getPath());
|
||||
if (restore.matches()) {
|
||||
handle(exchange, Map.of("POST", () -> guarded(exchange,
|
||||
body -> postRestore(exchange, restore.group(1), setpoints))));
|
||||
} else {
|
||||
try (exchange) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
}
|
||||
}
|
||||
});
|
||||
context(PRESETS_PATH, exchange -> handle(exchange, Map.of(
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, setpoints)))));
|
||||
"POST", () -> guarded(exchange, body -> postPreset(exchange, body, setpoints)))));
|
||||
}
|
||||
|
||||
/** The JDK matches a context by prefix, so /api/heat-pumpX would reach /api/heat-pump. */
|
||||
@@ -175,15 +231,71 @@ public class ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, SetpointService setpoints) throws IOException {
|
||||
private static void postSchedule(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||
try {
|
||||
var change = JSON.readValue(body, BatteryChange.class);
|
||||
if (change == null) {
|
||||
throw new IllegalArgumentException("a change is required");
|
||||
}
|
||||
var result = battery.changeSchedule(change, user(exchange));
|
||||
send(exchange, batteryHttpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"enabled\"?, \"slots\": [{\"slot\", \"from\"?: \"HH:MM\", "
|
||||
+ "\"socPct\"?, \"gridCharge\"?}]}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postReserve(HttpExchange exchange, byte[] body, BatteryService battery) throws IOException {
|
||||
try {
|
||||
var request = JSON.readValue(body, ReserveRequest.class);
|
||||
if (request == null || request.pct() == null) {
|
||||
throw new IllegalArgumentException("pct is required");
|
||||
}
|
||||
var result = battery.changeReserve(request.pct(), user(exchange));
|
||||
send(exchange, batteryHttpStatus(result.status()), result);
|
||||
} catch (JsonProcessingException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"pct\"}"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNAVAILABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRefresh(HttpExchange exchange, GatewayRefresh refresh) throws IOException {
|
||||
try {
|
||||
send(exchange, 200, refresh.refresh());
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postRestore(HttpExchange exchange, String setpoint, SetpointService setpoints)
|
||||
throws IOException {
|
||||
try {
|
||||
var result = setpoints.restore(setpoint, user(exchange));
|
||||
send(exchange, restoreHttpStatus(result.outcome()), result);
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
}
|
||||
|
||||
private static void postPreset(HttpExchange exchange, byte[] body, SetpointService setpoints)
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var result = setpoints.applyPreset(id, user(exchange));
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
+ "for setpoints of the preset"));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error(UNREACHABLE));
|
||||
}
|
||||
@@ -197,6 +309,26 @@ public class ApiServer {
|
||||
};
|
||||
}
|
||||
|
||||
private static int batteryHttpStatus(BatteryResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
case REJECTED -> 422;
|
||||
case STALE -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int restoreHttpStatus(RestoreResult.Outcome outcome) {
|
||||
return switch (outcome) {
|
||||
case RESTORED -> 200;
|
||||
case NOT_PENDING -> 404;
|
||||
case CONFLICT -> 409;
|
||||
case TOO_SOON -> 429;
|
||||
case FAILED -> 502;
|
||||
};
|
||||
}
|
||||
|
||||
private static int presetHttpStatus(PresetResult.Status status) {
|
||||
return switch (status) {
|
||||
case APPLIED -> 200;
|
||||
@@ -223,6 +355,31 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleAudit(HttpExchange exchange, AuditService audit) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, audit.audit(query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("audit unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleStats(HttpExchange exchange, StatsService stats) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, stats.stats(parseQuery(exchange.getRequestURI().getRawQuery()).get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("stats unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's audit measurements (S3) read from InfluxDB; never written. */
|
||||
public final class InfluxAuditRepository implements AuditRepository {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxAuditRepository(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
return rows("Dom_Pompa", "setpoint_audit", "setpoint", from, to);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
return rows("electricity", "deye_settings_audit", "register", from, to);
|
||||
}
|
||||
|
||||
private List<Row> rows(String database, String measurement, String targetTag, Instant from, Instant to)
|
||||
throws IOException {
|
||||
String query = "SELECT * FROM \"" + measurement + "\" WHERE time >= " + from.toEpochMilli()
|
||||
+ "ms AND time < " + to.toEpochMilli() + "ms ORDER BY time DESC";
|
||||
var rows = new ArrayList<Row>();
|
||||
for (JsonNode result : results(influx.query(database, query))) {
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode values : series.path("values")) {
|
||||
rows.add(row(series.path("columns"), values, targetTag));
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static JsonNode results(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
}
|
||||
return root.path("results");
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
private static Row row(JsonNode columns, JsonNode values, String targetTag) {
|
||||
Map<String, JsonNode> byName = new HashMap<>();
|
||||
for (int i = 0; i < columns.size(); i++) {
|
||||
byName.put(columns.get(i).asText(), values.get(i));
|
||||
}
|
||||
return new Row(Instant.ofEpochMilli(byName.get("time").asLong()), text(byName.get(targetTag)),
|
||||
text(byName.get("requested_by")), text(byName.get("status")),
|
||||
number(byName.get("old_value")), number(byName.get("new_value")));
|
||||
}
|
||||
|
||||
private static String text(JsonNode node) {
|
||||
return node == null || node.isNull() ? "" : node.asText();
|
||||
}
|
||||
|
||||
private static Double number(JsonNode node) {
|
||||
return node == null || !node.isNumber() ? null : node.asDouble();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** Read-only queries to InfluxDB 1.x over its HTTP {@code /query} endpoint; times come back in epoch ms. */
|
||||
final class InfluxClient {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
InfluxClient(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
String query(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -20,14 +16,12 @@ 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 {
|
||||
public final class InfluxHistoryRepository implements HistoryRepository, HeatPumpSamples {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -57,39 +51,19 @@ public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HeatPumpSample> samples(Instant from, Instant to) throws IOException {
|
||||
String query = InfluxSamples.query(from.toEpochMilli(), to.toEpochMilli());
|
||||
return InfluxSamples.parse(influx.query(SeriesSources.LEGACY, query));
|
||||
}
|
||||
|
||||
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));
|
||||
return InfluxResponse.parse(influx.query(source.database(), query));
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.PvEnergyRepository;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Daily PV energy: the Deye's and the Fronius's own day counters, each read as the day's maximum. */
|
||||
public final class InfluxPvEnergy implements PvEnergyRepository {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final String DATABASE = "electricity";
|
||||
|
||||
private final InfluxClient influx;
|
||||
|
||||
public InfluxPvEnergy(InfluxSettings settings) {
|
||||
this.influx = new InfluxClient(settings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException {
|
||||
Map<LocalDate, Double> deye = dailyMax("deye", "pv_today_kwh", from, to);
|
||||
Map<LocalDate, Double> fronius = dailyMax("fronius", "energy_day_wh", from, to);
|
||||
Map<LocalDate, Double> total = new TreeMap<>();
|
||||
deye.forEach((day, kwh) -> {
|
||||
Double wh = fronius.get(day);
|
||||
if (wh != null) {
|
||||
total.put(day, kwh + wh / 1000);
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
private Map<LocalDate, Double> dailyMax(String measurement, String field, LocalDate from, LocalDate to)
|
||||
throws IOException {
|
||||
String query = "SELECT max(\"" + field + "\") FROM \"" + measurement + "\" WHERE time >= "
|
||||
+ millis(from) + "ms AND time < " + millis(to) + "ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')";
|
||||
Map<LocalDate, Double> days = new TreeMap<>();
|
||||
InfluxResponse.parse(influx.query(DATABASE, query)).forEach((start, max) ->
|
||||
days.put(Instant.ofEpochMilli(start).atZone(ZONE).toLocalDate(), max));
|
||||
return days;
|
||||
}
|
||||
|
||||
private static long millis(LocalDate day) {
|
||||
return day.atStartOfDay(ZONE).toInstant().toEpochMilli();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The raw {@code heat_pump} rows of an InfluxDB 1.x query. */
|
||||
final class InfluxSamples {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxSamples() {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||
}
|
||||
|
||||
/** The rows of {@link #query}, as returned (ascending); empty when there is no data. */
|
||||
static List<HeatPumpSample> parse(String body) throws IOException {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
failOnError(root);
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
||||
number(row, 5), number(row, 6)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.util.Map;
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
private static final String LEGACY = "Dom_Pompa";
|
||||
static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
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;
|
||||
|
||||
/** GET of a public JSON document, with a timeout on connecting and on the whole request. */
|
||||
final class HttpJson {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final HttpClient http;
|
||||
private final Duration timeout;
|
||||
|
||||
HttpJson(Duration timeout) {
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
JsonNode get(URI uri) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(uri).timeout(timeout).GET().build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException(uri.getHost() + " answered HTTP " + response.statusCode());
|
||||
}
|
||||
return JSON.readTree(response.body());
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException(uri.getHost() + " returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("request to " + uri.getHost() + " interrupted", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.WarningSource;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* IMGW's public list of meteorological warnings, kept to those naming one of our TERYT areas. A warning
|
||||
* that is malformed (no degree or times) is left out rather than failing the others.
|
||||
*/
|
||||
public final class ImgwClient implements WarningSource {
|
||||
|
||||
private static final ZoneId POLAND = ZoneId.of("Europe/Warsaw");
|
||||
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
private final List<String> teryt;
|
||||
|
||||
public ImgwClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/api/data/warningsmeteo");
|
||||
this.teryt = place.teryt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Warning> warnings() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
if (!root.isArray()) {
|
||||
throw new IOException("IMGW returned no list of warnings");
|
||||
}
|
||||
List<Warning> ours = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (covers(item.path("teryt"))) {
|
||||
warning(item).ifPresent(ours::add);
|
||||
}
|
||||
}
|
||||
return ours;
|
||||
}
|
||||
|
||||
private boolean covers(JsonNode codes) {
|
||||
for (JsonNode code : codes) {
|
||||
if (teryt.contains(code.asText())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Optional<Warning> warning(JsonNode item) {
|
||||
try {
|
||||
return Optional.of(new Warning(item.path("nazwa_zdarzenia").asText(),
|
||||
Integer.parseInt(item.path("stopien").asText()),
|
||||
time(item.path("obowiazuje_od")), time(item.path("obowiazuje_do")),
|
||||
item.path("tresc").asText()));
|
||||
} catch (NumberFormatException | DateTimeException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static OffsetDateTime time(JsonNode text) {
|
||||
return LocalDateTime.parse(text.asText(), TIME).atZone(POLAND).toOffsetDateTime();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import home.backend.application.ForecastSource;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** Open-Meteo (free, no key): daily values for the past 14 days and the next 4, and the temperature now. */
|
||||
public final class OpenMeteoClient implements ForecastSource {
|
||||
|
||||
private static final String DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,"
|
||||
+ "shortwave_radiation_sum,sunshine_duration";
|
||||
|
||||
private final HttpJson http;
|
||||
private final URI uri;
|
||||
|
||||
public OpenMeteoClient(URI baseUri, WeatherSettings place, Duration timeout) {
|
||||
this.http = new HttpJson(timeout);
|
||||
this.uri = URI.create(baseUri.toString().replaceAll("/+$", "") + "/v1/forecast?latitude="
|
||||
+ place.latitude() + "&longitude=" + place.longitude() + "&daily=" + DAILY
|
||||
+ "¤t=temperature_2m&timezone=Europe%2FWarsaw&past_days=14&forecast_days=4");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Forecast forecast() throws IOException {
|
||||
JsonNode root = http.get(uri);
|
||||
JsonNode daily = root.path("daily");
|
||||
JsonNode dates = daily.path("time");
|
||||
if (!dates.isArray() || dates.isEmpty()) {
|
||||
throw new IOException("Open-Meteo returned no daily forecast");
|
||||
}
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
days.put(date(dates.get(i).asText()), new DailyForecast(
|
||||
integer(daily, "weather_code", i), number(daily, "temperature_2m_max", i),
|
||||
number(daily, "temperature_2m_min", i), number(daily, "precipitation_sum", i),
|
||||
number(daily, "shortwave_radiation_sum", i), number(daily, "sunshine_duration", i)));
|
||||
}
|
||||
JsonNode now = root.path("current").path("temperature_2m");
|
||||
return new Forecast(now.isNumber() ? now.asDouble() : null, days);
|
||||
}
|
||||
|
||||
private static LocalDate date(String text) throws IOException {
|
||||
try {
|
||||
return LocalDate.parse(text);
|
||||
} catch (DateTimeException e) {
|
||||
throw new IOException("Open-Meteo returned a bad date: " + text, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Double number(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asDouble() : null;
|
||||
}
|
||||
|
||||
private static Integer integer(JsonNode daily, String name, int i) {
|
||||
JsonNode value = daily.path(name).path(i);
|
||||
return value.isNumber() ? value.asInt() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Where the house is and which IMGW areas (TERYT powiat codes) count as ours. Krosno is a city with
|
||||
* powiat rights, 1862; 1807 is the surrounding powiat krośnieński, which warnings may name instead.
|
||||
*/
|
||||
public record WeatherSettings(double latitude, double longitude, List<String> teryt) {
|
||||
|
||||
public static WeatherSettings fromEnvironment(Map<String, String> env) {
|
||||
return new WeatherSettings(
|
||||
coordinate(env, "WEATHER_LAT", 49.684, 90),
|
||||
coordinate(env, "WEATHER_LON", 21.770, 180),
|
||||
List.of(env.getOrDefault("WEATHER_TERYT", "1862,1807").strip().split("\\s*,\\s*")));
|
||||
}
|
||||
|
||||
private static double coordinate(Map<String, String> env, String name, double fallback, double limit) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
double number = Double.parseDouble(value.strip());
|
||||
if (Math.abs(number) <= limit) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a number from -" + limit + " to " + limit
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's audit rows in InfluxDB (S3), read only. */
|
||||
public interface AuditRepository {
|
||||
|
||||
/** One audited write as recorded; {@code target} is the setpoint name or the Deye register. */
|
||||
record Row(Instant time, String target, String requestedBy, String status, Double oldValue, Double newValue) {
|
||||
}
|
||||
|
||||
List<Row> heatPump(Instant from, Instant to) throws IOException;
|
||||
|
||||
List<Row> battery(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import home.backend.domain.AuditView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** S3: who changed what and when, from the gateway's audit rows of the heat pump and of the battery. */
|
||||
public class AuditService {
|
||||
|
||||
private static final String DEFAULT_RANGE = "7d";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7), "30d", Duration.ofDays(30));
|
||||
private static final Map<String, String> SETPOINTS = Map.of(
|
||||
"cwu_economy", "Ciepła woda – oszczędnie", "cwu_comfort", "Ciepła woda – komfort",
|
||||
"co_economy", "Ogrzewanie – oszczędnie", "co_comfort", "Ogrzewanie – komfort");
|
||||
private static final String RESTORE_USER = "restore";
|
||||
private static final String RESTORE_LABEL = "automatycznie (przywrócenie)";
|
||||
|
||||
private final AuditRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public AuditService(AuditRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h}, {@code 7d} or {@code 30d}; null means 7d
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public AuditView audit(String range) throws IOException {
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
Instant from = to.minus(length);
|
||||
var heatPump = repository.heatPump(from, to).stream().map(AuditService::heatPumpEntry);
|
||||
var battery = repository.battery(from, to).stream().map(AuditService::batteryEntry);
|
||||
return new AuditView(Stream.concat(heatPump, battery)
|
||||
.sorted(Comparator.comparing(AuditEntry::time).reversed())
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static AuditEntry heatPumpEntry(Row row) {
|
||||
return entry(AuditEntry.HEAT_PUMP, row, SETPOINTS.getOrDefault(row.target(), row.target()), "°C");
|
||||
}
|
||||
|
||||
private static AuditEntry batteryEntry(Row row) {
|
||||
var label = DeyeSettingLabels.of(row.target());
|
||||
return entry(AuditEntry.BATTERY, row, label.what(), label.unit());
|
||||
}
|
||||
|
||||
private static AuditEntry entry(String source, Row row, String what, String unit) {
|
||||
return new AuditEntry(row.time(), source, what, whole(row.oldValue()), whole(row.newValue()), unit,
|
||||
RESTORE_USER.equals(row.requestedBy()) ? RESTORE_LABEL : row.requestedBy(), row.status());
|
||||
}
|
||||
|
||||
private static Integer whole(Double value) {
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.BatteryView.Energy;
|
||||
import home.backend.domain.BatteryView.Limits;
|
||||
import home.backend.domain.BatteryView.Mode;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.BatteryView.Slot;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static home.backend.application.SnapshotFields.on;
|
||||
import static home.backend.application.SnapshotFields.whole;
|
||||
|
||||
/** F7: the Deye's battery now, and how it is set up. */
|
||||
public class BatteryService {
|
||||
|
||||
/** The settings are read every 10 minutes; three missed reads and they are not current. */
|
||||
private static final Duration SETTINGS_MAX_AGE = Duration.ofMinutes(30);
|
||||
private static final int SLOTS = 6;
|
||||
/** The nominal voltage of a 16-cell lithium pack; the Deye reports capacity in Ah only. */
|
||||
private static final double NOMINAL_VOLTS = 51.2;
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final GatewayBattery battery;
|
||||
private final Clock clock;
|
||||
|
||||
public BatteryService(GatewayClient gateway, GatewayBattery battery, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.battery = battery;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public BatteryResult changeSchedule(BatteryChange change, String user) throws IOException {
|
||||
return battery.changeSchedule(change, user);
|
||||
}
|
||||
|
||||
public BatteryResult changeReserve(int pct, String user) throws IOException {
|
||||
return battery.changeReserve(pct, user);
|
||||
}
|
||||
|
||||
/** Asked on its own: a gateway without battery commands still serves the live view. */
|
||||
private Reserve reserve() {
|
||||
try {
|
||||
return battery.reserve();
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public BatteryView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live settings = SnapshotFields.live(snapshot, "deye-settings", "deye_settings", clock.instant(),
|
||||
SETTINGS_MAX_AGE);
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> s = settings.fields();
|
||||
boolean settingsStale = settings.source().stale();
|
||||
Reserve reserve = reserve();
|
||||
return new BatteryView(deye.source().time(), deye.source().stale(),
|
||||
d.get("battery_soc_pct"), d.get("battery_power_w"), d.get("battery_voltage_v"),
|
||||
d.get("battery_current_a"), d.get("battery_temperature_c"), capacityKwh(s),
|
||||
new Energy(d.get("battery_charge_today_kwh"), d.get("battery_discharge_today_kwh")),
|
||||
new Energy(d.get("battery_charge_total_kwh"), d.get("battery_discharge_total_kwh")),
|
||||
settingsStale ? null : mode(s), settingsStale ? List.of() : schedule(s),
|
||||
settingsStale ? null : limits(s), settings.source().time(), settingsStale, reserve, reserve != null);
|
||||
}
|
||||
|
||||
private static Double capacityKwh(Map<String, Double> settings) {
|
||||
Double ah = settings.get("battery_capacity_ah");
|
||||
return ah == null ? null : Math.round(ah * NOMINAL_VOLTS) / 1000.0;
|
||||
}
|
||||
|
||||
private static Mode mode(Map<String, Double> s) {
|
||||
boolean tou = on(s, "tou_enabled");
|
||||
return new Mode(tou, tou ? "Harmonogram: bateria pracuje według przedziałów"
|
||||
: "Rezerwa na awarię: bateria nie rozładowuje się na co dzień");
|
||||
}
|
||||
|
||||
/** Slot n runs from its start to the start of slot n + 1; the last one wraps to the first.
|
||||
* No schedule at all when a start is missing. */
|
||||
private static List<Slot> schedule(Map<String, Double> s) {
|
||||
boolean complete = IntStream.rangeClosed(1, SLOTS).allMatch(n -> whole(s, "prog" + n + "_start_hhmm") != null);
|
||||
if (!complete) {
|
||||
return List.of();
|
||||
}
|
||||
return IntStream.rangeClosed(1, SLOTS).mapToObj(n -> {
|
||||
String prefix = "prog" + n + "_";
|
||||
String next = "prog" + (n % SLOTS + 1) + "_start_hhmm";
|
||||
return new Slot(time(whole(s, prefix + "start_hhmm")), time(whole(s, next)),
|
||||
whole(s, prefix + "power_w"), whole(s, prefix + "soc_pct"), on(s, prefix + "grid_charge"));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private static String time(int hhmm) {
|
||||
return "%02d:%02d".formatted(hhmm / 100, hhmm % 100);
|
||||
}
|
||||
|
||||
private static Limits limits(Map<String, Double> s) {
|
||||
return new Limits(whole(s, "max_charge_current_a"), whole(s, "max_discharge_current_a"),
|
||||
whole(s, "shutdown_soc_pct"), whole(s, "low_soc_pct"), whole(s, "restart_soc_pct"),
|
||||
on(s, "grid_charge_enabled"), whole(s, "grid_charge_current_a"),
|
||||
whole(s, "grid_charge_start_soc_pct"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* One remote answer kept for a while, and no longer than its local day when a zone is given. When a refresh
|
||||
* fails the older answer is served marked stale, and the source is left alone for a short back-off so a
|
||||
* source that is down does not make every request wait for its timeout.
|
||||
*/
|
||||
final class Cached<T> {
|
||||
|
||||
interface Fetch<T> {
|
||||
T fetch() throws IOException;
|
||||
}
|
||||
|
||||
record Entry<T>(T value, boolean stale) {
|
||||
}
|
||||
|
||||
private static final Duration BACK_OFF = Duration.ofSeconds(60);
|
||||
|
||||
private final Duration ttl;
|
||||
private final ZoneId dayZone;
|
||||
private final Clock clock;
|
||||
private final Fetch<T> source;
|
||||
private T value;
|
||||
private Instant fetchedAt;
|
||||
private Instant failedAt;
|
||||
|
||||
Cached(Duration ttl, Clock clock, Fetch<T> source) {
|
||||
this(ttl, null, clock, source);
|
||||
}
|
||||
|
||||
/** @param dayZone the answer expires at midnight of this zone even before the ttl, or null */
|
||||
Cached(Duration ttl, ZoneId dayZone, Clock clock, Fetch<T> source) {
|
||||
this.ttl = ttl;
|
||||
this.dayZone = dayZone;
|
||||
this.clock = clock;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
/** @throws IOException when the refresh fails or is backing off, and there is nothing older to serve */
|
||||
synchronized Entry<T> get() throws IOException {
|
||||
Instant now = clock.instant();
|
||||
if (value != null && now.isBefore(expiry())) {
|
||||
return new Entry<>(value, false);
|
||||
}
|
||||
if (failedAt != null && now.isBefore(failedAt.plus(BACK_OFF))) {
|
||||
return older(new IOException("source failed recently, not retrying yet"));
|
||||
}
|
||||
try {
|
||||
value = source.fetch();
|
||||
fetchedAt = now;
|
||||
failedAt = null;
|
||||
return new Entry<>(value, false);
|
||||
} catch (IOException e) {
|
||||
failedAt = now;
|
||||
return older(e);
|
||||
}
|
||||
}
|
||||
|
||||
private Entry<T> older(IOException failure) throws IOException {
|
||||
if (value == null) {
|
||||
throw failure;
|
||||
}
|
||||
return new Entry<>(value, true);
|
||||
}
|
||||
|
||||
private Instant expiry() {
|
||||
Instant byTtl = fetchedAt.plus(ttl);
|
||||
if (dayZone == null) {
|
||||
return byTtl;
|
||||
}
|
||||
Instant midnight = fetchedAt.atZone(dayZone).toLocalDate().plusDays(1).atStartOfDay(dayZone).toInstant();
|
||||
return midnight.isBefore(byTtl) ? midnight : byTtl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import home.backend.domain.StatsView.Totals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** What the heat pump did over some span of polls; days are added up into the totals. */
|
||||
final class DayTally {
|
||||
|
||||
private double compressorSeconds;
|
||||
private double cwuSeconds;
|
||||
private double coSeconds;
|
||||
private double heaterSeconds;
|
||||
private int starts;
|
||||
private int samples;
|
||||
private double outdoorSum;
|
||||
private int outdoorCount;
|
||||
private Double minCwu;
|
||||
private Double maxCwu;
|
||||
|
||||
void sample(HeatPumpSample s) {
|
||||
samples++;
|
||||
if (s.outdoorTemp() != null) {
|
||||
outdoorSum += s.outdoorTemp();
|
||||
outdoorCount++;
|
||||
}
|
||||
if (s.cwuTemp() != null) {
|
||||
minCwu = lower(minCwu, s.cwuTemp());
|
||||
maxCwu = higher(maxCwu, s.cwuTemp());
|
||||
}
|
||||
}
|
||||
|
||||
/** The outputs of {@code s} held for {@code held}. */
|
||||
void hold(HeatPumpSample s, Duration held) {
|
||||
double seconds = held.toMillis() / 1000.0;
|
||||
compressorSeconds += on(s.compressor()) ? seconds : 0;
|
||||
cwuSeconds += on(s.cwu()) ? seconds : 0;
|
||||
coSeconds += on(s.co()) ? seconds : 0;
|
||||
heaterSeconds += on(s.heater()) ? seconds : 0;
|
||||
}
|
||||
|
||||
void start() {
|
||||
starts++;
|
||||
}
|
||||
|
||||
void add(DayTally other) {
|
||||
compressorSeconds += other.compressorSeconds;
|
||||
cwuSeconds += other.cwuSeconds;
|
||||
coSeconds += other.coSeconds;
|
||||
heaterSeconds += other.heaterSeconds;
|
||||
starts += other.starts;
|
||||
samples += other.samples;
|
||||
outdoorSum += other.outdoorSum;
|
||||
outdoorCount += other.outdoorCount;
|
||||
if (other.minCwu != null) {
|
||||
minCwu = lower(minCwu, other.minCwu);
|
||||
maxCwu = higher(maxCwu, other.maxCwu);
|
||||
}
|
||||
}
|
||||
|
||||
Day day(LocalDate date) {
|
||||
if (samples == 0) {
|
||||
return new Day(date, null, null, null, null, null, null, null, null);
|
||||
}
|
||||
return new Day(date, hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
Totals totals() {
|
||||
return new Totals(hours(compressorSeconds), starts, hours(cwuSeconds), hours(coSeconds),
|
||||
hours(heaterSeconds), avgOutdoor(), minCwu, maxCwu);
|
||||
}
|
||||
|
||||
static boolean on(Double output) {
|
||||
return output != null && output > 0;
|
||||
}
|
||||
|
||||
private static Double lower(Double known, double value) {
|
||||
return known == null ? value : Math.min(known, value);
|
||||
}
|
||||
|
||||
private static Double higher(Double known, double value) {
|
||||
return known == null ? value : Math.max(known, value);
|
||||
}
|
||||
|
||||
private Double avgOutdoor() {
|
||||
return outdoorCount == 0 ? null : Math.round(outdoorSum / outdoorCount * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static double hours(double seconds) {
|
||||
return Math.round(seconds / 3600 * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Polish name and unit of the Deye program registers and of the app's reserve, as the gateway audits them. */
|
||||
final class DeyeSettingLabels {
|
||||
|
||||
record Label(String what, String unit) {
|
||||
}
|
||||
|
||||
private static final int TOU_ENABLE = 146;
|
||||
private static final int FIRST_START = 148;
|
||||
private static final int FIRST_SOC = 166;
|
||||
private static final int FIRST_GRID_CHARGE = 172;
|
||||
private static final int SLOTS = 6;
|
||||
|
||||
private DeyeSettingLabels() {
|
||||
}
|
||||
|
||||
static Label of(String register) {
|
||||
if ("reserve".equals(register)) {
|
||||
return new Label("Rezerwa baterii", "%");
|
||||
}
|
||||
int address;
|
||||
try {
|
||||
address = Integer.parseInt(register);
|
||||
} catch (NumberFormatException e) {
|
||||
return unknown(register);
|
||||
}
|
||||
if (address == TOU_ENABLE) {
|
||||
return new Label("Harmonogram baterii", "0/1");
|
||||
}
|
||||
return slot(address, FIRST_START, "start", "HHMM")
|
||||
.or(() -> slot(address, FIRST_SOC, "poziom", "%"))
|
||||
.or(() -> slot(address, FIRST_GRID_CHARGE, "ładowanie z sieci", "0/1"))
|
||||
.orElseGet(() -> unknown(register));
|
||||
}
|
||||
|
||||
private static Label unknown(String register) {
|
||||
return new Label("Rejestr " + register, "");
|
||||
}
|
||||
|
||||
private static Optional<Label> slot(int address, int first, String what, String unit) {
|
||||
return address >= first && address < first + SLOTS
|
||||
? Optional.of(new Label("Przedział " + (address - first + 1) + ": " + what, unit))
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
/**
|
||||
* F7 details: the Deye, Fronius and house meter fields worth a look, grouped and labelled. The
|
||||
* catalog below is the only place that knows a field's label, unit and place; showing a new field
|
||||
* is one line here.
|
||||
*/
|
||||
public class EnergyDetailsService {
|
||||
|
||||
/** A gateway field shown as {@code label}; {@code scale} turns the stored unit into {@code unit}. */
|
||||
private record Field(String key, String label, String unit, int digits, double scale) {
|
||||
}
|
||||
|
||||
private record Spec(String id, String name, String measurement, List<GroupSpec> groups) {
|
||||
}
|
||||
|
||||
/** {@code measurement} is null for the device's own one. */
|
||||
private record GroupSpec(String title, String measurement, List<Field> fields) {
|
||||
}
|
||||
|
||||
// Each helper returns the fields it stands for, so a group can mix single fields and phase triples.
|
||||
private static Field[] field(String key, String label, String unit, int digits) {
|
||||
return new Field[] {new Field(key, label, unit, digits, 1)};
|
||||
}
|
||||
|
||||
private static Field[] kwhFromWh(String key, String label, int digits) {
|
||||
return new Field[] {new Field(key, label, "kWh", digits, 0.001)};
|
||||
}
|
||||
|
||||
/** L1 to L3 of one quantity; {@code key} holds %d for the phase number. */
|
||||
private static Field[] phases(String key, String label, String unit, int digits) {
|
||||
return IntStream.rangeClosed(1, 3)
|
||||
.mapToObj(n -> new Field(key.formatted(n), label + " L" + n, unit, digits, 1)).toArray(Field[]::new);
|
||||
}
|
||||
|
||||
private static GroupSpec group(String title, Field[]... parts) {
|
||||
return groupOf(null, title, parts);
|
||||
}
|
||||
|
||||
/** A group whose fields come from another measurement of the same device. */
|
||||
private static GroupSpec groupOf(String measurement, String title, Field[]... parts) {
|
||||
return new GroupSpec(title, measurement, Arrays.stream(parts).flatMap(Arrays::stream).toList());
|
||||
}
|
||||
|
||||
private static final List<Spec> CATALOG = List.of(
|
||||
new Spec("deye", "Falownik Deye", "deye", List.of(
|
||||
group("Bateria",
|
||||
field("battery_soc_pct", "Naładowanie", "%", 0),
|
||||
field("battery_power_w", "Moc", "W", 0),
|
||||
field("battery_voltage_v", "Napięcie", "V", 1),
|
||||
field("battery_current_a", "Prąd", "A", 1),
|
||||
field("battery_temperature_c", "Temperatura", "°C", 1)),
|
||||
group("Sieć",
|
||||
field("grid_power_w", "Moc razem", "W", 0),
|
||||
phases("grid_power_l%d_w", "Moc", "W", 0),
|
||||
phases("grid_voltage_l%d_v", "Napięcie", "V", 1),
|
||||
field("grid_frequency_hz", "Częstotliwość", "Hz", 2)),
|
||||
group("Odbiór domu",
|
||||
field("load_power_w", "Moc razem", "W", 0),
|
||||
phases("load_power_l%d_w", "Moc", "W", 0)),
|
||||
group("Panele PV",
|
||||
field("pv1_power_w", "PV1 moc", "W", 0),
|
||||
field("pv1_voltage_v", "PV1 napięcie", "V", 1),
|
||||
field("pv1_current_a", "PV1 prąd", "A", 1),
|
||||
field("pv2_power_w", "PV2 moc", "W", 0),
|
||||
field("pv2_voltage_v", "PV2 napięcie", "V", 1),
|
||||
field("pv2_current_a", "PV2 prąd", "A", 1)),
|
||||
group("Dziś",
|
||||
field("pv_today_kwh", "Produkcja PV", "kWh", 1),
|
||||
field("load_today_kwh", "Zużycie domu", "kWh", 1),
|
||||
field("grid_bought_today_kwh", "Pobrano z sieci", "kWh", 1),
|
||||
field("grid_sold_today_kwh", "Oddano do sieci", "kWh", 1),
|
||||
field("battery_charge_today_kwh", "Bateria naładowana", "kWh", 1),
|
||||
field("battery_discharge_today_kwh", "Bateria rozładowana", "kWh", 1)),
|
||||
group("Od początku",
|
||||
field("pv_total_kwh", "Produkcja PV", "kWh", 0),
|
||||
field("load_total_kwh", "Zużycie domu", "kWh", 0),
|
||||
field("grid_bought_total_kwh", "Pobrano z sieci", "kWh", 0),
|
||||
field("grid_sold_total_kwh", "Oddano do sieci", "kWh", 0),
|
||||
field("battery_charge_total_kwh", "Bateria naładowana", "kWh", 0),
|
||||
field("battery_discharge_total_kwh", "Bateria rozładowana", "kWh", 0)),
|
||||
group("Falownik",
|
||||
field("inverter_power_w", "Moc wyjściowa", "W", 0),
|
||||
field("inverter_temperature_c", "Temperatura", "°C", 1),
|
||||
field("heatsink_temperature_c", "Radiator", "°C", 1)))),
|
||||
new Spec("fronius", "Falownik Fronius", "fronius", List.of(
|
||||
group("Teraz", field("pv_power_w", "Moc PV", "W", 0)),
|
||||
group("Produkcja",
|
||||
kwhFromWh("energy_day_wh", "Dziś", 1),
|
||||
kwhFromWh("energy_year_wh", "Ten rok", 0),
|
||||
kwhFromWh("energy_total_wh", "Od początku", 0)))),
|
||||
new Spec("house-meter", "Licznik domu", "PRAD2", List.of(
|
||||
group("Moc czynna",
|
||||
field("total_power_active", "Razem", "W", 0),
|
||||
phases("p%d_power_active", "Moc", "W", 0)),
|
||||
group("Napięcie", phases("p%d_voltage", "Napięcie", "V", 1)),
|
||||
group("Prąd", phases("p%d_current", "Prąd", "A", 2)),
|
||||
group("Współczynnik mocy", phases("p%d_power_factor", "cos φ", "", 2)),
|
||||
group("Sieć", field("frequency", "Częstotliwość", "Hz", 2)),
|
||||
group("Energia od początku",
|
||||
field("import_energy_active", "Pobrana", "kWh", 0),
|
||||
field("export_energy_active", "Oddana", "kWh", 0)),
|
||||
groupOf("house_meter_phases", "Energia na fazę",
|
||||
phases("l%d_import_energy_active_kwh", "Pobrana", "kWh", 0),
|
||||
phases("l%d_export_energy_active_kwh", "Oddana", "kWh", 0)))));
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyDetailsService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyDetails current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
return new EnergyDetails(CATALOG.stream().map(spec -> device(spec, snapshot)).toList());
|
||||
}
|
||||
|
||||
private Device device(Spec spec, GatewaySnapshot snapshot) {
|
||||
Live live = SnapshotFields.live(snapshot, spec.id(), spec.measurement(), clock.instant());
|
||||
List<Group> groups = spec.groups().stream()
|
||||
.map(g -> group(g, g.measurement() == null ? live.fields() : fieldsOf(snapshot, spec.id(), g)))
|
||||
.filter(g -> !g.items().isEmpty()).toList();
|
||||
return new Device(spec.id(), spec.name(), live.source().time(), live.source().stale(), groups);
|
||||
}
|
||||
|
||||
private Map<String, Double> fieldsOf(GatewaySnapshot snapshot, String device, GroupSpec group) {
|
||||
return SnapshotFields.live(snapshot, device, group.measurement(), clock.instant()).fields();
|
||||
}
|
||||
|
||||
private static Group group(GroupSpec spec, Map<String, Double> fields) {
|
||||
List<Item> items = spec.fields().stream().filter(f -> fields.containsKey(f.key()))
|
||||
.map(f -> new Item(f.label(), scaled(fields.get(f.key()), f), f.unit(), f.digits())).toList();
|
||||
return new Group(spec.title(), items);
|
||||
}
|
||||
|
||||
/** Rounded to the shown precision, so a conversion leaves no binary noise such as 4.3999999. */
|
||||
private static double scaled(double value, Field f) {
|
||||
double factor = Math.pow(10, f.digits());
|
||||
return Math.round(value * f.scale() * factor) / factor;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,21 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
/** One inverter's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
private record Inverter(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -31,8 +26,8 @@ public class EnergyService {
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Inverter deye = inverter(snapshot, "deye");
|
||||
Inverter fronius = inverter(snapshot, "fronius");
|
||||
Live deye = SnapshotFields.live(snapshot, "deye", "deye", clock.instant());
|
||||
Live fronius = SnapshotFields.live(snapshot, "fronius", "fronius", clock.instant());
|
||||
Map<String, Double> d = deye.fields();
|
||||
Map<String, Double> f = fronius.fields();
|
||||
|
||||
@@ -46,13 +41,6 @@ public class EnergyService {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The weather forecast service: the past 14 days and the next 3. */
|
||||
public interface ForecastSource {
|
||||
|
||||
Forecast forecast() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Battery settings through the gateway, which checks every change (ADR-013). */
|
||||
public interface GatewayBattery {
|
||||
|
||||
/** @throws IOException when the gateway has no battery commands, or cannot be reached */
|
||||
Reserve reserve() throws IOException;
|
||||
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException;
|
||||
|
||||
BatteryResult changeReserve(int pct, String requestedBy) throws IOException;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -12,5 +13,11 @@ public interface GatewayCommands {
|
||||
/** A verdict, even a negative one; IOException only when no verdict could be obtained. */
|
||||
CommandResult apply(Command command) throws IOException;
|
||||
|
||||
/** Ends the pending change of a setpoint now; a verdict like {@link #apply}. */
|
||||
RestoreResult restore(String setpoint, String requestedBy) throws IOException;
|
||||
|
||||
SetpointState setpoints() throws IOException;
|
||||
|
||||
/** Keeps the gateway's live restores alive; returns how many it extended. */
|
||||
int renewLeases() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.RefreshResult;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Asks the gateway for fresh readings; it decides which devices may be polled again yet. */
|
||||
public interface GatewayRefresh {
|
||||
|
||||
RefreshResult refresh() throws IOException;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
@@ -13,19 +14,21 @@ public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
private final String version;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock) {
|
||||
public HealthService(GatewayClient gateway, Clock clock, String version) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
List<DeviceHealth> devices = snapshot.devices().stream().map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices, version, snapshot.version());
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of());
|
||||
return new HealthView(clock.instant(), false, List.of(), version, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** The heat pump polls stored in InfluxDB, oldest first. */
|
||||
public interface HeatPumpSamples {
|
||||
|
||||
List<HeatPumpSample> samples(Instant from, Instant to) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package home.backend.application;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Tells the gateway now and then that we are still here (ADR-011): while it hears us, our setpoint
|
||||
* changes stay; once we or the tunnel are gone for 15 minutes, it restores them on its own.
|
||||
*/
|
||||
public class LeaseRenewer implements AutoCloseable {
|
||||
|
||||
/** A third of the gateway's lease, so two renewals in a row may fail. */
|
||||
public static final Duration PERIOD = Duration.ofMinutes(5);
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LeaseRenewer.class);
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(
|
||||
Thread.ofVirtual().name("lease-renewal").factory());
|
||||
|
||||
public LeaseRenewer(GatewayCommands gateway) {
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
start(PERIOD);
|
||||
}
|
||||
|
||||
void start(Duration period) {
|
||||
scheduler.scheduleWithFixedDelay(this::renew, 0, period.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** A failure is logged and retried next cycle; an exception would end the schedule for good. */
|
||||
void renew() {
|
||||
try {
|
||||
int renewed = gateway.renewLeases();
|
||||
LOGGER.debug("renewed {} lease(s)", renewed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
LOGGER.warn("could not renew the gateway leases, retrying in {}: {}", PERIOD, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.SnapshotFields.Live;
|
||||
import home.backend.domain.PiSensorView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
|
||||
/** The BME280 on the Pi, read by the gateway every 5 minutes. */
|
||||
public class PiSensorService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public PiSensorService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public PiSensorView current() throws IOException {
|
||||
Live live = SnapshotFields.live(gateway.snapshot(), "bme280", "MEASUREMENT", clock.instant());
|
||||
Map<String, Double> f = live.fields();
|
||||
return new PiSensorView(live.source().time(), live.source().stale(),
|
||||
f.get("temperature"), f.get("humidity"), f.get("pressure"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the panels produced, from InfluxDB. */
|
||||
public interface PvEnergyRepository {
|
||||
|
||||
/** Deye plus Fronius kWh per local day from {@code from} up to {@code to}; days missing either are absent. */
|
||||
Map<LocalDate, Double> dailyKwh(LocalDate from, LocalDate to) throws IOException;
|
||||
}
|
||||
@@ -7,15 +7,20 @@ import home.backend.domain.Preset.Change;
|
||||
import home.backend.domain.PresetResult;
|
||||
import home.backend.domain.PresetResult.ChangeResult;
|
||||
import home.backend.domain.PresetResult.ChangeStatus;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.domain.SetpointsView;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
@@ -38,48 +43,86 @@ public class SetpointService {
|
||||
return gateway.apply(new Command(setpoint, value, duration(hours), user));
|
||||
}
|
||||
|
||||
/** Puts the value from before the pending change back now, instead of waiting for its end. */
|
||||
public RestoreResult restore(String setpoint, String user) throws IOException {
|
||||
return gateway.restore(setpoint, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty if there is no such preset. A preset that does not fit the gateway's limits writes nothing.
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, String user) throws IOException {
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
List<Change> changes = preset.get().changes();
|
||||
if (preset.get().isHeld() != (until != null)) {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, limits)).toList();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
if (checked.stream().anyMatch(r -> r.status() == ChangeStatus.REJECTED)) {
|
||||
return Optional.of(PresetResult.of(checked));
|
||||
}
|
||||
return Optional.of(PresetResult.of(run(changes, user)));
|
||||
return Optional.of(PresetResult.of(run(changes, until, user)));
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, String user) {
|
||||
private static List<Change> withValues(List<Change> changes, Map<String, Integer> values) {
|
||||
Set<String> own = changes.stream().map(Change::setpoint).collect(Collectors.toSet());
|
||||
if (!own.containsAll(values.keySet())) {
|
||||
throw new IllegalArgumentException("values may only be for the setpoints the preset changes: " + own);
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), values.getOrDefault(c.setpoint(), c.value()),
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
if (value != null && !limit.allows(value)) {
|
||||
throw new IllegalArgumentException(limit.name() + " must be " + limit.min() + "–" + limit.max()
|
||||
+ " in steps of " + limit.step());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ChangeResult> run(List<Change> changes, Instant until, String user) {
|
||||
List<ChangeResult> results = new ArrayList<>();
|
||||
for (Change change : changes) {
|
||||
boolean stopped = !results.isEmpty() && results.getLast().status() != ChangeStatus.APPLIED;
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, user));
|
||||
results.add(stopped ? ChangeResult.notAttempted() : attempt(change, until, user));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private ChangeResult attempt(Change change, String user) {
|
||||
private ChangeResult attempt(Change change, Instant until, String user) {
|
||||
try {
|
||||
return ChangeResult.of(apply(change.setpoint(), change.value(), change.hours(), user));
|
||||
var command = until == null ? new Command(change.setpoint(), change.value(), duration(change.hours()), user)
|
||||
: new Command(change.setpoint(), change.value(), null, until, user);
|
||||
return ChangeResult.of(gateway.apply(command));
|
||||
} catch (IOException e) {
|
||||
return new ChangeResult(ChangeStatus.UNKNOWN, "no answer from the gateway", null);
|
||||
}
|
||||
}
|
||||
|
||||
/** REJECTED with the reason, or NOT_ATTEMPTED (nothing is sent if any change is rejected). */
|
||||
private static ChangeResult check(Change change, List<Setpoint> limits) {
|
||||
String problem = problem(change, limits);
|
||||
private static ChangeResult check(Change change, boolean held, List<Setpoint> limits) {
|
||||
String problem = problem(change, held, limits);
|
||||
return problem == null ? ChangeResult.notAttempted() : new ChangeResult(ChangeStatus.REJECTED, problem, null);
|
||||
}
|
||||
|
||||
private static String problem(Change change, List<Setpoint> limits) {
|
||||
private static String problem(Change change, boolean held, List<Setpoint> limits) {
|
||||
Optional<Setpoint> known = limits.stream().filter(s -> s.name().equals(change.setpoint())).findFirst();
|
||||
if (known.isEmpty()) {
|
||||
return "preset sets " + change.setpoint() + ", which the gateway does not know";
|
||||
@@ -88,6 +131,11 @@ public class SetpointService {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", outside the gateway's limits "
|
||||
+ known.get().min() + "–" + known.get().max() + " step " + known.get().step();
|
||||
}
|
||||
// The gateway refuses a held raise too; asking first keeps a preset from stopping half way.
|
||||
if (held && known.get().value() != null && change.value() >= known.get().value()) {
|
||||
return "preset sets " + change.setpoint() + " to " + change.value() + ", which is not below the current "
|
||||
+ known.get().value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
@@ -25,8 +26,28 @@ final class SnapshotFields {
|
||||
|
||||
/** 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 stale(device, reading, now, MAX_AGE);
|
||||
}
|
||||
|
||||
static boolean stale(Optional<DeviceSnapshot> device, Optional<Measurement> reading, Instant now,
|
||||
Duration maxAge) {
|
||||
return device.isEmpty() || !device.get().up() || reading.isEmpty()
|
||||
|| Duration.between(reading.get().time(), now).compareTo(MAX_AGE) > 0;
|
||||
|| Duration.between(reading.get().time(), now).compareTo(maxAge) > 0;
|
||||
}
|
||||
|
||||
/** One device's fields; empty when it is stale, so nothing old is shown as current. */
|
||||
record Live(Source source, Map<String, Double> fields) {
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now) {
|
||||
return live(snapshot, device, measurement, now, MAX_AGE);
|
||||
}
|
||||
|
||||
static Live live(GatewaySnapshot snapshot, String device, String measurement, Instant now, Duration maxAge) {
|
||||
Optional<Measurement> reading = find(snapshot, device, measurement);
|
||||
boolean stale = stale(device(snapshot, device), reading, now, maxAge);
|
||||
Source source = new Source(device, reading.map(Measurement::time).orElse(null), stale);
|
||||
return new Live(source, stale ? Map.of() : reading.get().fields());
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* F2: the heat pump's work per local day over the last 7 or 30 days. A poll is taken to hold its
|
||||
* outputs until the next poll, but at most {@link #MAX_HOLD}, so a gap in the data adds no hours.
|
||||
*/
|
||||
public class StatsService {
|
||||
|
||||
static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
static final Duration MAX_HOLD = Duration.ofSeconds(600);
|
||||
private static final Map<String, Integer> RANGE_DAYS = Map.of("7d", 7, "30d", 30);
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public StatsService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 7d} or {@code 30d}
|
||||
* @throws IllegalArgumentException for an unknown range
|
||||
*/
|
||||
public StatsView stats(String range) throws IOException {
|
||||
Integer length = range == null ? null : RANGE_DAYS.get(range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
LocalDate first = now.atZone(ZONE).toLocalDate().minusDays(length - 1);
|
||||
Map<LocalDate, DayTally> tallies = new TreeMap<>();
|
||||
for (int i = 0; i < length; i++) {
|
||||
tallies.put(first.plusDays(i), new DayTally());
|
||||
}
|
||||
tally(repository.samples(first.atStartOfDay(ZONE).toInstant(), now), now, tallies);
|
||||
|
||||
DayTally all = new DayTally();
|
||||
List<Day> days = new ArrayList<>();
|
||||
tallies.forEach((date, tally) -> {
|
||||
days.add(tally.day(date));
|
||||
all.add(tally);
|
||||
});
|
||||
return new StatsView(range, days, all.totals());
|
||||
}
|
||||
|
||||
private static void tally(List<HeatPumpSample> samples, Instant now, Map<LocalDate, DayTally> tallies) {
|
||||
HeatPumpSample previous = null;
|
||||
for (int i = 0; i < samples.size(); i++) {
|
||||
HeatPumpSample s = samples.get(i);
|
||||
DayTally day = tallies.get(s.t().atZone(ZONE).toLocalDate());
|
||||
day.sample(s);
|
||||
if (previous != null && previous.compressor() != null && previous.compressor() == 0
|
||||
&& DayTally.on(s.compressor())) {
|
||||
day.start();
|
||||
}
|
||||
previous = s;
|
||||
hold(s, i + 1 < samples.size() ? samples.get(i + 1).t() : now, tallies);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds the time {@code s} was in force to the days it falls in; a stretch over midnight is split. */
|
||||
private static void hold(HeatPumpSample s, Instant next, Map<LocalDate, DayTally> tallies) {
|
||||
Instant end = Duration.between(s.t(), next).compareTo(MAX_HOLD) > 0 ? s.t().plus(MAX_HOLD) : next;
|
||||
Instant at = s.t();
|
||||
while (at.isBefore(end)) {
|
||||
LocalDate date = at.atZone(ZONE).toLocalDate();
|
||||
Instant midnight = date.plusDays(1).atStartOfDay(ZONE).toInstant();
|
||||
Instant stop = midnight.isBefore(end) ? midnight : end;
|
||||
tallies.get(date).hold(s, Duration.between(at, stop));
|
||||
at = stop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** Weather warnings that cover the house, whether in force now or announced. */
|
||||
public interface WarningSource {
|
||||
|
||||
List<Warning> warnings() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.Cached.Entry;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.PvCalibration;
|
||||
import home.backend.domain.WeatherCodes;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Day;
|
||||
import home.backend.domain.WeatherView.Now;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Forecast, PV estimate and warnings, each kept for a while so the phones do not load the sources. */
|
||||
public class WeatherService {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Warsaw");
|
||||
private static final int CALIBRATION_DAYS = 14;
|
||||
|
||||
private record Calibrated(Forecast forecast, Double factor) {
|
||||
}
|
||||
|
||||
private final Clock clock;
|
||||
private final PvEnergyRepository pv;
|
||||
private final Cached<Calibrated> forecast;
|
||||
private final Cached<List<Warning>> warnings;
|
||||
|
||||
public WeatherService(ForecastSource forecastSource, WarningSource warningSource, PvEnergyRepository pv,
|
||||
Clock clock) {
|
||||
this.clock = clock;
|
||||
this.pv = pv;
|
||||
this.forecast = new Cached<>(Duration.ofMinutes(30), ZONE, clock, () -> calibrated(forecastSource.forecast()));
|
||||
this.warnings = new Cached<>(Duration.ofMinutes(10), clock, warningSource::warnings);
|
||||
}
|
||||
|
||||
/** @throws IOException when there is no forecast at all; warnings alone may be missing. */
|
||||
public WeatherView current() throws IOException {
|
||||
Entry<Calibrated> weather = forecast.get();
|
||||
Entry<List<Warning>> warned = warnings();
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Calibrated w = weather.value();
|
||||
return new WeatherView(clock.instant(),
|
||||
day(w, today), day(w, today.plusDays(1)), day(w, today.plusDays(2)),
|
||||
new Now(w.forecast().temperature()), inForce(warned.value()), w.factor(),
|
||||
weather.stale() || warned.stale());
|
||||
}
|
||||
|
||||
private Entry<List<Warning>> warnings() {
|
||||
try {
|
||||
return warnings.get();
|
||||
} catch (IOException e) {
|
||||
return new Entry<>(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Warning> inForce(List<Warning> all) {
|
||||
return all.stream().filter(w -> w.to().toInstant().isAfter(clock.instant())).toList();
|
||||
}
|
||||
|
||||
private Calibrated calibrated(Forecast fetched) {
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZONE));
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
fetched.days().forEach((date, day) -> {
|
||||
if (day.radiationMj() != null) {
|
||||
radiation.put(date, day.radiationMj());
|
||||
}
|
||||
});
|
||||
try {
|
||||
var measured = pv.dailyKwh(today.minusDays(CALIBRATION_DAYS), today);
|
||||
return new Calibrated(fetched, PvCalibration.factor(measured, radiation));
|
||||
} catch (IOException e) {
|
||||
return new Calibrated(fetched, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static Day day(Calibrated w, LocalDate date) throws IOException {
|
||||
DailyForecast d = w.forecast().days().get(date);
|
||||
if (d == null) {
|
||||
throw new IOException("forecast has no " + date);
|
||||
}
|
||||
Double kwh = w.factor() == null || d.radiationMj() == null ? null : round(w.factor() * d.radiationMj());
|
||||
Double sunshine = d.sunshineSeconds() == null ? null : round(d.sunshineSeconds() / 3600);
|
||||
return new Day(date, d.weatherCode(), WeatherCodes.describe(d.weatherCode()), d.tMax(), d.tMin(),
|
||||
d.precipitationMm(), sunshine, kwh);
|
||||
}
|
||||
|
||||
private static double round(double value) {
|
||||
return Math.round(value * 10) / 10.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One audited write. {@code unit} is "°C", "%", "HHMM" (a clock time written as 1830) or "0/1" (off/on);
|
||||
* the values are null when the gateway did not record them.
|
||||
*/
|
||||
public record AuditEntry(Instant time, String source, String what, Integer oldValue, Integer newValue,
|
||||
String unit, String who, String status) {
|
||||
|
||||
public static final String HEAT_PUMP = "heat_pump";
|
||||
public static final String BATTERY = "battery";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The audited writes of the heat pump and the battery, newest first. */
|
||||
public record AuditView(List<AuditEntry> entries) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A change of the Time of Use switch and of single program slots (1 to 6); a null field stays as it is.
|
||||
* The gateway checks every rule (ADR-013).
|
||||
*/
|
||||
public record BatteryChange(Boolean enabled, List<Slot> slots) {
|
||||
|
||||
public BatteryChange {
|
||||
slots = slots == null ? List.of() : List.copyOf(slots);
|
||||
}
|
||||
|
||||
/** {@code from} is the slot's start, to the minute. */
|
||||
public record Slot(int slot, LocalTime from, Integer socPct, Boolean gridCharge) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The gateway's verdict on a battery change, passed on unchanged. REJECTED: refused by the rules, nothing
|
||||
* written. STALE: the inverter's settings are unknown or changed meanwhile, nothing written. TOO_SOON: S4.
|
||||
* FAILED: a write or its read-back went wrong; {@code read} has the registers as read back.
|
||||
*/
|
||||
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
|
||||
|
||||
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The Deye's battery (F7), read-only. Power in W, + discharge / − charge. The live values
|
||||
* ({@code time}, {@code stale}) come from the Deye's readings and are null when stale; the settings
|
||||
* ({@code settingsTime}, {@code settingsStale}: mode, schedule, limits, capacity) come from their own,
|
||||
* slower reading and are null (empty schedule) when stale. The capacity assumes the nominal 51.2 V.
|
||||
* {@code reserve} is the app's backup reserve (ADR-013) and {@code editable} says the gateway accepts
|
||||
* changes; they are null and false when it does not.
|
||||
*/
|
||||
public record BatteryView(Instant time, boolean stale, Double socPct, Double powerW, Double voltageV,
|
||||
Double currentA, Double temperatureC, Double capacityKwh, Energy today, Energy total, Mode mode,
|
||||
List<Slot> schedule, Limits limits, Instant settingsTime, boolean settingsStale, Reserve reserve,
|
||||
boolean editable) {
|
||||
|
||||
/** The share of the battery kept for a power cut, and the lowest the user may set. */
|
||||
public record Reserve(int pct, int floorPct) {
|
||||
}
|
||||
|
||||
/** Battery energy in kWh. */
|
||||
public record Energy(Double chargeKwh, Double dischargeKwh) {
|
||||
}
|
||||
|
||||
/** Time of use on means the battery follows the schedule; off means backup only. */
|
||||
public record Mode(boolean touEnabled, String description) {
|
||||
}
|
||||
|
||||
/** A program slot, {@code from} and {@code to} as HH:MM; it runs until the next slot starts. */
|
||||
public record Slot(String from, String to, int powerW, int socPct, boolean gridCharge) {
|
||||
}
|
||||
|
||||
public record Limits(Integer maxChargeA, Integer maxDischargeA, Integer shutdownSocPct,
|
||||
Integer lowSocPct, Integer restartSocPct, boolean gridChargeEnabled, Integer gridChargeCurrentA,
|
||||
Integer gridChargeStartSocPct) {
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** Set a setpoint; with a duration the gateway restores the old value afterwards, else it stays. */
|
||||
public record Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
/**
|
||||
* Set a setpoint. With a duration the gateway restores the old value afterwards; with
|
||||
* {@code heldUntil} it holds a lowering value until then without renewals (ADR-012); else it stays
|
||||
* while the backend renews the lease (ADR-011).
|
||||
*/
|
||||
public record Command(String setpoint, int value, Duration duration, Instant heldUntil, String requestedBy) {
|
||||
|
||||
public Command(String setpoint, int value, Duration duration, String requestedBy) {
|
||||
this(setpoint, value, duration, null, requestedBy);
|
||||
}
|
||||
|
||||
/** The gateway refuses longer boosts too; we refuse first, so the phone gets a clear 400. */
|
||||
public static final int MAX_HOURS = 72;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Every value the energy devices report, grouped for a details panel (F7). A device without a
|
||||
* fresh reading has {@code stale} true and no groups; a field the device did not report is absent.
|
||||
*/
|
||||
public record EnergyDetails(List<Device> devices) {
|
||||
|
||||
/** One device; {@code id} is the gateway's device name. */
|
||||
public record Device(String id, String name, Instant time, boolean stale, List<Group> groups) {
|
||||
}
|
||||
|
||||
public record Group(String title, List<Item> items) {
|
||||
}
|
||||
|
||||
/** A value in {@code unit}, to be shown with {@code digits} decimals. */
|
||||
public record Item(String label, double value, String unit, int digits) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/** What the forecast service says: the temperature now and a record per calendar day, past days included. */
|
||||
public record Forecast(Double temperature, Map<LocalDate, DailyForecast> days) {
|
||||
|
||||
/** Radiation in MJ/m² per day, sunshine in seconds; any value may be missing. */
|
||||
public record DailyForecast(Integer weatherCode, Double tMax, Double tMin, Double precipitationMm,
|
||||
Double radiationMj, Double sunshineSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
/** The gateway's latest state of every device, as the backend sees it.
|
||||
* The version is the gateway's release tag, null for a gateway that does not report it yet. */
|
||||
public record GatewaySnapshot(String version, List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package home.backend.domain;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
/** Health of the chain phone → backend → gateway → devices (F5), with the deployed versions.
|
||||
* The gateway's version is null when it is unreachable or too old to report it. */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices,
|
||||
String backendVersion, String gatewayVersion) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
||||
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
|
||||
Double outdoorTemp, Double cwuTemp) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The BME280 sensor on the Raspberry Pi: °C, % and hPa. When it is stale (device down, no reading
|
||||
* or older than 2 × 300 s) the values are null and {@code stale} is true; {@code time} is null
|
||||
* without any reading.
|
||||
*/
|
||||
public record PiSensorView(Instant time, boolean stale, Double temperature, Double humidity, Double pressure) {
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A named one-tap bundle of setpoint changes (F3), e.g. boost or away. */
|
||||
public record Preset(String id, String name, List<Change> changes) {
|
||||
/**
|
||||
* A named one-tap bundle of setpoint changes (F3), e.g. boost or away. {@code until} is null, or
|
||||
* {@link #CHOSEN}: the user picks the end on a calendar and the gateway holds the changes until
|
||||
* then without renewals (ADR-012).
|
||||
*/
|
||||
public record Preset(String id, String name, @JsonInclude(JsonInclude.Include.NON_NULL) String until,
|
||||
List<Change> changes) {
|
||||
|
||||
public static final String CHOSEN = "chosen";
|
||||
|
||||
/** {@code hours} null means "until changed back". */
|
||||
public record Change(String setpoint, int value, Double hours) {
|
||||
@@ -18,6 +28,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public boolean isHeld() {
|
||||
return CHOSEN.equals(until);
|
||||
}
|
||||
|
||||
public Preset {
|
||||
if (id == null || id.isBlank() || name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("a preset needs an id and a name");
|
||||
@@ -25,5 +40,11 @@ public record Preset(String id, String name, List<Change> changes) {
|
||||
if (changes == null || changes.isEmpty()) {
|
||||
throw new IllegalArgumentException("preset " + id + " has no changes");
|
||||
}
|
||||
if (until != null && !until.equals(CHOSEN)) {
|
||||
throw new IllegalArgumentException("preset " + id + ": until can only be \"" + CHOSEN + "\"");
|
||||
}
|
||||
if (CHOSEN.equals(until) && changes.stream().anyMatch(change -> change.hours() != null)) {
|
||||
throw new IllegalArgumentException("preset " + id + " is held until a chosen time, so no hours");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The PV output per unit of radiation, found from past days instead of the panels' tilt and azimuth:
|
||||
* measured kWh over forecast MJ/m², summed over the days that have both.
|
||||
*/
|
||||
public final class PvCalibration {
|
||||
|
||||
static final int MIN_DAYS = 5;
|
||||
|
||||
private PvCalibration() {
|
||||
}
|
||||
|
||||
/** Null with fewer than {@value MIN_DAYS} days having both values or no radiation at all. */
|
||||
public static Double factor(Map<LocalDate, Double> measuredKwh, Map<LocalDate, Double> radiationMj) {
|
||||
double kwh = 0;
|
||||
double mj = 0;
|
||||
int days = 0;
|
||||
for (var day : measuredKwh.entrySet()) {
|
||||
Double radiation = radiationMj.get(day.getKey());
|
||||
if (radiation != null) {
|
||||
kwh += day.getValue();
|
||||
mj += radiation;
|
||||
days++;
|
||||
}
|
||||
}
|
||||
return days < MIN_DAYS || mj <= 0 ? null : kwh / mj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The gateway's answer to a refresh: the devices it will poll again, and those polled too recently. */
|
||||
public record RefreshResult(List<String> requested, List<Skipped> skipped) {
|
||||
|
||||
public record Skipped(String device, long retryInSeconds) {
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A boost the gateway will undo at {@code due}, unless someone changed the register meanwhile. */
|
||||
/**
|
||||
* A change the gateway will undo at {@code due} (null: until changed), unless someone changed the
|
||||
* register meanwhile. It also undoes it when we stop renewing the lease (ADR-011).
|
||||
*/
|
||||
public record Restore(String setpoint, int register, int originalValue, int appliedValue, Instant due) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** The gateway's answer to "restore now", passed on to the phone unchanged. */
|
||||
public record RestoreResult(Outcome outcome, String message) {
|
||||
|
||||
/**
|
||||
* NOT_PENDING: nothing to restore. CONFLICT: the register was changed meanwhile, so it was left alone.
|
||||
* TOO_SOON: the gateway's rate limit. FAILED: the restore stays pending and is retried.
|
||||
*/
|
||||
public enum Outcome { RESTORED, NOT_PENDING, CONFLICT, TOO_SOON, FAILED }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Heat pump work per local day (F2). Hours are approximated from the polls, starts are counted
|
||||
* between polls, so short cycles are missed. A day without polls has null values.
|
||||
*/
|
||||
public record StatsView(String range, List<Day> days, Totals totals) {
|
||||
|
||||
public record Day(LocalDate date, Double compressorHours, Integer compressorStarts, Double cwuHours,
|
||||
Double coHours, Double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
|
||||
public record Totals(double compressorHours, int compressorStarts, double cwuHours, double coHours,
|
||||
double heaterHours, Double avgOutdoorTemp, Double minCwuTemp, Double maxCwuTemp) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package home.backend.domain;
|
||||
|
||||
/** WMO weather interpretation codes as Polish text. */
|
||||
public final class WeatherCodes {
|
||||
|
||||
private WeatherCodes() {
|
||||
}
|
||||
|
||||
public static String describe(Integer code) {
|
||||
if (code == null) {
|
||||
return "Brak danych";
|
||||
}
|
||||
return switch (code) {
|
||||
case 0 -> "Bezchmurnie";
|
||||
case 1 -> "Przeważnie słonecznie";
|
||||
case 2 -> "Częściowe zachmurzenie";
|
||||
case 3 -> "Pochmurno";
|
||||
case 45, 48 -> "Mgła";
|
||||
case 51, 53, 55 -> "Mżawka";
|
||||
case 56, 57 -> "Marznąca mżawka";
|
||||
case 61 -> "Słaby deszcz";
|
||||
case 63 -> "Deszcz";
|
||||
case 65 -> "Silny deszcz";
|
||||
case 66, 67 -> "Marznący deszcz";
|
||||
case 71 -> "Słaby śnieg";
|
||||
case 73 -> "Śnieg";
|
||||
case 75 -> "Intensywny śnieg";
|
||||
case 77 -> "Ziarna śnieżne";
|
||||
case 80, 81 -> "Przelotny deszcz";
|
||||
case 82 -> "Ulewa";
|
||||
case 85, 86 -> "Przelotny śnieg";
|
||||
case 95 -> "Burza";
|
||||
case 96, 99 -> "Burza z gradem";
|
||||
default -> "Brak danych";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Weather at the house: three days, the temperature now and the IMGW warnings in force or announced.
|
||||
* {@code pvFactor} (kWh per MJ/m² of forecast radiation) and every {@code pvForecastKwh} are null while
|
||||
* there are too few measured days to calibrate. {@code stale} means part of it is an older answer
|
||||
* because a source could not be reached.
|
||||
*/
|
||||
public record WeatherView(Instant time, Day today, Day tomorrow, Day dayAfter, Now now,
|
||||
List<Warning> warnings, Double pvFactor, boolean stale) {
|
||||
|
||||
public record Day(LocalDate date, Integer weatherCode, String description, Double tMax, Double tMin,
|
||||
Double precipitationMm, Double sunshineHours, Double pvForecastKwh) {
|
||||
}
|
||||
|
||||
public record Now(Double temperature) {
|
||||
}
|
||||
|
||||
/** {@code level} is IMGW's degree 1 to 3. */
|
||||
public record Warning(String event, int level, OffsetDateTime from, OffsetDateTime to, String text) {
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -151,6 +151,11 @@ class BackendTest {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesPiSensorFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
|
||||
@@ -28,8 +28,8 @@ class PresetFileTest {
|
||||
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 Preset("cwu-now", "Ciepła woda teraz", null, List.of(new Change("cwu_economy", 45, 2.0))),
|
||||
new Preset("away", "Wyjazd", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 15, null)))), presets);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class PresetFileTest {
|
||||
[{"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);
|
||||
assertEquals(List.of(new Preset("bath", "Kąpiel", null, List.of(new Change("cwu_economy", 48, 1.5)))), presets);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -51,6 +51,8 @@ class PresetFileTest {
|
||||
"[{\"id\": \"\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 0}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 73}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"tomorrow\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"until\": \"chosen\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15, \"hours\": 2}]}]",
|
||||
"[{\"id\": \"a\", \"name\": \"A\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 15}]},"
|
||||
+ "{\"id\": \"a\", \"name\": \"B\", \"changes\": [{\"setpoint\": \"co_comfort\", \"value\": 16}]}]")) {
|
||||
Path file = file(json);
|
||||
|
||||
@@ -86,6 +86,20 @@ class GatewayHttpClientTest {
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheGatewayVersion() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
assertEquals("v1.2.1", client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOldGatewayWithoutVersionHasNullVersion() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
assertNull(client.snapshot().version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
@@ -2,9 +2,15 @@ package home.backend.adapters.gateway;
|
||||
|
||||
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.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,6 +24,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -32,7 +39,7 @@ 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 record Seen(String method, String path, String authorization, String requestedBy, String body) {
|
||||
}
|
||||
|
||||
private final List<Seen> seen = new ArrayList<>();
|
||||
@@ -59,16 +66,20 @@ class GatewayHttpCommandsTest {
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
for (String path : List.of("/commands", "/battery", "/refresh")) {
|
||||
server.createContext(path, this::answer);
|
||||
}
|
||||
}
|
||||
|
||||
private void answer(HttpExchange exchange) throws IOException {
|
||||
seen.add(new Seen(exchange.getRequestMethod(), exchange.getRequestURI().getPath(),
|
||||
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||
exchange.getRequestHeaders().getFirst("X-Requested-By"),
|
||||
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
private GatewayHttpCommands commands(String token) {
|
||||
@@ -95,6 +106,15 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(contractJson("request-boost.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldChangeSendsTheEndAsUntil() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
|
||||
commands("s3cret-token").apply(new Command("cwu_comfort", 10, null, Instant.parse("2026-11-02T17:00:00Z"), "phone"));
|
||||
|
||||
assertEquals(contractJson("request-held.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayHasNoMinutes() throws Exception {
|
||||
serve(200, contract("response-applied.json"));
|
||||
@@ -136,6 +156,172 @@ class GatewayHttpCommandsTest {
|
||||
assertEquals(8, state.restores().getFirst().originalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUntilChangedRestoreHasNoDue() throws Exception {
|
||||
serve(200, "{\"setpoints\":[],\"restores\":[{\"setpoint\":\"cwu_comfort\",\"register\":4144,"
|
||||
+ "\"originalValue\":45,\"appliedValue\":10,\"leaseUntil\":\"2026-10-24T10:15:00Z\"}]}");
|
||||
|
||||
assertNull(commands("s3cret-token").setpoints().restores().getFirst().due());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restorePostsTheSetpointWithTokenAndUser() throws Exception {
|
||||
serve(200, contract("response-restored.json"));
|
||||
|
||||
var result = commands("s3cret-token").restore("cwu_economy", "anna");
|
||||
|
||||
assertEquals(Outcome.RESTORED, result.outcome());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/restore", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-restore.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aConflictOnRestoreIsAVerdictNotAnError() throws Exception {
|
||||
serve(409, contract("response-restore-conflict.json"));
|
||||
|
||||
assertEquals(Outcome.CONFLICT, commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyRestoreStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(404, Outcome.NOT_PENDING, 429, Outcome.TOO_SOON, 502, Outcome.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"outcome\":\"" + entry.getValue() + "\",\"message\":\"x\"}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").restore("cwu_economy", "anna").outcome());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreFailsWithoutAVerdictOrToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").restore("cwu_economy", "anna"));
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).restore("cwu_economy", "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewPostsToRenewWithTheTokenAndReturnsHowManyWereExtended() throws Exception {
|
||||
serve(200, "{\"renewed\":2}");
|
||||
|
||||
assertEquals(2, commands("s3cret-token").renewLeases());
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/commands/renew", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(503, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").renewLeases());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).renewLeases());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshPostsToRefreshWithTheTokenAndParsesTheAnswer() throws Exception {
|
||||
serve(200, Files.readString(Path.of("src/test/resources/api/refresh.json")));
|
||||
|
||||
var result = commands("s3cret-token").refresh();
|
||||
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/refresh", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals(List.of("deye", "house-meter"), result.requested());
|
||||
assertEquals(new RefreshResult.Skipped("heat-pump", 41), result.skipped().getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshFailsOnAnyOtherStatusOrWithoutToken() {
|
||||
serve(401, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").refresh());
|
||||
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).refresh());
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheBatteryReserveWithTheToken() throws Exception {
|
||||
serve(200, contract("battery-reserve.json"));
|
||||
|
||||
var reserve = commands("s3cret-token").reserve();
|
||||
|
||||
assertEquals(new BatteryView.Reserve(50, 30), reserve);
|
||||
assertEquals("GET", seen.getFirst().method());
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAScheduleChangeAsTheGatewayExpectsIt() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
var change = new BatteryChange(true, List.of(new BatteryChange.Slot(2, LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null)));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(change, "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.APPLIED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 90, 172, 1), result.read());
|
||||
assertEquals("POST", seen.getFirst().method());
|
||||
assertEquals("/battery/schedule", seen.getFirst().path());
|
||||
assertEquals("Bearer s3cret-token", seen.getFirst().authorization());
|
||||
assertEquals("anna", seen.getFirst().requestedBy());
|
||||
assertEquals(contractJson("request-battery-schedule.json"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsAReserveChange() throws Exception {
|
||||
serve(200, contract("response-battery-applied.json"));
|
||||
|
||||
commands("s3cret-token").changeReserve(60, "anna");
|
||||
|
||||
assertEquals("/battery/reserve", seen.getFirst().path());
|
||||
assertEquals(contractJson("{\"pct\":60}"), contractJson(seen.getFirst().body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyBatteryStatusOfTheGatewayIsAVerdict() throws Exception {
|
||||
var expected = java.util.Map.of(422, BatteryResult.Status.REJECTED, 409, BatteryResult.Status.STALE,
|
||||
429, BatteryResult.Status.TOO_SOON, 502, BatteryResult.Status.FAILED);
|
||||
for (var entry : expected.entrySet()) {
|
||||
serve(entry.getKey(), "{\"status\":\"" + entry.getValue() + "\",\"message\":\"x\",\"read\":{}}");
|
||||
|
||||
assertEquals(entry.getValue(), commands("s3cret-token").changeReserve(60, "anna").status());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedBatteryWriteCarriesWhatWasRead() throws Exception {
|
||||
serve(502, contract("response-battery-failed.json"));
|
||||
|
||||
var result = commands("s3cret-token").changeSchedule(new BatteryChange(null, null), "anna");
|
||||
|
||||
assertEquals(BatteryResult.Status.FAILED, result.status());
|
||||
assertEquals(java.util.Map.of(167, 80), result.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryCallsFailWithoutAVerdictOrToken() {
|
||||
for (int status : List.of(400, 401, 404, 503)) {
|
||||
serve(status, "{\"error\":\"x\"}");
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").reserve(), "GET " + status);
|
||||
assertThrows(IOException.class, () -> commands("s3cret-token").changeReserve(60, "anna"), "POST " + status);
|
||||
}
|
||||
var seenBefore = seen.size();
|
||||
assertThrows(IOException.class, () -> commands(null).reserve());
|
||||
assertThrows(IOException.class, () -> commands(null).changeReserve(60, "anna"));
|
||||
assertEquals(seenBefore, seen.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSetpointNotReadYetStaysNullInsteadOfZero() throws Exception {
|
||||
serve(200, "{\"setpoints\":[{\"name\":\"co_comfort\",\"register\":4132,\"min\":5,\"max\":25,\"step\":1,"
|
||||
|
||||
@@ -2,24 +2,42 @@ package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.AuditRepository;
|
||||
import home.backend.application.AuditService;
|
||||
import home.backend.application.BatteryService;
|
||||
import home.backend.application.EnergyDetailsService;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.GatewayBattery;
|
||||
import home.backend.application.GatewayCommands;
|
||||
import home.backend.application.SetpointService;
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView.Reserve;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.RefreshResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.Restore;
|
||||
import home.backend.domain.Setpoint;
|
||||
import home.backend.domain.SetpointState;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -35,11 +53,14 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -54,10 +75,18 @@ class ApiServerTest {
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private boolean auditDown;
|
||||
private HeatPumpSamples samples;
|
||||
private RuntimeException gatewayBug;
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
private Status verdict;
|
||||
private Outcome restoreOutcome;
|
||||
private int refreshes;
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean commandsDown;
|
||||
private boolean batteryDown;
|
||||
private BatteryResult.Status batteryVerdict;
|
||||
private final List<String> batterySent = new ArrayList<>();
|
||||
private int answersBeforeDrop;
|
||||
private ApiServer server;
|
||||
|
||||
@@ -81,7 +110,18 @@ class ApiServerTest {
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
samples = (from, to) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
answersBeforeDrop = Integer.MAX_VALUE;
|
||||
GatewayCommands commands = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -98,6 +138,15 @@ class ApiServerTest {
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(restoreOutcome, "restored to 8");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (commandsDown) {
|
||||
@@ -109,11 +158,63 @@ class ApiServerTest {
|
||||
new Setpoint("cwu_comfort", 4144, 5, 48, 1, 45)),
|
||||
List.of(new Restore("cwu_economy", 4143, 8, 45, Instant.parse("2026-10-24T12:00:00Z"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
batteryDown = false;
|
||||
batteryVerdict = BatteryResult.Status.APPLIED;
|
||||
GatewayBattery batteryCommands = new GatewayBattery() {
|
||||
@Override
|
||||
public Reserve reserve() throws IOException {
|
||||
if (batteryDown) {
|
||||
throw new IOException("gateway /battery/reserve answered 404");
|
||||
}
|
||||
return new Reserve(50, 30);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) throws IOException {
|
||||
return answer("schedule " + change + " by " + requestedBy);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) throws IOException {
|
||||
return answer("reserve " + pct + " by " + requestedBy);
|
||||
}
|
||||
|
||||
private BatteryResult answer(String what) throws IOException {
|
||||
if (batteryDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
batterySent.add(what);
|
||||
return switch (batteryVerdict) {
|
||||
case APPLIED -> new BatteryResult(batteryVerdict, "applied", Map.of(167, 90));
|
||||
case FAILED -> new BatteryResult(batteryVerdict,
|
||||
"registers not as written: [167]; not retried", Map.of(167, 80));
|
||||
default -> new BatteryResult(batteryVerdict, "nope", Map.of());
|
||||
};
|
||||
}
|
||||
};
|
||||
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 EnergyDetailsService(gateway, clock),
|
||||
new HealthService(gateway, clock, "v1.3.0"), new HistoryService(history, clock),
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(from, to) -> exampleMeasuredPv(), clock),
|
||||
() -> {
|
||||
if (commandsDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
refreshes++;
|
||||
return new RefreshResult(List.of("deye", "house-meter"),
|
||||
List.of(new RefreshResult.Skipped("heat-pump", 41)));
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -122,6 +223,27 @@ class ApiServerTest {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private AuditRepository auditRows() {
|
||||
return new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) throws IOException {
|
||||
if (auditDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T08:00:00Z"), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
new Row(Instant.parse("2026-10-23T07:00:00Z"), "co_comfort", "mariusz", "REJECTED", null, 22.0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) {
|
||||
return List.of(
|
||||
new Row(Instant.parse("2026-10-24T09:30:00Z"), "reserve", "mariusz", "APPLIED", 50.0, 60.0),
|
||||
new Row(Instant.parse("2026-10-23T18:00:00Z"), "174", "mariusz", "FAILED", 0.0, 1.0));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
@@ -133,18 +255,46 @@ class ApiServerTest {
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
return new GatewaySnapshot(List.of(
|
||||
"load_today_kwh", 24.2));
|
||||
deye.putAll(Map.of("battery_voltage_v", 56.49, "battery_current_a", -0.05, "battery_temperature_c", 19.2,
|
||||
"battery_charge_today_kwh", 0.5, "battery_discharge_today_kwh", 0.1,
|
||||
"battery_charge_total_kwh", 453.7, "battery_discharge_total_kwh", 162.1));
|
||||
return new GatewaySnapshot("v1.2.1", List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", true, energyTime, null,
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime))),
|
||||
new DeviceSnapshot("house-meter", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "PRAD2", Map.of("p1_voltage", 235.69,
|
||||
"p1_current", 0.32, "p1_power_active", 55.94, "p1_power_factor", 0.75,
|
||||
"frequency", 50.01, "import_energy_active", 67514.27), energyTime))),
|
||||
new DeviceSnapshot("deye-settings", true, energyTime, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), energyTime))),
|
||||
new DeviceSnapshot("bme280", true, energyTime, null,
|
||||
List.of(new Measurement("measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8), energyTime)))));
|
||||
}
|
||||
|
||||
/** The settings read from the inverter on 2026-10-24: time of use off, six 4-hour slots. */
|
||||
private static Map<String, Double> deyeSettings() {
|
||||
Map<String, Double> settings = new HashMap<>(Map.of("battery_capacity_ah", 280.0, "tou_enabled", 0.0,
|
||||
"max_charge_current_a", 80.0, "max_discharge_current_a", 120.0, "shutdown_soc_pct", 20.0,
|
||||
"low_soc_pct", 35.0, "restart_soc_pct", 50.0, "grid_charge_enabled", 1.0,
|
||||
"grid_charge_current_a", 40.0, "grid_charge_start_soc_pct", 30.0));
|
||||
double[] starts = {100, 500, 900, 1300, 1700, 2100};
|
||||
for (int slot = 1; slot <= 6; slot++) {
|
||||
settings.put("prog" + slot + "_start_hhmm", starts[slot - 1]);
|
||||
settings.put("prog" + slot + "_power_w", 10000.0);
|
||||
settings.put("prog" + slot + "_soc_pct", 80.0);
|
||||
settings.put("prog" + slot + "_grid_charge", 0.0);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
@@ -153,6 +303,10 @@ class ApiServerTest {
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static String awayRequest() throws IOException {
|
||||
return example("preset-away-request").toString();
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
@@ -178,16 +332,21 @@ class ApiServerTest {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyDetailsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/energy/details", "energy-details");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of(snapshot.devices().get(0),
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of(snapshot.devices().get(0),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out", List.of())));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutReadingMatchesUnreadExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(List.of());
|
||||
snapshot = new GatewaySnapshot("v1.2.1", List.of());
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump-unread");
|
||||
}
|
||||
|
||||
@@ -198,25 +357,205 @@ class ApiServerTest {
|
||||
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);
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/energy", "energy-deye-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/battery", "battery");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithStaleSettingsMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
devices.set(devices.size() - 2, new DeviceSnapshot("deye-settings", true, old, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", deyeSettings(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/battery", "battery-settings-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithoutGatewayCommandsIsNotEditable() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
var body = JSON.readTree(get("/api/battery").body());
|
||||
|
||||
assertTrue(body.get("reserve").isNull());
|
||||
assertEquals(false, body.get("editable").asBoolean());
|
||||
assertEquals(99.0, body.get("socPct").asDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scheduleChangeGoesToTheGatewayWithTheUserAndAnswersWithTheVerdict() throws Exception {
|
||||
var response = post("/api/battery/schedule", example("battery-schedule-request").toString(),
|
||||
"Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("schedule " + new BatteryChange(true, List.of(
|
||||
new BatteryChange.Slot(2, java.time.LocalTime.of(9, 30), 90, true),
|
||||
new BatteryChange.Slot(3, null, 85, null))) + " by anna"), batterySent);
|
||||
assertEquals(JSON.readTree("{\"status\":\"APPLIED\",\"message\":\"applied\",\"read\":{\"167\":90}}"),
|
||||
JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reserveChangeGoesToTheGateway() throws Exception {
|
||||
var response = post("/api/battery/reserve", example("battery-reserve-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of("reserve 60 by phone"), batterySent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(BatteryResult.Status.REJECTED, 422, BatteryResult.Status.STALE, 409,
|
||||
BatteryResult.Status.TOO_SOON, 429, BatteryResult.Status.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
batteryVerdict = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(entry.getValue(), post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
batteryVerdict = BatteryResult.Status.FAILED;
|
||||
assertEquals(example("battery-failed"),
|
||||
JSON.readTree(post("/api/battery/schedule", "{\"enabled\":false}").body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryChangeWithTheGatewayDownIs503() throws Exception {
|
||||
batteryDown = true;
|
||||
|
||||
assertEquals(503, post("/api/battery/reserve", "{\"pct\":60}").statusCode());
|
||||
assertEquals(503, post("/api/battery/schedule", "{\"enabled\":false}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badBatteryRequestsAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "not json", "[]", "{}", "{\"pct\":\"60\"}", "{\"pct\":60.5}", "{\"pct\":null}",
|
||||
"{\"pct\":60,\"x\":1}")) {
|
||||
assertEquals(400, post("/api/battery/reserve", body).statusCode(), body);
|
||||
}
|
||||
for (String body : List.of("", "not json", "[]", "{\"enabled\":\"yes\"}", "{\"slots\":[{\"slot\":1,\"from\":\"9h\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"from\":\"25:00\"}]}", "{\"slots\":[{\"slot\":1,\"socPct\":\"9\"}]}",
|
||||
"{\"slots\":[{\"slot\":1,\"soc\":9}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}")) {
|
||||
assertEquals(400, post("/api/battery/schedule", body).statusCode(), body);
|
||||
}
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWritesAreGuardedLikeEveryWrite() throws Exception {
|
||||
for (String path : List.of("/api/battery/reserve", "/api/battery/schedule")) {
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode(), path);
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode(), path);
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode(), path);
|
||||
assertEquals(405, get(path).statusCode(), path);
|
||||
}
|
||||
assertEquals(404, post("/api/battery/other", "{}").statusCode());
|
||||
assertTrue(batterySent.isEmpty());
|
||||
}
|
||||
|
||||
private static Forecast exampleForecast() {
|
||||
Map<LocalDate, Forecast.DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(LocalDate.parse("2026-10-19").plusDays(i), new Forecast.DailyForecast(3, 12.0, 4.0, 0.0, 10.0, 7200.0));
|
||||
}
|
||||
days.put(LocalDate.parse("2026-10-24"), new Forecast.DailyForecast(3, 10.9, 3.8, 0.0, 9.0, 7151.0));
|
||||
days.put(LocalDate.parse("2026-10-25"), new Forecast.DailyForecast(51, 13.2, 4.0, 0.3, 4.5, 28314.0));
|
||||
days.put(LocalDate.parse("2026-10-26"), new Forecast.DailyForecast(61, 9.6, 4.1, 8.4, 2.1, 0.0));
|
||||
return new Forecast(10.6, days);
|
||||
}
|
||||
|
||||
/** Twice the forecast radiation of the same days, so the factor is 2 kWh per MJ/m². */
|
||||
private static Map<LocalDate, Double> exampleMeasuredPv() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(LocalDate.parse("2026-10-19").plusDays(i), 20.0);
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
private static Warning exampleWarning() {
|
||||
return new Warning("Silny wiatr", 2, OffsetDateTime.parse("2026-10-25T08:00:00+01:00"),
|
||||
OffsetDateTime.parse("2026-10-25T18:00:00+01:00"),
|
||||
"Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weatherMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/weather", "weather");
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorMatchesExample()throws Exception {
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorMatchesExample() throws Exception {
|
||||
Instant old = Instant.parse("2026-10-23T21:00:00Z");
|
||||
List<DeviceSnapshot> devices = new ArrayList<>(snapshot.devices());
|
||||
Measurement bme = devices.getLast().measurements().get(0);
|
||||
devices.set(devices.size() - 1, new DeviceSnapshot("bme280", true, old, null,
|
||||
List.of(new Measurement(bme.database(), bme.measurement(), bme.fields(), old))));
|
||||
snapshot = new GatewaySnapshot("v1.2.1", devices);
|
||||
assertMatchesExample("/api/pi-sensor", "pi-sensor-stale");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsMatchExample() throws Exception {
|
||||
assertMatchesExample("/api/stats?range=7d", "stats");
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsNeedAKnownRange() throws Exception {
|
||||
assertEquals(400, get("/api/stats").statusCode());
|
||||
assertEquals(400, get("/api/stats?range=24h").statusCode());
|
||||
assertEquals(200, get("/api/stats?range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsAre502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/audit?range=7d", "audit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditRangeDefaultsAndIsChecked() throws Exception {
|
||||
assertEquals(200, get("/api/audit").statusCode());
|
||||
assertEquals(400, get("/api/audit?range=90d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void auditInfluxFailureIs502() throws Exception {
|
||||
auditDown = true;
|
||||
|
||||
assertEquals(502, get("/api/audit?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy", "/api/energy/details")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
@@ -301,6 +640,78 @@ class ApiServerTest {
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, java.time.Duration.ofHours(2), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreMatchesExampleAndAsksForTheUsersSetpoint() throws Exception {
|
||||
HttpResponse<String> response = post("/api/setpoints/cwu_economy/restore", "{}", "Remote-User", "anna");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("setpoint-restored"), JSON.readTree(response.body()));
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreStatusFollowsTheGatewaysVerdict() throws Exception {
|
||||
var expected = Map.of(Outcome.NOT_PENDING, 404, Outcome.CONFLICT, 409, Outcome.TOO_SOON, 429,
|
||||
Outcome.FAILED, 502);
|
||||
for (var entry : expected.entrySet()) {
|
||||
restoreOutcome = entry.getKey();
|
||||
|
||||
assertEquals(entry.getValue(), post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIs503() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/setpoints/cwu_economy/restore", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/setpoints/cwu_economy/restore";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshPassesTheGatewaysAnswerThrough() throws Exception {
|
||||
var response = post("/api/refresh", "{}");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(example("refresh"), JSON.readTree(response.body()));
|
||||
assertEquals(1, refreshes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshIs503WhenTheGatewayCannotBeReached() throws Exception {
|
||||
commandsDown = true;
|
||||
|
||||
assertEquals(503, post("/api/refresh", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshIsGuardedLikeEveryWrite() throws Exception {
|
||||
String path = "/api/refresh";
|
||||
assertEquals(415, rawPost(path, "{}", "X-Requested-With", "home-pump").statusCode());
|
||||
assertEquals(403, rawPost(path, "{}", "Content-Type", "application/json").statusCode());
|
||||
assertEquals(403, post(path, "{}", "Origin", "https://evil.example").statusCode());
|
||||
assertEquals(405, get(path).statusCode());
|
||||
assertEquals(0, refreshes);
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPathsUnderSetpointsAre404() throws Exception {
|
||||
for (String path : List.of("/api/setpoints/", "/api/setpoints/cwu_economy",
|
||||
"/api/setpoints/cwu_economy/restore/x", "/api/setpoints/CWU/restore", "/api/setpoints/a-b/restore")) {
|
||||
assertEquals(404, post(path, "{}").statusCode(), path);
|
||||
}
|
||||
assertTrue(restored.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedIs422AndMatchesExample() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
@@ -343,7 +754,7 @@ class ApiServerTest {
|
||||
|
||||
@Test
|
||||
void presetResultsMatchExample() throws Exception {
|
||||
HttpResponse<String> response = post("/api/presets/away", "");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
JsonNode body = JSON.readTree(response.body());
|
||||
@@ -356,12 +767,66 @@ class ApiServerTest {
|
||||
void presetCutShortByALostLinkKeepsTheResultsSoFar() throws Exception {
|
||||
answersBeforeDrop = 1;
|
||||
|
||||
HttpResponse<String> response = post("/api/presets/away", "{}");
|
||||
HttpResponse<String> response = post("/api/presets/away", awayRequest());
|
||||
|
||||
assertEquals(502, response.statusCode());
|
||||
assertEquals(example("setpoints-preset-partial"), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsTheChosenEndHeldInEachChange() throws Exception {
|
||||
post("/api/presets/away", awayRequest());
|
||||
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, end, "phone"),
|
||||
new Command("co_comfort", 15, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayWithOwnValuesSendsThem() throws Exception {
|
||||
var response = post("/api/presets/away", example("preset-away-values-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
var end = java.time.Instant.parse("2026-11-02T17:00:00Z");
|
||||
assertEquals(List.of(new Command("cwu_comfort", 12, null, end, "phone"),
|
||||
new Command("co_comfort", 16, null, end, "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesForOtherSetpointsOrOutsideTheLimitsAre400AndSendNothing() throws Exception {
|
||||
String until = "\"until\":\"2026-11-02T18:00:00Z\"";
|
||||
for (String values : List.of("{\"cwu_economy\":6}", "{\"co_comfort\":4}", "{\"co_comfort\":26}",
|
||||
"{\"co_comfort\":null}", "{\"co_comfort\":15.5}", "{\"co_comfort\":\"15\"}", "[]")) {
|
||||
assertEquals(400, post("/api/presets/away", "{" + until + ",\"values\":" + values + "}").statusCode(),
|
||||
values);
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValueThatDoesNotLowerAHeldSetpointIs422() throws Exception {
|
||||
var body = "{\"until\":\"2026-11-02T18:00:00Z\",\"values\":{\"co_comfort\":19}}";
|
||||
|
||||
assertEquals(422, post("/api/presets/away", body).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badOrMissingEndTimesAre400AndSendNothing() throws Exception {
|
||||
for (String body : List.of("", "{}", "{\"until\":null}", "{\"until\":\"tomorrow\"}", "{\"until\":5}",
|
||||
"{\"until\":\"2026-11-02T18:00:00\"}", "not json", "{\"until\":\"2026-11-02T18:00:00Z\",\"x\":1}")) {
|
||||
assertEquals(400, post("/api/presets/away", body).statusCode(), body);
|
||||
}
|
||||
assertEquals(400, post("/api/presets/cwu-now", awayRequest()).statusCode());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetWithoutAnEndStillTakesNoBodyOrAnEmptyObject() throws Exception {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "").statusCode());
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
@@ -372,7 +837,7 @@ class ApiServerTest {
|
||||
void presetOutsideLimitsIs422() throws Exception {
|
||||
verdict = Status.REJECTED;
|
||||
|
||||
assertEquals(422, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(422, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -381,7 +846,7 @@ class ApiServerTest {
|
||||
|
||||
assertEquals(503, get("/api/setpoints").statusCode());
|
||||
assertEquals(503, post("/api/setpoints", BOOST).statusCode());
|
||||
assertEquals(503, post("/api/presets/away", "").statusCode());
|
||||
assertEquals(503, post("/api/presets/away", awayRequest()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.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;
|
||||
|
||||
class InfluxAuditRepositoryTest {
|
||||
|
||||
private static final Instant FROM = Instant.parse("2026-10-17T12:00:00Z");
|
||||
private static final Instant TO = Instant.parse("2026-10-24T12:00:00Z");
|
||||
|
||||
private HttpServer server;
|
||||
private final List<String> queries = Collections.synchronizedList(new ArrayList<>());
|
||||
private String body;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
queries.add(URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsHeatPumpRowsFromTheLegacyDatabase() throws IOException {
|
||||
body = fixture("influx-audit-setpoints.json");
|
||||
|
||||
List<Row> rows = repository().heatPump(FROM, TO);
|
||||
|
||||
assertEquals(2, rows.size());
|
||||
assertEquals(new Row(Instant.ofEpochMilli(1761292800000L), "cwu_economy", "restore", "APPLIED", 45.0, 8.0),
|
||||
rows.get(0));
|
||||
assertNull(rows.get(1).oldValue());
|
||||
assertEquals("co_comfort", rows.get(1).target());
|
||||
assertTrue(queries.getFirst().startsWith("db=Dom_Pompa&epoch=ms&q=SELECT * FROM \"setpoint_audit\" "
|
||||
+ "WHERE time >= " + FROM.toEpochMilli() + "ms AND time < " + TO.toEpochMilli() + "ms"),
|
||||
queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsBatteryRowsByRegisterTag() throws IOException {
|
||||
body = fixture("influx-audit-battery.json");
|
||||
|
||||
List<Row> rows = repository().battery(FROM, TO);
|
||||
|
||||
assertEquals(List.of("reserve", "174"), rows.stream().map(Row::target).toList());
|
||||
assertEquals(60.0, rows.getFirst().newValue());
|
||||
assertTrue(queries.getFirst().startsWith("db=electricity&"), queries.getFirst());
|
||||
assertTrue(queries.getFirst().contains("\"deye_settings_audit\""), queries.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRowsIsAnEmptyList() throws IOException {
|
||||
body = fixture("influx-query-empty.json");
|
||||
|
||||
assertTrue(repository().battery(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anInfluxErrorIsAnIOException() throws IOException {
|
||||
body = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository().heatPump(FROM, TO));
|
||||
}
|
||||
|
||||
private InfluxAuditRepository repository() {
|
||||
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
return new InfluxAuditRepository(new InfluxSettings(base, Duration.ofSeconds(5)));
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -208,6 +209,35 @@ class InfluxHistoryRepositoryTest {
|
||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsRawHeatPumpPollsFromTheLegacyDatabase() throws IOException {
|
||||
defaultBody = fixture("influx-query-heat-pump.json");
|
||||
|
||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||
requests.get(0).get("q"));
|
||||
assertEquals(3, samples.size());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
||||
samples.get(1));
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeatPumpPollsIsAnEmptyList() throws IOException {
|
||||
assertTrue(repository(TIMEOUT).samples(FROM, TO).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpQueryErrorIsAnIoException() throws IOException {
|
||||
defaultBody = fixture("influx-query-error.json");
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).samples(FROM, TO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEmptyRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxPvEnergyTest {
|
||||
|
||||
private static final LocalDate FROM = LocalDate.parse("2026-10-19");
|
||||
private static final LocalDate TO = LocalDate.parse("2026-10-24");
|
||||
|
||||
private HttpServer server;
|
||||
private InfluxPvEnergy pv;
|
||||
private final List<String> queries = new ArrayList<>();
|
||||
private int status = 200;
|
||||
/** Day buckets start at Warsaw midnight: 2026-10-19 is 2026-10-18T22:00Z. */
|
||||
private String deye = rows("1792360800000", 22.5, "1792447200000", 8.0);
|
||||
private String fronius = rows("1792360800000", 4400.0, "1792533600000", 100.0);
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
String query = URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8);
|
||||
queries.add(query);
|
||||
byte[] bytes = (query.contains("\"deye\"") ? deye : fronius).getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
var settings = new InfluxSettings(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofSeconds(2));
|
||||
pv = new InfluxPvEnergy(settings);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String rows(String t1, double v1, String t2, double v2) {
|
||||
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"max\"],"
|
||||
+ "\"values\":[[" + t1 + "," + v1 + "],[" + t2 + "," + v2 + "]]}]}]}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsTheDeyeAndFroniusDayCountersPerLocalDay() throws IOException {
|
||||
Map<LocalDate, Double> days = pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(Map.of(FROM, 22.5 + 4.4), days);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForTheDailyMaximumOfEachCounterInWarsawDays() throws IOException {
|
||||
pv.dailyKwh(FROM, TO);
|
||||
|
||||
assertEquals(2, queries.size());
|
||||
String deyeQuery = queries.stream().filter(q -> q.contains("\"deye\"")).findFirst().orElseThrow();
|
||||
assertTrue(deyeQuery.contains("db=electricity"), deyeQuery);
|
||||
assertTrue(deyeQuery.contains("SELECT max(\"pv_today_kwh\") FROM \"deye\" WHERE time >= 1792360800000ms"
|
||||
+ " AND time < 1792792800000ms GROUP BY time(1d) fill(none) tz('Europe/Warsaw')"), deyeQuery);
|
||||
assertTrue(queries.stream().anyMatch(q -> q.contains("max(\"energy_day_wh\") FROM \"fronius\"")), queries.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureIsAnIoError() {
|
||||
status = 500;
|
||||
|
||||
assertThrows(IOException.class, () -> pv.dailyKwh(FROM, TO));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package home.backend.adapters.weather;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
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.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The two clients against a local server that answers with responses recorded from the real services. */
|
||||
class WeatherClientsTest {
|
||||
|
||||
private static final WeatherSettings PLACE = WeatherSettings.fromEnvironment(Map.of());
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
private HttpServer server;
|
||||
private URI base;
|
||||
private final List<String> requests = new ArrayList<>();
|
||||
private int status = 200;
|
||||
private String body = "";
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
requests.add(exchange.getRequestURI().toString());
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/weather", name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsTheOpenMeteoForecastForTheHouse() throws IOException {
|
||||
body = fixture("open-meteo.json");
|
||||
|
||||
Forecast forecast = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast();
|
||||
|
||||
assertEquals(10.6, forecast.temperature());
|
||||
assertEquals(17, forecast.days().size());
|
||||
var today = forecast.days().get(LocalDate.parse("2026-10-24"));
|
||||
assertEquals(3, today.weatherCode());
|
||||
assertEquals(10.9, today.tMax());
|
||||
assertEquals(3.8, today.tMin());
|
||||
assertEquals(6.88, today.radiationMj());
|
||||
assertEquals(7151.32, today.sunshineSeconds());
|
||||
assertEquals(8.4, forecast.days().get(LocalDate.parse("2026-10-26")).precipitationMm());
|
||||
String request = requests.getFirst();
|
||||
for (String part : List.of("latitude=49.684", "longitude=21.77", "timezone=Europe%2FWarsaw",
|
||||
"past_days=14", "shortwave_radiation_sum", "sunshine_duration")) {
|
||||
assertEquals(true, request.contains(part), part + " in " + request);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingValueInTheForecastIsNull() throws IOException {
|
||||
body = """
|
||||
{"daily":{"time":["2026-10-24"],"weather_code":[null],"temperature_2m_max":[1.5]}}""";
|
||||
|
||||
var day = new OpenMeteoClient(base, PLACE, TIMEOUT).forecast().days().get(LocalDate.parse("2026-10-24"));
|
||||
|
||||
assertNull(day.weatherCode());
|
||||
assertEquals(1.5, day.tMax());
|
||||
assertNull(day.tMin());
|
||||
assertNull(day.radiationMj());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anOpenMeteoFailureIsAnIoError() {
|
||||
body = "{\"error\":true}";
|
||||
status = 500;
|
||||
var client = new OpenMeteoClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
status = 200;
|
||||
body = "not json";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
|
||||
body = "{\"daily\":{}}";
|
||||
assertThrows(IOException.class, client::forecast);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsWarningsThatCoverTheCityOrThePowiatAndMapsThem() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals("/api/data/warningsmeteo", requests.getFirst());
|
||||
assertEquals(2, warnings.size());
|
||||
Warning city = warnings.get(0);
|
||||
assertEquals("Silny wiatr", city.event());
|
||||
assertEquals(2, city.level());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T08:00:00+01:00"), city.from());
|
||||
assertEquals(OffsetDateTime.parse("2026-10-25T18:00:00+01:00"), city.to());
|
||||
assertEquals("Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h, "
|
||||
+ "w porywach do 90 km/h, z południa.", city.text());
|
||||
assertEquals(1, warnings.get(1).level());
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherAreasAreIgnoredAndTerytIsConfigurable() throws IOException {
|
||||
body = fixture("imgw-warnings.json");
|
||||
var cityOnly = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("1862"));
|
||||
var elsewhere = new WeatherSettings(PLACE.latitude(), PLACE.longitude(), List.of("2804"));
|
||||
|
||||
assertEquals(1, new ImgwClient(base, cityOnly, TIMEOUT).warnings().size());
|
||||
assertEquals(1, new ImgwClient(base, elsewhere, TIMEOUT).warnings().size());
|
||||
assertEquals(0, new ImgwClient(base, new WeatherSettings(0, 0, List.of("0000")), TIMEOUT).warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedWarningIsSkippedNotFatal() throws IOException {
|
||||
body = """
|
||||
[{"nazwa_zdarzenia":"Burze","stopien":"x","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"a","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"soon",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"b","teryt":["1862"]},
|
||||
{"nazwa_zdarzenia":"Burze","stopien":"3","obowiazuje_od":"2026-10-25 08:00:00",
|
||||
"obowiazuje_do":"2026-10-25 18:00:00","tresc":"c","teryt":["1862"]}]""";
|
||||
|
||||
List<Warning> warnings = new ImgwClient(base, PLACE, TIMEOUT).warnings();
|
||||
|
||||
assertEquals(1, warnings.size());
|
||||
assertEquals("c", warnings.getFirst().text());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anImgwFailureIsAnIoError() {
|
||||
body = "{}";
|
||||
var client = new ImgwClient(base, PLACE, TIMEOUT);
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
|
||||
status = 503;
|
||||
assertThrows(IOException.class, client::warnings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsDefaultToKrosnoAndRejectNonsense() {
|
||||
assertEquals(49.684, PLACE.latitude());
|
||||
assertEquals(21.770, PLACE.longitude());
|
||||
assertEquals(List.of("1862", "1807"), PLACE.teryt());
|
||||
|
||||
var custom = WeatherSettings.fromEnvironment(
|
||||
Map.of("WEATHER_LAT", "50.1", "WEATHER_LON", "-20", "WEATHER_TERYT", "1465, 1401"));
|
||||
assertEquals(50.1, custom.latitude());
|
||||
assertEquals(-20.0, custom.longitude());
|
||||
assertEquals(List.of("1465", "1401"), custom.teryt());
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LAT", "north")));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> WeatherSettings.fromEnvironment(Map.of("WEATHER_LON", "200")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.application.AuditRepository.Row;
|
||||
import home.backend.domain.AuditEntry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.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;
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private List<Row> heatPump = List.of();
|
||||
private List<Row> battery = List.of();
|
||||
private final List<Instant> asked = new ArrayList<>();
|
||||
|
||||
private final AuditRepository repository = new AuditRepository() {
|
||||
@Override
|
||||
public List<Row> heatPump(Instant from, Instant to) {
|
||||
asked.add(from);
|
||||
return heatPump;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Row> battery(Instant from, Instant to) throws IOException {
|
||||
if (battery == null) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return battery;
|
||||
}
|
||||
};
|
||||
private final AuditService service = new AuditService(repository, CLOCK);
|
||||
|
||||
private static Row row(String time, String target, String by, Double old, Double value) {
|
||||
return new Row(Instant.parse(time), target, by, "APPLIED", old, value);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergesBothSourcesNewestFirst() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "a", 8.0, 45.0),
|
||||
row("2026-10-22T08:00:00Z", "co_comfort", "a", 19.0, 20.0));
|
||||
battery = List.of(row("2026-10-23T08:00:00Z", "reserve", "b", 50.0, 60.0));
|
||||
|
||||
var entries = service.audit(null).entries();
|
||||
|
||||
assertEquals(List.of("Ciepła woda – oszczędnie", "Rezerwa baterii", "Ogrzewanie – komfort"),
|
||||
entries.stream().map(AuditEntry::what).toList());
|
||||
assertEquals(List.of("heat_pump", "battery", "heat_pump"),
|
||||
entries.stream().map(AuditEntry::source).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namesTheDeyeRegisters() throws IOException {
|
||||
battery = List.of("146", "148", "153", "166", "171", "172", "177", "reserve", "999").stream()
|
||||
.map(register -> row("2026-10-24T08:00:00Z", register, "a", 0.0, 1.0)).toList();
|
||||
|
||||
var entries = service.audit("24h").entries();
|
||||
|
||||
assertEquals(List.of("Harmonogram baterii/0/1", "Przedział 1: start/HHMM", "Przedział 6: start/HHMM",
|
||||
"Przedział 1: poziom/%", "Przedział 6: poziom/%", "Przedział 1: ładowanie z sieci/0/1",
|
||||
"Przedział 6: ładowanie z sieci/0/1", "Rezerwa baterii/%", "Rejestr 999/"),
|
||||
entries.stream().map(e -> e.what() + "/" + e.unit()).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void showsARestoreAsAutomaticAndKeepsMissingValuesNull() throws IOException {
|
||||
heatPump = List.of(row("2026-10-24T08:00:00Z", "cwu_economy", "restore", null, 8.4));
|
||||
|
||||
var entry = service.audit("7d").entries().getFirst();
|
||||
|
||||
assertEquals("automatycznie (przywrócenie)", entry.who());
|
||||
assertNull(entry.oldValue());
|
||||
assertEquals(8, entry.newValue());
|
||||
assertEquals("°C", entry.unit());
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToSevenDaysAndAllowsUpToThirty() throws IOException {
|
||||
service.audit(null);
|
||||
service.audit("30d");
|
||||
|
||||
assertEquals(List.of(NOW.minus(Duration.ofDays(7)), NOW.minus(Duration.ofDays(30))), asked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit("90d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.audit(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenOneSourceFails() {
|
||||
battery = null;
|
||||
|
||||
assertThrows(IOException.class, () -> service.audit("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.SetpointState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class LeaseRenewerTest {
|
||||
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final CountDownLatch threeCalls = new CountDownLatch(3);
|
||||
private Exception failure;
|
||||
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
public CommandResult apply(Command command) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() throws IOException {
|
||||
calls.incrementAndGet();
|
||||
threeCalls.countDown();
|
||||
if (failure instanceof IOException e) {
|
||||
throw e;
|
||||
}
|
||||
if (failure instanceof RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void renewsAtOnceAndThenEveryPeriod() throws Exception {
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRenewalDoesNotStopTheSchedule() throws Exception {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnexpectedErrorDoesNotStopTheScheduleEither() throws Exception {
|
||||
failure = new IllegalStateException("bad answer");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.start(Duration.ofMillis(10));
|
||||
|
||||
assertTrue(threeCalls.await(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewDoesNotThrowOutOfTheScheduler() {
|
||||
failure = new IOException("tunnel down");
|
||||
try (var renewer = new LeaseRenewer(gateway)) {
|
||||
renewer.renew();
|
||||
}
|
||||
|
||||
assertEquals(1, calls.get());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.BatteryChange;
|
||||
import home.backend.domain.BatteryResult;
|
||||
import home.backend.domain.BatteryView;
|
||||
import home.backend.domain.EnergyDetails;
|
||||
import home.backend.domain.EnergyDetails.Device;
|
||||
import home.backend.domain.EnergyDetails.Group;
|
||||
import home.backend.domain.EnergyDetails.Item;
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Source;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
@@ -9,6 +16,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -18,6 +26,7 @@ import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -40,7 +49,7 @@ class ServicesTest {
|
||||
}
|
||||
|
||||
private static GatewayClient gateway(DeviceSnapshot... devices) {
|
||||
return () -> new GatewaySnapshot(List.of(devices));
|
||||
return () -> new GatewaySnapshot("v1.2.1", List.of(devices));
|
||||
}
|
||||
|
||||
private static GatewayClient unreachable() {
|
||||
@@ -166,6 +175,197 @@ class ServicesTest {
|
||||
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static Map<String, Double> settings(double touEnabled) {
|
||||
Map<String, Double> fields = new HashMap<>(Map.of("battery_capacity_ah", 280.0,
|
||||
"tou_enabled", touEnabled, "grid_charge_enabled", 0.0));
|
||||
double[] starts = {100, 500, 900, 1300, 1700, 2100};
|
||||
for (int n = 1; n <= 6; n++) {
|
||||
fields.putAll(Map.of("prog" + n + "_start_hhmm", starts[n - 1], "prog" + n + "_power_w", 8000.0,
|
||||
"prog" + n + "_soc_pct", 60.0, "prog" + n + "_grid_charge", n == 2 ? 1.0 : 0.0));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static BatteryView battery(DeviceSnapshot... devices) throws IOException {
|
||||
return new BatteryService(gateway(devices), new FakeBattery(), CLOCK).current();
|
||||
}
|
||||
|
||||
private static final class FakeBattery implements GatewayBattery {
|
||||
static final List<String> SENT = new java.util.ArrayList<>();
|
||||
final boolean enabled;
|
||||
|
||||
FakeBattery() {
|
||||
this(true);
|
||||
}
|
||||
|
||||
FakeBattery(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryView.Reserve reserve() throws IOException {
|
||||
if (!enabled) {
|
||||
throw new IOException("gateway /battery/reserve answered 404");
|
||||
}
|
||||
return new BatteryView.Reserve(50, 30);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeSchedule(BatteryChange change, String requestedBy) {
|
||||
SENT.add("schedule by " + requestedBy);
|
||||
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
public BatteryResult changeReserve(int pct, String requestedBy) {
|
||||
SENT.add("reserve " + pct + " by " + requestedBy);
|
||||
return new BatteryResult(BatteryResult.Status.APPLIED, "applied", Map.of());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryShowsTheReserveAndIsEditableWhenTheGatewayAcceptsChanges() throws IOException {
|
||||
BatteryView view = battery();
|
||||
|
||||
assertEquals(new BatteryView.Reserve(50, 30), view.reserve());
|
||||
assertTrue(view.editable());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryIsNotEditableWithoutGatewayCommandsButStillShowsTheLiveState() throws IOException {
|
||||
BatteryView view = new BatteryService(gateway(device("deye", "home", "deye",
|
||||
Map.of("battery_soc_pct", 80.0))), new FakeBattery(false), CLOCK).current();
|
||||
|
||||
assertNull(view.reserve());
|
||||
assertFalse(view.editable());
|
||||
assertEquals(80.0, view.socPct());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryChangesAreHandedToTheGatewayWithTheUser() throws IOException {
|
||||
var service = new BatteryService(gateway(), new FakeBattery(), CLOCK);
|
||||
|
||||
assertEquals(BatteryResult.Status.APPLIED, service.changeReserve(60, "anna").status());
|
||||
assertEquals(BatteryResult.Status.APPLIED,
|
||||
service.changeSchedule(new BatteryChange(true, null), "ola").status());
|
||||
assertEquals(List.of("reserve 60 by anna", "schedule by ola"), FakeBattery.SENT);
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeSettings(Map<String, Double> fields, Instant time) {
|
||||
return new DeviceSnapshot("deye-settings", true, time, null,
|
||||
List.of(new Measurement("electricity", "deye_settings", fields, time)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryReadsLiveValuesAndComputesCapacityAtNominalVoltage() throws IOException {
|
||||
BatteryView view = battery(device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0,
|
||||
"battery_power_w", 1500.0, "battery_charge_today_kwh", 2.5)),
|
||||
deyeSettings(settings(0), READ));
|
||||
|
||||
assertFalse(view.stale());
|
||||
assertEquals(80.0, view.socPct());
|
||||
assertEquals(1500.0, view.powerW());
|
||||
assertEquals(2.5, view.today().chargeKwh());
|
||||
assertEquals(14.336, view.capacityKwh(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryScheduleRunsToTheNextStartAndWrapsAroundMidnight() throws IOException {
|
||||
List<BatteryView.Slot> schedule = battery(deyeSettings(settings(1), READ)).schedule();
|
||||
|
||||
assertEquals(6, schedule.size());
|
||||
assertEquals(new BatteryView.Slot("01:00", "05:00", 8000, 60, false), schedule.get(0));
|
||||
assertEquals(new BatteryView.Slot("05:00", "09:00", 8000, 60, true), schedule.get(1));
|
||||
assertEquals(new BatteryView.Slot("21:00", "01:00", 8000, 60, false), schedule.get(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryScheduleIsEmptyWhenASlotStartIsMissing() throws IOException {
|
||||
Map<String, Double> fields = settings(1);
|
||||
fields.remove("prog3_start_hhmm");
|
||||
|
||||
assertTrue(battery(deyeSettings(fields, READ)).schedule().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryModeFollowsTimeOfUse() throws IOException {
|
||||
BatteryView backup = battery(deyeSettings(settings(0), READ));
|
||||
BatteryView scheduled = battery(deyeSettings(settings(1), READ));
|
||||
|
||||
assertFalse(backup.mode().touEnabled());
|
||||
assertEquals("Rezerwa na awarię: bateria nie rozładowuje się na co dzień", backup.mode().description());
|
||||
assertTrue(scheduled.mode().touEnabled());
|
||||
assertEquals("Harmonogram: bateria pracuje według przedziałów", scheduled.mode().description());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batterySettingsAreStaleAfterThirtyMinutesButLiveValuesStayCurrent() throws IOException {
|
||||
var live = device("deye", "home", "deye", Map.of("battery_soc_pct", 80.0));
|
||||
|
||||
BatteryView fresh = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1800)));
|
||||
BatteryView old = battery(live, deyeSettings(settings(0), NOW.minusSeconds(1801)));
|
||||
|
||||
assertFalse(fresh.settingsStale());
|
||||
assertEquals(6, fresh.schedule().size());
|
||||
assertTrue(old.settingsStale());
|
||||
assertEquals(NOW.minusSeconds(1801), old.settingsTime());
|
||||
assertTrue(old.schedule().isEmpty());
|
||||
assertNull(old.mode());
|
||||
assertNull(old.limits());
|
||||
assertNull(old.capacityKwh());
|
||||
assertEquals(80.0, old.socPct());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeyeShowsNoLiveBatteryValues() throws IOException {
|
||||
var old = new Measurement("home", "deye", Map.of("battery_soc_pct", 80.0), NOW.minusSeconds(601));
|
||||
|
||||
BatteryView view = battery(new DeviceSnapshot("deye", true, old.time(), null, List.of(old)),
|
||||
deyeSettings(settings(0), READ));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.socPct());
|
||||
assertNull(view.powerW());
|
||||
assertFalse(view.settingsStale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void batteryWithoutAnyReadingIsStaleTwice() throws IOException {
|
||||
BatteryView view = battery();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertTrue(view.settingsStale());
|
||||
assertNull(view.time());
|
||||
assertNull(view.settingsTime());
|
||||
}
|
||||
|
||||
private static PiSensorView piSensor(DeviceSnapshot... devices) throws IOException {
|
||||
return new PiSensorService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void piSensorReadsTemperatureHumidityAndPressure() throws IOException {
|
||||
PiSensorView view = piSensor(device("bme280", "measurement", "MEASUREMENT",
|
||||
Map.of("temperature", 20.17, "humidity", 45.82, "pressure", 985.8)));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertFalse(view.stale());
|
||||
assertEquals(20.17, view.temperature());
|
||||
assertEquals(45.82, view.humidity());
|
||||
assertEquals(985.8, view.pressure());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stalePiSensorShowsNoValues() throws IOException {
|
||||
var old = new Measurement("measurement", "MEASUREMENT", Map.of("temperature", 20.0), NOW.minusSeconds(601));
|
||||
PiSensorView view = piSensor(new DeviceSnapshot("bme280", true, old.time(), null, List.of(old)));
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertNull(view.temperature());
|
||||
assertTrue(piSensor().stale());
|
||||
assertNull(piSensor().time());
|
||||
}
|
||||
|
||||
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
@@ -215,6 +415,80 @@ class ServicesTest {
|
||||
assertEquals(List.of(new Source("deye", null, true), new Source("fronius", null, true)), view.sources());
|
||||
}
|
||||
|
||||
private static EnergyDetails details(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyDetailsService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static EnergyDetails.Device detailsOf(EnergyDetails details, String id) {
|
||||
return details.devices().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsShowOnlyReportedFieldsAndDropEmptyGroups() throws IOException {
|
||||
EnergyDetails.Device deye = detailsOf(details(device("deye", "home", "deye",
|
||||
Map.of("battery_soc_pct", 80.0, "grid_power_l2_w", 52.0, "pv1_power_w", 10.0))), "deye");
|
||||
|
||||
assertFalse(deye.stale());
|
||||
assertEquals(READ, deye.time());
|
||||
assertEquals(List.of("Bateria", "Sieć", "Panele PV"), deye.groups().stream().map(Group::title).toList());
|
||||
assertEquals(List.of(new Item("Moc L2", 52.0, "W", 0)), deye.groups().get(1).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsOfAStaleDeviceHaveNoValues() throws IOException {
|
||||
Instant old = NOW.minusSeconds(3600);
|
||||
var stale = new DeviceSnapshot("deye", true, old, null, List.of(new Measurement("home", "deye", DEYE, old)));
|
||||
|
||||
EnergyDetails.Device deye = detailsOf(details(stale), "deye");
|
||||
|
||||
assertTrue(deye.stale());
|
||||
assertEquals(old, deye.time());
|
||||
assertTrue(deye.groups().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsListEveryDeviceEvenWhenTheGatewayKnowsNone() throws IOException {
|
||||
EnergyDetails all = details();
|
||||
|
||||
assertEquals(List.of("deye", "fronius", "house-meter"), all.devices().stream().map(Device::id).toList());
|
||||
assertTrue(all.devices().stream().allMatch(d -> d.stale() && d.groups().isEmpty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsConvertFroniusWhToKwhAndRoundThem() throws IOException {
|
||||
EnergyDetails.Device fronius = detailsOf(details(device("fronius", "home", "fronius",
|
||||
Map.of("energy_day_wh", 4399.99, "energy_total_wh", 2.90713E7))), "fronius");
|
||||
|
||||
assertEquals(List.of(new Item("Dziś", 4.4, "kWh", 1), new Item("Od początku", 29071.0, "kWh", 0)),
|
||||
fronius.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsReadTheHouseMeterPerPhase() throws IOException {
|
||||
EnergyDetails.Device meter = detailsOf(details(device("house-meter", "electricity", "PRAD2",
|
||||
Map.of("p1_voltage", 235.69, "p3_voltage", 232.2))), "house-meter");
|
||||
|
||||
assertEquals(List.of(new Item("Napięcie L1", 235.7, "V", 1), new Item("Napięcie L3", 232.2, "V", 1)),
|
||||
meter.groups().get(0).items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsTakeThePhaseEnergyFromItsOwnMeasurement() throws IOException {
|
||||
var meter = new DeviceSnapshot("house-meter", true, READ, null, List.of(
|
||||
new Measurement("electricity", "PRAD2", Map.of("frequency", 50.01), READ),
|
||||
new Measurement("electricity", "house_meter_phases", Map.of("l2_import_energy_active_kwh", 7012.4), READ)));
|
||||
|
||||
Group phases = detailsOf(details(meter), "house-meter").groups().getLast();
|
||||
|
||||
assertEquals("Energia na fazę", phases.title());
|
||||
assertEquals(List.of(new Item("Pobrana L2", 7012.0, "kWh", 0)), phases.items());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailsFailWhenTheGatewayIsUnreachable() {
|
||||
assertThrows(IOException.class, () -> new EnergyDetailsService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static DeviceSnapshot deyeRead(boolean up, Instant time) {
|
||||
return new DeviceSnapshot("deye", up, time, null, List.of(new Measurement("home", "deye", DEYE, time)));
|
||||
}
|
||||
@@ -269,7 +543,7 @@ class ServicesTest {
|
||||
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
|
||||
|
||||
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
|
||||
down), CLOCK).current();
|
||||
down), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertTrue(view.gatewayReachable());
|
||||
assertEquals(NOW, view.time());
|
||||
@@ -279,12 +553,22 @@ class ServicesTest {
|
||||
assertEquals(READ, view.devices().get(0).lastSuccess());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthReportsBothVersions() {
|
||||
HealthView view = new HealthService(gateway(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertEquals("v1.2.1", view.gatewayVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK).current();
|
||||
HealthView view = new HealthService(unreachable(), CLOCK, "v1.3.0").current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
assertEquals("v1.3.0", view.backendVersion());
|
||||
assertNull(view.gatewayVersion());
|
||||
}
|
||||
|
||||
/** Remembers the request and answers with one point. */
|
||||
|
||||
@@ -3,6 +3,8 @@ package home.backend.application;
|
||||
import home.backend.adapters.config.PresetFile;
|
||||
import home.backend.domain.Command;
|
||||
import home.backend.domain.CommandResult;
|
||||
import home.backend.domain.RestoreResult;
|
||||
import home.backend.domain.RestoreResult.Outcome;
|
||||
import home.backend.domain.CommandResult.Status;
|
||||
import home.backend.domain.Preset;
|
||||
import home.backend.domain.Preset.Change;
|
||||
@@ -16,8 +18,10 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -25,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SetpointServiceTest {
|
||||
|
||||
private static final Instant RETURN = Instant.parse("2026-11-02T17:00:00Z");
|
||||
private static final SetpointState STATE = new SetpointState(List.of(
|
||||
new Setpoint("co_comfort", 4132, 5, 25, 1, 19),
|
||||
new Setpoint("cwu_economy", 4143, 5, 48, 1, 8),
|
||||
@@ -33,6 +38,7 @@ class SetpointServiceTest {
|
||||
private final List<Command> sent = new ArrayList<>();
|
||||
/** What the gateway does on the next calls to apply, in order: a Status, or an IOException; then APPLIED. */
|
||||
private final List<Object> script = new ArrayList<>();
|
||||
private final List<String> restored = new ArrayList<>();
|
||||
private boolean down;
|
||||
private final GatewayCommands gateway = new GatewayCommands() {
|
||||
@Override
|
||||
@@ -48,6 +54,15 @@ class SetpointServiceTest {
|
||||
return new CommandResult((Status) next, command.setpoint(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestoreResult restore(String setpoint, String requestedBy) throws IOException {
|
||||
if (down) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
restored.add(setpoint + " by " + requestedBy);
|
||||
return new RestoreResult(Outcome.RESTORED, "restored");
|
||||
}
|
||||
|
||||
@Override
|
||||
public SetpointState setpoints() throws IOException {
|
||||
if (down) {
|
||||
@@ -55,10 +70,29 @@ class SetpointServiceTest {
|
||||
}
|
||||
return STATE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int renewLeases() {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
private final List<Preset> presets = PresetFile.load(null);
|
||||
private final SetpointService service = new SetpointService(gateway, presets);
|
||||
|
||||
@Test
|
||||
void restoreAsksTheGatewayInTheUsersName() throws Exception {
|
||||
assertEquals(Outcome.RESTORED, service.restore("cwu_economy", "anna").outcome());
|
||||
|
||||
assertEquals(List.of("cwu_economy by anna"), restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoreWithTheGatewayDownIsAnIoError() {
|
||||
down = true;
|
||||
|
||||
assertThrows(IOException.class, () -> service.restore("cwu_economy", "anna"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewJoinsGatewayStateAndPresets() throws Exception {
|
||||
var view = service.current();
|
||||
@@ -93,33 +127,93 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesUntilChanged() throws Exception {
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, "anna"), new Command("co_comfort", 15, null, "anna")),
|
||||
sent);
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 15, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetThatWouldNotLowerAnySetpointWritesNothing() throws Exception {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
assertTrue(result.results().getLast().message().contains("not below the current 19"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void presetOutsideGatewayLimitsWritesNothing() throws Exception {
|
||||
var tooHot = new Preset("hot", "Za gorąco", List.of(new Change("co_comfort", 15, null),
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -129,9 +223,9 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", List.of(new Change("garage_heater", 1, null)));
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -145,7 +239,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -156,7 +250,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -166,7 +260,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -176,7 +270,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -186,7 +280,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -198,7 +292,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
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;
|
||||
|
||||
class StatsServiceTest {
|
||||
|
||||
/** Monday 2026-10-26 11:00 in Warsaw; the week before it holds the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-26T10:00:00Z");
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private StatsView stats(String range, Instant now, HeatPumpSample... samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return List.of(samples);
|
||||
};
|
||||
return new StatsService(repository, Clock.fixed(now, ZoneOffset.UTC)).stats(range);
|
||||
}
|
||||
|
||||
private static HeatPumpSample compressor(String time, double stage) {
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
|
||||
}
|
||||
|
||||
private static Day day(StatsView view, String date) {
|
||||
return view.days().stream().filter(d -> d.date().equals(LocalDate.parse(date))).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysUpToNow() throws IOException {
|
||||
stats("7d", NOW);
|
||||
|
||||
assertEquals(Instant.parse("2026-10-19T22:00:00Z"), askedFrom);
|
||||
assertEquals(NOW, askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOneDayPerLocalDateOldestFirst() throws IOException {
|
||||
StatsView view = stats("30d", NOW);
|
||||
|
||||
assertEquals(30, view.days().size());
|
||||
assertEquals(LocalDate.parse("2026-09-27"), view.days().getFirst().date());
|
||||
assertEquals(LocalDate.parse("2026-10-26"), view.days().getLast().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesEmptyDaysAndZeroTotals() throws IOException {
|
||||
StatsView view = stats("7d", NOW);
|
||||
|
||||
assertEquals(7, view.days().size());
|
||||
for (Day d : view.days()) {
|
||||
assertNull(d.compressorHours());
|
||||
assertNull(d.compressorStarts());
|
||||
assertNull(d.avgOutdoorTemp());
|
||||
}
|
||||
assertEquals(0.0, view.totals().compressorHours());
|
||||
assertEquals(0, view.totals().compressorStarts());
|
||||
assertNull(view.totals().avgOutdoorTemp());
|
||||
assertNull(view.totals().minCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapIsCappedAtTenMinutesPerPoll() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T11:00:00Z", 0));
|
||||
|
||||
assertEquals(0.25, day(view, "2026-10-23").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theLastPollHoldsUntilNowAtMostTenMinutes() throws IOException {
|
||||
StatsView recent = stats("7d", NOW, compressor("2026-10-26T09:58:00Z", 50));
|
||||
StatsView old = stats("7d", NOW, compressor("2026-10-26T08:00:00Z", 50));
|
||||
|
||||
assertEquals(0.03, day(recent, "2026-10-26").compressorHours());
|
||||
assertEquals(0.17, day(old, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsOnlyTransitionsFromOffToOn() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-23T10:00:00Z", 100),
|
||||
compressor("2026-10-23T10:05:00Z", 100),
|
||||
compressor("2026-10-23T10:10:00Z", 0),
|
||||
compressor("2026-10-23T10:15:00Z", 0),
|
||||
compressor("2026-10-23T10:20:00Z", 50),
|
||||
compressor("2026-10-23T10:25:00Z", 100),
|
||||
compressor("2026-10-23T10:30:00Z", 0),
|
||||
compressor("2026-10-23T10:35:00Z", 100));
|
||||
|
||||
assertEquals(2, day(view, "2026-10-23").compressorStarts());
|
||||
assertEquals(2, view.totals().compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStartAtMidnightBelongsToTheNewDay() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:55:00Z", 0),
|
||||
compressor("2026-10-24T22:00:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-24").compressorStarts());
|
||||
assertEquals(1, day(view, "2026-10-25").compressorStarts());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInSummerTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-24T21:58:00Z", 100),
|
||||
compressor("2026-10-24T22:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-24").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-25").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitsAStretchAtLocalMidnightInWinterTime() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
compressor("2026-10-25T22:58:00Z", 100),
|
||||
compressor("2026-10-25T23:03:00Z", 0));
|
||||
|
||||
assertEquals(0.03, day(view, "2026-10-25").compressorHours());
|
||||
assertEquals(0.05, day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theDayOfTheClockChangeLastsTwentyFiveHours() throws IOException {
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
Instant at = Instant.parse("2026-10-24T22:00:00Z");
|
||||
while (at.isBefore(Instant.parse("2026-10-25T23:00:00Z"))) {
|
||||
samples.add(compressor(at.toString(), 100));
|
||||
at = at.plusSeconds(300);
|
||||
}
|
||||
|
||||
StatsView view = stats("7d", NOW, samples.toArray(HeatPumpSample[]::new));
|
||||
|
||||
assertEquals(25.0, day(view, "2026-10-25").compressorHours());
|
||||
assertNull(day(view, "2026-10-26").compressorHours());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsEachOutputSeparately() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
|
||||
|
||||
Day d = day(view, "2026-10-23");
|
||||
assertEquals(0.17, d.compressorHours());
|
||||
assertEquals(0.08, d.cwuHours());
|
||||
assertEquals(0.08, d.coHours());
|
||||
assertEquals(0.08, d.heaterHours());
|
||||
assertEquals(11.0, d.avgOutdoorTemp());
|
||||
assertEquals(38.0, d.minCwuTemp());
|
||||
assertEquals(40.0, d.maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
|
||||
|
||||
assertEquals(0.17, view.totals().compressorHours());
|
||||
assertEquals(0.08, view.totals().heaterHours());
|
||||
assertEquals(7.0, view.totals().avgOutdoorTemp());
|
||||
assertEquals(30.0, view.totals().minCwuTemp());
|
||||
assertEquals(45.0, view.totals().maxCwuTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
|
||||
compressor("2026-10-23T10:05:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-23").compressorStarts());
|
||||
assertNull(day(view, "2026-10-23").avgOutdoorTemp());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> stats("24h", NOW));
|
||||
assertThrows(IllegalArgumentException.class, () -> stats(null, NOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesRepositoryFailure() {
|
||||
StatsService service = new StatsService((from, to) -> {
|
||||
throw new IOException("influx down");
|
||||
}, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
|
||||
assertThrows(IOException.class, () -> service.stats("7d"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.Forecast;
|
||||
import home.backend.domain.Forecast.DailyForecast;
|
||||
import home.backend.domain.WeatherView;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WeatherServiceTest {
|
||||
|
||||
private static final Instant START = Instant.parse("2026-10-24T10:00:00Z");
|
||||
private static final LocalDate TODAY = LocalDate.parse("2026-10-24");
|
||||
|
||||
private Instant now = START;
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return Clock.fixed(now, zone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now;
|
||||
}
|
||||
};
|
||||
|
||||
private final AtomicInteger forecastCalls = new AtomicInteger();
|
||||
private final AtomicInteger warningCalls = new AtomicInteger();
|
||||
private boolean forecastDown;
|
||||
private boolean warningsDown;
|
||||
private boolean influxDown;
|
||||
private int measuredDays = 5;
|
||||
private LocalDate today = TODAY;
|
||||
private List<Warning> warned = List.of(warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
private final WeatherService service = new WeatherService(
|
||||
() -> {
|
||||
forecastCalls.incrementAndGet();
|
||||
if (forecastDown) {
|
||||
throw new IOException("open-meteo down");
|
||||
}
|
||||
return forecast();
|
||||
},
|
||||
() -> {
|
||||
warningCalls.incrementAndGet();
|
||||
if (warningsDown) {
|
||||
throw new IOException("imgw down");
|
||||
}
|
||||
return warned;
|
||||
},
|
||||
(from, to) -> {
|
||||
if (influxDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
assertEquals(today.minusDays(14), from);
|
||||
assertEquals(today, to);
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
for (int i = 1; i <= measuredDays; i++) {
|
||||
measured.put(today.minusDays(i), 15.0);
|
||||
}
|
||||
return measured;
|
||||
},
|
||||
clock);
|
||||
|
||||
private Forecast forecast() {
|
||||
Map<LocalDate, DailyForecast> days = new TreeMap<>();
|
||||
for (int i = 1; i <= 14; i++) {
|
||||
days.put(today.minusDays(i), new DailyForecast(3, 10.0, 2.0, 0.0, 5.0, 3600.0));
|
||||
}
|
||||
days.put(today, new DailyForecast(0, 12.34, 3.0, 0.0, 6.0, 5400.0));
|
||||
days.put(today.plusDays(1), new DailyForecast(63, 9.0, 4.0, 5.5, null, null));
|
||||
days.put(today.plusDays(2), new DailyForecast(null, null, null, null, 1.0, 0.0));
|
||||
return new Forecast(8.5, days);
|
||||
}
|
||||
|
||||
private static Warning warning(Instant until) {
|
||||
return new Warning("Burza", 2, OffsetDateTime.ofInstant(START, ZoneOffset.UTC),
|
||||
OffsetDateTime.ofInstant(until, ZoneOffset.UTC), "text");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shapesThreeDaysAndEstimatesPvFromTheFactor() throws IOException {
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(START, view.time());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
assertEquals(TODAY, view.today().date());
|
||||
assertEquals("Bezchmurnie", view.today().description());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
assertEquals(1.5, view.today().sunshineHours());
|
||||
assertEquals(18.0, view.today().pvForecastKwh());
|
||||
assertEquals(TODAY.plusDays(2), view.dayAfter().date());
|
||||
assertEquals("Deszcz", view.tomorrow().description());
|
||||
assertEquals(5.5, view.tomorrow().precipitationMm());
|
||||
assertNull(view.tomorrow().pvForecastKwh());
|
||||
assertEquals(3.0, view.dayAfter().pvForecastKwh());
|
||||
assertEquals("Brak danych", view.dayAfter().description());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesTheWarsawDateNearMidnight() throws IOException {
|
||||
now = Instant.parse("2026-10-23T22:30:00Z");
|
||||
|
||||
assertEquals(TODAY, service.current().today().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastFetchedBeforeLocalMidnightIsNotUsedAfterIt() throws IOException {
|
||||
now = Instant.parse("2026-10-24T21:50:00Z");
|
||||
service.current();
|
||||
|
||||
now = Instant.parse("2026-10-24T22:05:00Z");
|
||||
today = TODAY.plusDays(1);
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertEquals(2, forecastCalls.get());
|
||||
assertEquals(today, view.today().date());
|
||||
assertEquals(today.plusDays(2), view.dayAfter().date());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedRefreshServesTheOlderWarningsWithoutAskingAgainFor60Seconds() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(59);
|
||||
assertTrue(service.current().stale());
|
||||
assertEquals(2, warningCalls.get());
|
||||
|
||||
now = now.plusSeconds(1);
|
||||
service.current();
|
||||
assertEquals(3, warningCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFailsFastDuringTheBackOff() {
|
||||
forecastDown = true;
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
now = START.plusSeconds(30);
|
||||
assertThrows(IOException.class, service::current);
|
||||
|
||||
assertEquals(1, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveMeasuredDaysGiveNoEstimate() throws IOException {
|
||||
measuredDays = 4;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertNull(view.today().pvForecastKwh());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnreachableInfluxGivesNoEstimateButStillTheWeather() throws IOException {
|
||||
influxDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertNull(view.pvFactor());
|
||||
assertEquals(0, view.today().weatherCode());
|
||||
assertFalse(view.stale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheForecastFor30MinutesAndWarningsFor10() throws IOException {
|
||||
service.current();
|
||||
now = START.plus(Duration.ofMinutes(9));
|
||||
service.current();
|
||||
assertEquals(1, warningCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(10));
|
||||
service.current();
|
||||
assertEquals(2, warningCalls.get());
|
||||
assertEquals(1, forecastCalls.get());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(30));
|
||||
service.current();
|
||||
assertEquals(2, forecastCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderForecastMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
forecastDown = true;
|
||||
now = START.plus(Duration.ofMinutes(31));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(8.5, view.now().temperature());
|
||||
assertEquals(3.0, view.pvFactor(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheOlderWarningsMarkedStaleWhenTheRefreshFails() throws IOException {
|
||||
service.current();
|
||||
warningsDown = true;
|
||||
now = START.plus(Duration.ofMinutes(11));
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.stale());
|
||||
assertEquals(1, view.warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyForecastItFails() {
|
||||
forecastDown = true;
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutAnyWarningsItStillAnswersMarkedStale() throws IOException {
|
||||
warningsDown = true;
|
||||
|
||||
WeatherView view = service.current();
|
||||
|
||||
assertTrue(view.warnings().isEmpty());
|
||||
assertTrue(view.stale());
|
||||
assertEquals(12.34, view.today().tMax());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsWarningsThatHaveEnded() throws IOException {
|
||||
warned = List.of(warning(START.plus(Duration.ofMinutes(5))), warning(START.plus(Duration.ofHours(8))));
|
||||
|
||||
assertEquals(2, service.current().warnings().size());
|
||||
|
||||
now = START.plus(Duration.ofMinutes(6));
|
||||
assertEquals(1, service.current().warnings().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForecastWithoutTodayFails() {
|
||||
var short_ = new WeatherService(() -> new Forecast(null, Map.of()), List::of, (from, to) -> Map.of(), clock);
|
||||
|
||||
assertThrows(IOException.class, short_::current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class WeatherDomainTest {
|
||||
|
||||
private static final LocalDate DAY = LocalDate.parse("2026-10-10");
|
||||
|
||||
@Test
|
||||
void describesWmoCodesInPolish() {
|
||||
assertEquals("Bezchmurnie", WeatherCodes.describe(0));
|
||||
assertEquals("Pochmurno", WeatherCodes.describe(3));
|
||||
assertEquals("Mgła", WeatherCodes.describe(45));
|
||||
assertEquals("Mżawka", WeatherCodes.describe(53));
|
||||
assertEquals("Deszcz", WeatherCodes.describe(63));
|
||||
assertEquals("Śnieg", WeatherCodes.describe(73));
|
||||
assertEquals("Przelotny deszcz", WeatherCodes.describe(81));
|
||||
assertEquals("Burza", WeatherCodes.describe(95));
|
||||
assertEquals("Burza z gradem", WeatherCodes.describe(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownOrMissingCodeIsNoData() {
|
||||
assertEquals("Brak danych", WeatherCodes.describe(42));
|
||||
assertEquals("Brak danych", WeatherCodes.describe(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calibratesAsTotalMeasuredOverTotalRadiation() {
|
||||
var measured = new HashMap<LocalDate, Double>();
|
||||
var radiation = new HashMap<LocalDate, Double>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0 + i);
|
||||
radiation.put(DAY.plusDays(i), 5.0 + i);
|
||||
}
|
||||
|
||||
// 60 kWh over 35 MJ/m², not the mean of the daily ratios.
|
||||
assertEquals(60.0 / 35.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void daysMissingEitherValueDoNotCount() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
measured.put(DAY.plusDays(9), 1000.0);
|
||||
radiation.put(DAY.plusDays(8), 1000.0);
|
||||
|
||||
assertEquals(2.0, PvCalibration.factor(measured, radiation), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fewerThanFiveDaysGiveNoFactor() {
|
||||
Map<LocalDate, Double> measured = new HashMap<>();
|
||||
Map<LocalDate, Double> radiation = new HashMap<>();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
measured.put(DAY.plusDays(i), 10.0);
|
||||
radiation.put(DAY.plusDays(i), 5.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(measured, radiation));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noRadiationGivesNoFactor() {
|
||||
Map<LocalDate, Double> days = new HashMap<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
days.put(DAY.plusDays(i), 0.0);
|
||||
}
|
||||
|
||||
assertNull(PvCalibration.factor(Map.of(), days));
|
||||
assertNull(PvCalibration.factor(days, days));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entries": [
|
||||
{ "time": "2026-10-24T09:30:00Z", "source": "battery", "what": "Rezerwa baterii", "oldValue": 50, "newValue": 60,
|
||||
"unit": "%", "who": "mariusz", "status": "APPLIED" },
|
||||
{ "time": "2026-10-24T08:00:00Z", "source": "heat_pump", "what": "Ciepła woda – oszczędnie", "oldValue": 45, "newValue": 8,
|
||||
"unit": "°C", "who": "automatycznie (przywrócenie)", "status": "APPLIED" },
|
||||
{ "time": "2026-10-23T18:00:00Z", "source": "battery", "what": "Przedział 3: ładowanie z sieci", "oldValue": 0,
|
||||
"newValue": 1, "unit": "0/1", "who": "mariusz", "status": "FAILED" },
|
||||
{ "time": "2026-10-23T07:00:00Z", "source": "heat_pump", "what": "Ogrzewanie – komfort", "oldValue": null, "newValue": 22,
|
||||
"unit": "°C", "who": "mariusz", "status": "REJECTED" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "status": "FAILED", "message": "registers not as written: [167]; not retried", "read": { "167": 80 } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "pct": 60 }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"slots": [
|
||||
{ "slot": 2, "from": "09:30", "socPct": 90, "gridCharge": true },
|
||||
{ "slot": 3, "socPct": 85 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"socPct": 99.0,
|
||||
"powerW": -24.0,
|
||||
"voltageV": 56.49,
|
||||
"currentA": -0.05,
|
||||
"temperatureC": 19.2,
|
||||
"capacityKwh": null,
|
||||
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
|
||||
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
|
||||
"mode": null,
|
||||
"schedule": [],
|
||||
"limits": null,
|
||||
"settingsTime": "2026-10-23T21:00:00Z",
|
||||
"settingsStale": true,
|
||||
"reserve": { "pct": 50, "floorPct": 30 },
|
||||
"editable": true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"socPct": 99.0,
|
||||
"powerW": -24.0,
|
||||
"voltageV": 56.49,
|
||||
"currentA": -0.05,
|
||||
"temperatureC": 19.2,
|
||||
"capacityKwh": 14.336,
|
||||
"today": { "chargeKwh": 0.5, "dischargeKwh": 0.1 },
|
||||
"total": { "chargeKwh": 453.7, "dischargeKwh": 162.1 },
|
||||
"mode": {
|
||||
"touEnabled": false,
|
||||
"description": "Rezerwa na awarię: bateria nie rozładowuje się na co dzień"
|
||||
},
|
||||
"schedule": [
|
||||
{ "from": "01:00", "to": "05:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "05:00", "to": "09:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "09:00", "to": "13:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "13:00", "to": "17:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "17:00", "to": "21:00", "powerW": 10000, "socPct": 80, "gridCharge": false },
|
||||
{ "from": "21:00", "to": "01:00", "powerW": 10000, "socPct": 80, "gridCharge": false }
|
||||
],
|
||||
"limits": {
|
||||
"maxChargeA": 80,
|
||||
"maxDischargeA": 120,
|
||||
"shutdownSocPct": 20,
|
||||
"lowSocPct": 35,
|
||||
"restartSocPct": 50,
|
||||
"gridChargeEnabled": true,
|
||||
"gridChargeCurrentA": 40,
|
||||
"gridChargeStartSocPct": 30
|
||||
},
|
||||
"settingsTime": "2026-10-23T22:26:00Z",
|
||||
"settingsStale": false,
|
||||
"reserve": { "pct": 50, "floorPct": 30 },
|
||||
"editable": true
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "deye", "name": "Falownik Deye", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Bateria", "items": [
|
||||
{ "label": "Naładowanie", "value": 99.0, "unit": "%", "digits": 0 },
|
||||
{ "label": "Moc", "value": -24.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "Napięcie", "value": 56.5, "unit": "V", "digits": 1 },
|
||||
{ "label": "Prąd", "value": 0.0, "unit": "A", "digits": 1 },
|
||||
{ "label": "Temperatura", "value": 19.2, "unit": "°C", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Moc razem", "value": 645.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Odbiór domu", "items": [
|
||||
{ "label": "Moc razem", "value": 570.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Panele PV", "items": [
|
||||
{ "label": "PV1 moc", "value": 0.0, "unit": "W", "digits": 0 },
|
||||
{ "label": "PV2 moc", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Dziś", "items": [
|
||||
{ "label": "Produkcja PV", "value": 29.9, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Zużycie domu", "value": 24.2, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Pobrano z sieci", "value": 16.8, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Oddano do sieci", "value": 22.3, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria naładowana", "value": 0.5, "unit": "kWh", "digits": 1 },
|
||||
{ "label": "Bateria rozładowana", "value": 0.1, "unit": "kWh", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Od początku", "items": [
|
||||
{ "label": "Bateria naładowana", "value": 454.0, "unit": "kWh", "digits": 0 },
|
||||
{ "label": "Bateria rozładowana", "value": 162.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fronius", "name": "Falownik Fronius", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Teraz", "items": [
|
||||
{ "label": "Moc PV", "value": 0.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Produkcja", "items": [
|
||||
{ "label": "Dziś", "value": 4.4, "unit": "kWh", "digits": 1 }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "house-meter", "name": "Licznik domu", "time": "2026-10-23T22:26:00Z", "stale": false,
|
||||
"groups": [
|
||||
{ "title": "Moc czynna", "items": [
|
||||
{ "label": "Moc L1", "value": 56.0, "unit": "W", "digits": 0 }
|
||||
] },
|
||||
{ "title": "Napięcie", "items": [
|
||||
{ "label": "Napięcie L1", "value": 235.7, "unit": "V", "digits": 1 }
|
||||
] },
|
||||
{ "title": "Prąd", "items": [
|
||||
{ "label": "Prąd L1", "value": 0.32, "unit": "A", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Współczynnik mocy", "items": [
|
||||
{ "label": "cos φ L1", "value": 0.75, "unit": "", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Sieć", "items": [
|
||||
{ "label": "Częstotliwość", "value": 50.01, "unit": "Hz", "digits": 2 }
|
||||
] },
|
||||
{ "title": "Energia od początku", "items": [
|
||||
{ "label": "Pobrana", "value": 67514.0, "unit": "kWh", "digits": 0 }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"backendVersion": "v1.3.0",
|
||||
"gatewayVersion": "v1.2.1",
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"time": "2026-10-23T21:00:00Z",
|
||||
"stale": true,
|
||||
"temperature": null,
|
||||
"humidity": null,
|
||||
"pressure": null
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"stale": false,
|
||||
"temperature": 20.17,
|
||||
"humidity": 45.82,
|
||||
"pressure": 985.8
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "until": "2026-11-02T18:00:00+01:00", "values": { "cwu_comfort": 12, "co_comfort": 16 } }
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"requested": ["deye", "house-meter"],
|
||||
"skipped": [{ "device": "heat-pump", "retryInSeconds": 41 }]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "outcome": "RESTORED", "message": "restored to 8" }
|
||||
@@ -17,6 +17,7 @@
|
||||
{
|
||||
"id": "away",
|
||||
"name": "Wyjazd",
|
||||
"until": "chosen",
|
||||
"changes": [
|
||||
{ "setpoint": "cwu_comfort", "value": 10, "hours": null },
|
||||
{ "setpoint": "co_comfort", "value": 15, "hours": null }
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"range": "7d",
|
||||
"days": [
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-20",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-22",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
},
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"compressorHours": 0.17,
|
||||
"compressorStarts": 1,
|
||||
"cwuHours": 0.08,
|
||||
"coHours": 0.0,
|
||||
"heaterHours": 0.08,
|
||||
"avgOutdoorTemp": 8.6,
|
||||
"minCwuTemp": 43.0,
|
||||
"maxCwuTemp": 46.0
|
||||
},
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"compressorHours": null,
|
||||
"compressorStarts": null,
|
||||
"cwuHours": null,
|
||||
"coHours": null,
|
||||
"heaterHours": null,
|
||||
"avgOutdoorTemp": null,
|
||||
"minCwuTemp": null,
|
||||
"maxCwuTemp": null
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"compressorHours": 0.17,
|
||||
"compressorStarts": 1,
|
||||
"cwuHours": 0.08,
|
||||
"coHours": 0.0,
|
||||
"heaterHours": 0.08,
|
||||
"avgOutdoorTemp": 8.6,
|
||||
"minCwuTemp": 43.0,
|
||||
"maxCwuTemp": 46.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"today": {"date": "2026-10-24", "weatherCode": 3, "description": "Pochmurno", "tMax": 10.9, "tMin": 3.8,
|
||||
"precipitationMm": 0.0, "sunshineHours": 2.0, "pvForecastKwh": 18.0},
|
||||
"tomorrow": {"date": "2026-10-25", "weatherCode": 51, "description": "Mżawka", "tMax": 13.2, "tMin": 4.0,
|
||||
"precipitationMm": 0.3, "sunshineHours": 7.9, "pvForecastKwh": 9.0},
|
||||
"dayAfter": {"date": "2026-10-26", "weatherCode": 61, "description": "Słaby deszcz", "tMax": 9.6, "tMin": 4.1,
|
||||
"precipitationMm": 8.4, "sunshineHours": 0.0, "pvForecastKwh": 4.2},
|
||||
"now": {"temperature": 10.6},
|
||||
"warnings": [{"event": "Silny wiatr", "level": 2, "from": "2026-10-25T08:00:00+01:00",
|
||||
"to": "2026-10-25T18:00:00+01:00", "text": "Prognozuje się wystąpienie silnego wiatru o średniej prędkości do 45 km/h."}],
|
||||
"pvFactor": 2.0,
|
||||
"stale": false
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
{"status":"UP","version":"v1.2.1","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"deye_settings_audit","columns":["time","new_value","old_value","register","requested_by","status"],"values":[[1761298200000,60,50,"reserve","mariusz","APPLIED"],[1761242400000,1,0,"174","mariusz","FAILED"]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"setpoint_audit","columns":["time","new_value","old_value","register","requested_by","setpoint","status"],"values":[[1761292800000,8,45,4143,"restore","cwu_economy","APPLIED"],[1761202800000,22,null,4146,"mariusz","co_comfort","REJECTED"]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,null,null]]}]}]}
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user