Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e28a25c22 | ||
|
|
2343fe410f | ||
|
|
e2f3b58777 | ||
|
|
1cc8a3b545 | ||
|
|
9ec0ae2425 | ||
|
|
f84bf5a953 | ||
|
|
4446499996 | ||
|
|
3813e010ae | ||
|
|
72960e74d0 | ||
|
|
ceae74efd1 | ||
|
|
dd02947bb2 | ||
|
|
fb72010a12 | ||
|
|
860aaa00e0 | ||
|
|
a22788ef48 | ||
|
|
1c74044ad0 | ||
|
|
1f114367b6 | ||
|
|
cf75f55c6b | ||
|
|
7cf4576e49 | ||
|
|
a1e749e4ad | ||
|
|
1a8e8e8585 | ||
|
|
eb0b23637b | ||
|
|
0db35d06f8 | ||
|
|
5be8ce49ef | ||
|
|
f0cbcb00f5 | ||
|
|
e8aa40f93f | ||
|
|
cac625e0e9 | ||
|
|
a025bf8298 | ||
|
|
ae5f2536c8 | ||
|
|
f71d112df7 | ||
|
|
a713147a30 | ||
|
|
1f9e245f2e | ||
|
|
da461bdf27 | ||
|
|
e90f28dbc3 | ||
|
|
d5328e40fc | ||
|
|
df96cb73d1 | ||
|
|
58ffc2e056 | ||
|
|
649d2e9cdd | ||
|
|
ae2e0ae896 | ||
|
|
4c4b2143ac | ||
|
|
996b6805a4 | ||
|
|
6c6f071a80 | ||
|
|
722b67b252 | ||
|
|
98fe849254 | ||
|
|
08f7bb8a8f | ||
|
|
3705a57155 | ||
|
|
3b15451874 | ||
|
|
4db8520303 | ||
|
|
e9a3c45d89 | ||
|
|
51a68f0d44 | ||
|
|
15c5b7a7af | ||
|
|
d2873efc7c | ||
|
|
3be02ed3a7 | ||
|
|
6a10fd5ccb | ||
|
|
4e92d16f41 | ||
|
|
5d3201f7e3 | ||
|
|
821f818b1e | ||
|
|
b9c401f077 | ||
|
|
f6f07c4225 | ||
|
|
2d61ce19f4 | ||
|
|
80873d748c | ||
|
|
df2d588e33 | ||
|
|
04a17041ec | ||
|
|
45ab01cdd0 | ||
|
|
797920bb82 | ||
|
|
77e3e0cb2a | ||
|
|
162e557334 | ||
|
|
2b06a5f344 | ||
|
|
ce95b7786a | ||
|
|
4669f430ec | ||
|
|
b328848b87 | ||
|
|
b99414d829 | ||
|
|
79c67f334c | ||
|
|
60f35b01a6 | ||
|
|
b0c4961cda | ||
|
|
7f2e98a781 | ||
|
|
1e43d9d7fb |
No files matched your search
@@ -56,3 +56,6 @@ coverage/
|
||||
/apk/
|
||||
/old/
|
||||
/old_backup/
|
||||
|
||||
# Agent worktrees
|
||||
/.claude/worktrees/
|
||||
@@ -1,168 +1,35 @@
|
||||
# Home Pump Monitor
|
||||
# home-pump
|
||||
|
||||
A Java application that reads data from a heat pump via Modbus TCP and sends metrics to InfluxDB.
|
||||
Heat pump (Frisko RX910-SE21) and home energy for the house in Krosno, from a phone.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads Modbus holding registers from a heat pump
|
||||
- Configurable send interval to InfluxDB (default: 5 minutes)
|
||||
- Automatic buffering when InfluxDB is unavailable (up to 50,000 data points)
|
||||
- Health check endpoint for monitoring application status
|
||||
- Automatic restart on Modbus connection failures
|
||||
- Graceful shutdown with InfluxDB buffer flush
|
||||
- Systemd service with automatic recovery
|
||||
- Configurable via JSON file
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
mvn clean package
|
||||
java -jar target/home-pump-1.0.0.jar
|
||||
```
|
||||
|
||||
### Production Deployment (Raspberry Pi / Ubuntu)
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for complete installation and configuration guide.
|
||||
- What we build: [docs/spec.md](docs/spec.md)
|
||||
- Plan and current milestone: [docs/roadmap.md](docs/roadmap.md)
|
||||
- Rules for changes: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
- Controller knowledge: [docs/frisko/](docs/frisko/rx910-se21.md)
|
||||
|
||||
```bash
|
||||
# Build
|
||||
mvn clean package
|
||||
|
||||
# Install as systemd service
|
||||
sudo scripts/install-service.sh
|
||||
|
||||
# Check status
|
||||
systemctl status home-pump.service
|
||||
curl http://localhost:8080/health
|
||||
scripts/check.sh # docs, secret scan, build, tests
|
||||
git config core.hooksPath .githooks # once per clone: check before every push
|
||||
scripts/deploy-gateway.sh vMAJOR.MINOR.PATCH # deploy master to the Pi (approval needed)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
## Backend (phone API and web UI)
|
||||
|
||||
Edit `src/main/resources/registryMapping.json` to configure:
|
||||
Settings are environment variables; the backend refuses to start without the two URLs.
|
||||
|
||||
- Modbus TCP connection (address, port)
|
||||
- InfluxDB connection (address, username, password)
|
||||
- Send interval (influxSendIntervalSeconds, default: 300)
|
||||
- Connection timeouts and retry settings
|
||||
- List of registers to read with descriptions
|
||||
|
||||
Additional mapping fields (optional):
|
||||
|
||||
- `useMock` (Boolean, top-level): When `true`, the application runs with a local `MockRepository` instead of attempting to connect to a Modbus device. Useful for local development when a device is not available (default: false).
|
||||
|
||||
- `unitId` (Integer): Modbus slave/unit id for the register (default: 1)
|
||||
- `scale` (Double): Multiply raw device value by this to obtain display value (default: 0.1)
|
||||
- `dataType` (String): Data type used for the locator (e.g. `TWO_BYTE_INT_SIGNED`). Supported values: `TWO_BYTE_INT_SIGNED`, `TWO_BYTE_INT_UNSIGNED`, `FOUR_BYTE_INT_SIGNED`, `FOUR_BYTE_INT_UNSIGNED`, `FOUR_BYTE_FLOAT` (the latter reads two adjacent registers and interprets as IEEE754 float). For devices with non-standard word order, the following swapped variants are supported: `FOUR_BYTE_INT_SWAPPED`, `FOUR_BYTE_INT_UNSIGNED_SWAPPED`, `FOUR_BYTE_FLOAT_SWAPPED` (word order low/high).
|
||||
- # Example Configuration
|
||||
```json
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": false,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"timeoutMs": 5000,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1000,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.example.com",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Health Check
|
||||
|
||||
The application exposes a health check endpoint on port 8080:
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `GATEWAY_URL` | required | the gateway on the Pi, e.g. `http://10.0.0.2:8080` |
|
||||
| `INFLUX_URL` | required | InfluxDB 1.x, e.g. `https://influxdb.example` |
|
||||
| `PORT` | `8080` | listening port |
|
||||
| `GATEWAY_TIMEOUT` | `5` | seconds |
|
||||
| `INFLUX_TIMEOUT` | `10` | seconds |
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
mvn -f backend/pom.xml clean package
|
||||
GATEWAY_URL=http://10.0.0.2:8080 INFLUX_URL=https://influxdb.example \
|
||||
java -jar backend/target/home-pump-backend-1.0.0.jar # UI at http://localhost:8080/
|
||||
|
||||
docker build -t home-pump-backend backend # after the mvn package above
|
||||
docker run --rm -p 8080:8080 -e GATEWAY_URL=... -e INFLUX_URL=... home-pump-backend
|
||||
```
|
||||
|
||||
**Healthy response (HTTP 200):**
|
||||
```json
|
||||
{
|
||||
"status": "UP",
|
||||
"lastSuccessfulRead": "2026-02-06T19:30:45Z",
|
||||
"message": "Last successful read 15 seconds ago"
|
||||
}
|
||||
```
|
||||
|
||||
**Unhealthy response (HTTP 503):**
|
||||
```json
|
||||
{
|
||||
"status": "DOWN",
|
||||
"lastSuccessfulRead": "2026-02-06T19:20:45Z",
|
||||
"message": "No successful read for 620 seconds. Last error: Connection timeout"
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **InfluxDB failures do NOT trigger unhealthy status** - data is buffered in memory
|
||||
- Only Modbus read failures trigger unhealthy status and potential restart
|
||||
- Health check considers the application unhealthy if no successful Modbus read within 2× send interval
|
||||
- Automatic monitoring script restarts the service on health check failures
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production use on Raspberry Pi or Ubuntu server:
|
||||
|
||||
1. **Install as systemd service** - Auto-start on boot with resource limits
|
||||
2. **Enable health monitoring** - Cron job checks health every 2 minutes
|
||||
3. **Automatic recovery** - Restarts service on Modbus failures (max 3 attempts per 10 minutes)
|
||||
4. **Graceful shutdown** - Flushes buffered data to InfluxDB before exit
|
||||
|
||||
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed instructions.
|
||||
|
||||
## Data Buffering
|
||||
|
||||
When InfluxDB is unavailable, data is automatically buffered:
|
||||
|
||||
- **Buffer size**: 50,000 data points in memory
|
||||
- **Batch size**: 50 points per flush
|
||||
- **Flush interval**: 180 seconds (3 minutes)
|
||||
- **No data loss**: Buffer persists during InfluxDB outages
|
||||
- **Automatic retry**: Built-in retry mechanism in InfluxDB Java client
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Check application logs
|
||||
```bash
|
||||
journalctl -u home-pump.service -f
|
||||
```
|
||||
|
||||
### Test health endpoint
|
||||
```bash
|
||||
./scripts/test-health.sh
|
||||
```
|
||||
|
||||
### View monitor logs
|
||||
```bash
|
||||
tail -f /var/log/home-pump-monitor.log
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Service won't start**: Check JAR file exists and Java 21 is installed
|
||||
2. **Health check fails**: Verify Modbus device is accessible
|
||||
3. **InfluxDB connection errors**: Check network and credentials (data will be buffered)
|
||||
4. **Too many restarts**: Check Modbus device connectivity and network stability clean package
|
||||
java -jar target/home-pump-1.0.0.jar
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21
|
||||
- Modbus TCP accessible heat pump
|
||||
- InfluxDB instance
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Build the jar first: mvn -f backend/pom.xml clean package
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
|
||||
RUN adduser -D -u 10001 app
|
||||
USER app
|
||||
WORKDIR /app
|
||||
COPY target/home-pump-backend-1.0.0.jar app.jar
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# /api/health answers 200 even while the gateway is unreachable (it reports that in the body),
|
||||
# so this only tells whether the backend itself serves requests. busybox wget ships with alpine.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/api/health" || exit 1
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.home</groupId>
|
||||
<artifactId>home-pump-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<jackson.version>2.20.1</jackson.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.32</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.8.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>home.backend.Main</mainClass>
|
||||
</transformer>
|
||||
</transformers>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,31 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.gateway.GatewayHttpClient;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import home.backend.adapters.influx.InfluxHistoryRepository;
|
||||
import home.backend.adapters.influx.InfluxSettings;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
/** Wires the adapters and services together; the only place that knows all of them. */
|
||||
final class Backend {
|
||||
|
||||
private Backend() {
|
||||
}
|
||||
|
||||
/** Binds the port but does not start serving. */
|
||||
static ApiServer assemble(Settings settings, Clock clock) throws IOException {
|
||||
var gateway = new GatewayHttpClient(settings.gatewayUrl(), settings.gatewayTimeout());
|
||||
var influx = new InfluxHistoryRepository(new InfluxSettings(settings.influxUrl(), settings.influxTimeout()));
|
||||
return new ApiServer(settings.port(),
|
||||
new HeatPumpService(gateway, clock),
|
||||
new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock),
|
||||
new HistoryService(influx, clock));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package home.backend;
|
||||
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
|
||||
public final class Main {
|
||||
|
||||
private Main() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
Settings settings;
|
||||
try {
|
||||
settings = Settings.fromEnvironment(System.getenv());
|
||||
} catch (IllegalArgumentException e) {
|
||||
System.err.println("home-pump-backend: " + e.getMessage());
|
||||
System.exit(2);
|
||||
return;
|
||||
}
|
||||
ApiServer server = Backend.assemble(settings, Clock.systemUTC());
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
|
||||
server.start();
|
||||
System.out.println("home-pump-backend listening on port " + server.port());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package home.backend;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/** Twelve-factor settings: everything comes from environment variables. */
|
||||
public record Settings(URI gatewayUrl, URI influxUrl, int port, Duration gatewayTimeout, Duration influxTimeout) {
|
||||
|
||||
public static Settings fromEnvironment(Map<String, String> env) {
|
||||
return new Settings(
|
||||
url(env, "GATEWAY_URL"),
|
||||
url(env, "INFLUX_URL"),
|
||||
(int) number(env, "PORT", 8080, 0, 65535),
|
||||
Duration.ofSeconds(number(env, "GATEWAY_TIMEOUT", 5, 1, 300)),
|
||||
Duration.ofSeconds(number(env, "INFLUX_TIMEOUT", 10, 1, 300)));
|
||||
}
|
||||
|
||||
private static URI url(Map<String, String> env, String name) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " is required, e.g. " + name + "=http://host:8080");
|
||||
}
|
||||
URI uri = URI.create(value.strip());
|
||||
if (uri.getHost() == null || !("http".equals(uri.getScheme()) || "https".equals(uri.getScheme()))) {
|
||||
throw new IllegalArgumentException(name + " must be an http(s) URL with a host, got: " + value);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
/** Timeouts are whole seconds. */
|
||||
private static long number(Map<String, String> env, String name, long fallback, long min, long max) {
|
||||
String value = env.get(name);
|
||||
if (value == null || value.isBlank()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
long number = Long.parseLong(value.strip());
|
||||
if (number >= min && number <= max) {
|
||||
return number;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below with the same message
|
||||
}
|
||||
throw new IllegalArgumentException(name + " must be a whole number from " + min + " to " + max
|
||||
+ ", got: " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Reads the gateway's {@code /health} and {@code /snapshot} over HTTP. */
|
||||
public class GatewayHttpClient implements GatewayClient {
|
||||
|
||||
private record Health(List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record DeviceReadings(String name, List<Reading> readings) {
|
||||
}
|
||||
|
||||
private record Reading(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
|
||||
// Unknown properties are ignored so the gateway can add fields without breaking us.
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final URI baseUri;
|
||||
private final Duration timeout;
|
||||
private final HttpClient http;
|
||||
|
||||
public GatewayHttpClient(URI baseUri, Duration timeout) {
|
||||
this.baseUri = baseUri;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GatewaySnapshot snapshot() throws IOException {
|
||||
var health = JSON.readValue(get("/health", true), Health.class);
|
||||
var readings = JSON.readValue(get("/snapshot", false), DeviceReadings[].class);
|
||||
Map<String, List<Measurement>> measurements = Arrays.stream(readings)
|
||||
.collect(Collectors.toMap(DeviceReadings::name, GatewayHttpClient::measurements));
|
||||
var devices = health.devices().stream()
|
||||
.map(device -> new DeviceSnapshot(device.name(), device.status().equals("UP"),
|
||||
device.lastSuccess(), device.lastError(),
|
||||
measurements.getOrDefault(device.name(), List.of())))
|
||||
.toList();
|
||||
return new GatewaySnapshot(devices);
|
||||
}
|
||||
|
||||
private static List<Measurement> measurements(DeviceReadings device) {
|
||||
return device.readings().stream()
|
||||
.map(r -> new Measurement(r.database(), r.measurement(), r.fields(), r.time()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** A gateway with a device down answers /health with 503 and a valid body. */
|
||||
private byte[] get(String path, boolean downIsValid) throws IOException {
|
||||
var request = HttpRequest.newBuilder(baseUri.resolve(path)).timeout(timeout).GET().build();
|
||||
try {
|
||||
var response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status != 200 && !(downIsValid && status == 503)) {
|
||||
throw new IOException("gateway " + path + " answered " + status);
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted while calling gateway " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/** The phone-facing JSON API under {@code /api/} and the static web UI. */
|
||||
public class ApiServer {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
private final HttpServer server;
|
||||
|
||||
/** Binds the port now; 0 picks a free one. */
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy, HealthService health,
|
||||
HistoryService history) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
server.createContext("/", new StaticFiles());
|
||||
api("/api/heat-pump", query -> heatPump.current());
|
||||
api("/api/energy", query -> energy.current());
|
||||
api("/api/health", query -> health.current());
|
||||
server.createContext("/api/history", exchange -> handleHistory(exchange, history));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
|
||||
private interface Gateway {
|
||||
Object get(Map<String, String> query) throws IOException;
|
||||
}
|
||||
|
||||
/** Gateway trouble on a live-state endpoint is the gateway being unreachable: 503. */
|
||||
private void api(String path, Gateway gateway) {
|
||||
server.createContext(path, exchange -> respond(exchange, () -> {
|
||||
try {
|
||||
send(exchange, 200, gateway.get(Map.of()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 503, error("gateway unreachable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleHistory(HttpExchange exchange, HistoryService history) throws IOException {
|
||||
respond(exchange, () -> {
|
||||
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||
try {
|
||||
send(exchange, 200, history.history(query.get("series"), query.get("range")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("history unavailable"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, Action action) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static void send(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-store");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
|
||||
private static Map<String, String> error(String message) {
|
||||
return Map.of("error", message);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseQuery(String raw) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
if (raw == null) {
|
||||
return query;
|
||||
}
|
||||
for (String pair : raw.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
query.put(decode(kv[0]), kv.length == 2 ? decode(kv[1]) : "");
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
private static String decode(String s) {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
/** Serves the web UI from the classpath folder {@code web/}; only files of known types. */
|
||||
final class StaticFiles implements HttpHandler {
|
||||
|
||||
private static final Map<String, String> CONTENT_TYPES = Map.of(
|
||||
"html", "text/html; charset=utf-8",
|
||||
"css", "text/css; charset=utf-8",
|
||||
"js", "text/javascript; charset=utf-8",
|
||||
"svg", "image/svg+xml",
|
||||
"png", "image/png",
|
||||
"ico", "image/x-icon");
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String resource = path.equals("/") ? "index.html" : path.substring(1);
|
||||
String type = contentType(resource);
|
||||
try (InputStream in = type == null || !isSafe(resource) ? null : open(resource)) {
|
||||
if (in == null) {
|
||||
exchange.sendResponseHeaders(404, -1);
|
||||
return;
|
||||
}
|
||||
byte[] body = in.readAllBytes();
|
||||
exchange.getResponseHeaders().set("Content-Type", type);
|
||||
exchange.getResponseHeaders().set("Cache-Control", "no-cache");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
try (OutputStream out = exchange.getResponseBody()) {
|
||||
out.write(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static InputStream open(String resource) {
|
||||
return StaticFiles.class.getClassLoader().getResourceAsStream("web/" + resource);
|
||||
}
|
||||
|
||||
/** The path is already URL-decoded, so this also catches %2e%2e. */
|
||||
private static boolean isSafe(String resource) {
|
||||
return !resource.contains("\\") && !resource.contains("\0") && !resource.contains("//")
|
||||
&& Arrays.stream(resource.split("/")).noneMatch(s -> s.equals("..") || s.equals("."));
|
||||
}
|
||||
|
||||
private static String contentType(String resource) {
|
||||
int dot = resource.lastIndexOf('.');
|
||||
return dot < 0 ? null : CONTENT_TYPES.get(resource.substring(dot + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** History from InfluxDB 1.x over its HTTP {@code /query} endpoint (ADR-005, ADR-008). */
|
||||
public final class InfluxHistoryRepository implements HistoryRepository {
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
|
||||
public InfluxHistoryRepository(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException {
|
||||
if (maxPoints < 1 || !from.isBefore(to)) {
|
||||
throw new IllegalArgumentException("need maxPoints >= 1 and from < to");
|
||||
}
|
||||
long stepMinutes = stepMinutes(from, to, maxPoints);
|
||||
Map<Long, Double> summed = new TreeMap<>();
|
||||
for (Source source : SeriesSources.of(series)) {
|
||||
String query = source.query(from.toEpochMilli(), to.toEpochMilli(), stepMinutes);
|
||||
// A part without data is absent, not zero, so a bucket is kept if any part has it.
|
||||
InfluxResponse.parse(fetch(source.database(), query)).forEach((t, v) -> summed.merge(t, v, Double::sum));
|
||||
}
|
||||
return summed.entrySet().stream()
|
||||
.map(e -> new Point(Instant.ofEpochMilli(e.getKey()), e.getValue()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static long stepMinutes(Instant from, Instant to, int maxPoints) {
|
||||
long stepMillis = Math.ceilDiv(Duration.between(from, to).toMillis(), maxPoints);
|
||||
return Math.max(1, Math.ceilDiv(stepMillis, Duration.ofMinutes(1).toMillis()));
|
||||
}
|
||||
|
||||
private String fetch(String database, String query) throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(queryUri(database, query))
|
||||
.timeout(settings.requestTimeout())
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("InfluxDB answered HTTP " + response.statusCode());
|
||||
}
|
||||
return response.body();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("InfluxDB request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private URI queryUri(String database, String query) {
|
||||
String base = settings.baseUri().toString().replaceAll("/+$", "");
|
||||
return URI.create(base + "/query?db=" + encode(database) + "&epoch=ms&q=" + encode(query));
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The {@code time, mean} rows of an InfluxDB 1.x query response. */
|
||||
final class InfluxResponse {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private InfluxResponse() {
|
||||
}
|
||||
|
||||
/** Mean per bucket start in epoch milliseconds, ascending; empty when there is no data. */
|
||||
static Map<Long, Double> parse(String body) throws IOException {
|
||||
JsonNode root = read(body);
|
||||
failOnError(root);
|
||||
Map<Long, Double> means = new TreeMap<>();
|
||||
for (JsonNode result : root.path("results")) {
|
||||
failOnError(result);
|
||||
for (JsonNode series : result.path("series")) {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
if (row.size() >= 2 && row.get(1).isNumber()) {
|
||||
means.put(row.get(0).asLong(), row.get(1).asDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return means;
|
||||
}
|
||||
|
||||
private static JsonNode read(String body) throws IOException {
|
||||
try {
|
||||
return JSON.readTree(body);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IOException("InfluxDB returned invalid JSON: " + e.getOriginalMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void failOnError(JsonNode node) throws IOException {
|
||||
if (node.hasNonNull("error")) {
|
||||
throw new IOException("InfluxDB query failed: " + node.get("error").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
|
||||
public record InfluxSettings(URI baseUri, Duration requestTimeout) {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Where each series lives in InfluxDB. A fixed table: no query is built from user input. */
|
||||
final class SeriesSources {
|
||||
|
||||
private static final String LEGACY = "Dom_Pompa";
|
||||
private static final String ELECTRICITY = "electricity";
|
||||
|
||||
private static final Map<Series, List<Source>> SOURCES = Map.ofEntries(
|
||||
Map.entry(Series.OUTDOOR_TEMP, List.of(pompa("External temperature"))),
|
||||
Map.entry(Series.INDOOR_TEMP, List.of(pompa("Temp wew"))),
|
||||
Map.entry(Series.CO_BUFFER_TEMP, List.of(pompa("Temp co"))),
|
||||
Map.entry(Series.CWU_TEMP, List.of(pompa("Temp cwu"))),
|
||||
Map.entry(Series.GROUND_IN_TEMP, List.of(pompa("Temp in"))),
|
||||
Map.entry(Series.GROUND_OUT_TEMP, List.of(pompa("Temp out"))),
|
||||
Map.entry(Series.PUMP_OUTLET_TEMP, List.of(pompa("Temp pc out"))),
|
||||
Map.entry(Series.GRID_POWER, List.of(electricity("PRAD2", "total_power_active"))),
|
||||
Map.entry(Series.PV_POWER, List.of(
|
||||
electricity("deye", "pv1_power_w"),
|
||||
electricity("deye", "pv2_power_w"),
|
||||
electricity("fronius", "pv_power_w"))),
|
||||
Map.entry(Series.LOAD_POWER, List.of(electricity("deye", "load_power_w"))),
|
||||
Map.entry(Series.BATTERY_SOC, List.of(electricity("deye", "battery_soc_pct"))));
|
||||
|
||||
private SeriesSources() {
|
||||
}
|
||||
|
||||
static List<Source> of(Series series) {
|
||||
return SOURCES.get(series);
|
||||
}
|
||||
|
||||
private static Source pompa(String field) {
|
||||
return new Source(LEGACY, "POMPA", field);
|
||||
}
|
||||
|
||||
private static Source electricity(String measurement, String field) {
|
||||
return new Source(ELECTRICITY, measurement, field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
/** One field of one measurement; the parts of a series are summed. */
|
||||
record Source(String database, String measurement, String field) {
|
||||
|
||||
String query(long fromMs, long toMs, long stepMinutes) {
|
||||
return "SELECT mean(" + quote(field) + ") FROM " + quote(measurement)
|
||||
+ " WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms"
|
||||
+ " GROUP BY time(" + stepMinutes + "m) fill(none)";
|
||||
}
|
||||
|
||||
private static String quote(String identifier) {
|
||||
return '"' + identifier.replace("\\", "\\\\").replace("\"", "\\\"") + '"';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.EnergyView.Today;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/** F7: house electricity from the Deye inverter and the Fronius PV inverter. */
|
||||
public class EnergyService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public EnergyService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public EnergyView current() throws IOException {
|
||||
GatewaySnapshot snapshot = gateway.snapshot();
|
||||
Optional<Measurement> deye = SnapshotFields.find(snapshot, "deye", "deye");
|
||||
Optional<Measurement> fronius = SnapshotFields.find(snapshot, "fronius", "fronius");
|
||||
Map<String, Double> d = deye.map(Measurement::fields).orElse(Map.of());
|
||||
Map<String, Double> f = fronius.map(Measurement::fields).orElse(Map.of());
|
||||
|
||||
Double froniusKwh = f.containsKey("energy_day_wh") ? f.get("energy_day_wh") / 1000 : null;
|
||||
Today today = new Today(round(sum(d.get("pv_today_kwh"), froniusKwh)),
|
||||
d.get("grid_bought_today_kwh"), d.get("grid_sold_today_kwh"), d.get("load_today_kwh"));
|
||||
return new EnergyView(time(deye, fronius),
|
||||
sum(d.get("pv1_power_w"), d.get("pv2_power_w"), f.get("pv_power_w")),
|
||||
d.get("grid_power_w"), d.get("battery_power_w"), d.get("battery_soc_pct"),
|
||||
d.get("load_power_w"), today);
|
||||
}
|
||||
|
||||
private Instant time(Optional<Measurement> deye, Optional<Measurement> fronius) {
|
||||
return Stream.of(deye, fronius).flatMap(Optional::stream).map(Measurement::time)
|
||||
.max(Instant::compareTo).orElseGet(clock::instant);
|
||||
}
|
||||
|
||||
/** Sum of the values that exist; null when none does. */
|
||||
private static Double sum(Double... values) {
|
||||
return Stream.of(values).filter(v -> v != null).reduce(Double::sum).orElse(null);
|
||||
}
|
||||
|
||||
/** Wh to kWh leaves binary noise such as 34.300000000000004. */
|
||||
private static Double round(Double kwh) {
|
||||
return kwh == null ? null : Math.round(kwh * 1000) / 1000.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The live state, read from the gateway on the Pi through the tunnel. */
|
||||
public interface GatewayClient {
|
||||
|
||||
GatewaySnapshot snapshot() throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HealthView.DeviceHealth;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
|
||||
/** F5: whether the gateway answers and which device links are up. */
|
||||
public class HealthService {
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public HealthService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public HealthView current() {
|
||||
try {
|
||||
List<DeviceHealth> devices = gateway.snapshot().devices().stream()
|
||||
.map(HealthService::health).toList();
|
||||
return new HealthView(clock.instant(), true, devices);
|
||||
} catch (IOException e) {
|
||||
return new HealthView(clock.instant(), false, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private static DeviceHealth health(DeviceSnapshot d) {
|
||||
return new DeviceHealth(d.name(), d.up(), d.lastSuccess(), d.lastError());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.Pumps;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static home.backend.application.SnapshotFields.on;
|
||||
import static home.backend.application.SnapshotFields.whole;
|
||||
|
||||
/** F1: the heat pump's live state from the gateway's {@code heat_pump} measurement. */
|
||||
public class HeatPumpService {
|
||||
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
private static final State[] STATES = State.values();
|
||||
|
||||
private final GatewayClient gateway;
|
||||
private final Clock clock;
|
||||
|
||||
public HeatPumpService(GatewayClient gateway, Clock clock) {
|
||||
this.gateway = gateway;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public HeatPumpView current() throws IOException {
|
||||
return SnapshotFields.find(gateway.snapshot(), "heat-pump", "heat_pump")
|
||||
.filter(m -> DATABASE.equals(m.database()))
|
||||
.map(m -> view(m.time(), m.fields()))
|
||||
.orElseGet(() -> view(clock.instant(), Map.of()));
|
||||
}
|
||||
|
||||
private static HeatPumpView view(Instant time, Map<String, Double> f) {
|
||||
return new HeatPumpView(time, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
on(f, "alarm"),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
new Temperatures(tenths(f, "outdoor_temp"), tenths(f, "indoor_temp"),
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp")),
|
||||
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
}
|
||||
|
||||
/** The controller resolves 0.1 °C; the gateway's 212 * 0.1 is 21.200000000000003. */
|
||||
private static Double tenths(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : Math.round(value * 10) / 10.0;
|
||||
}
|
||||
|
||||
private static State state(Map<String, Double> f) {
|
||||
Integer mode = whole(f, "pump_mode");
|
||||
return mode == null || mode < 0 || mode >= STATES.length ? null : STATES[mode];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Past values from InfluxDB, averaged into about {@code maxPoints} steps. */
|
||||
public interface HistoryRepository {
|
||||
|
||||
List<Point> history(Series series, Instant from, Instant to, int maxPoints) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Series;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/** F2: one series over the last 24 hours or 7 days. */
|
||||
public class HistoryService {
|
||||
|
||||
static final int MAX_POINTS = 300;
|
||||
private static final String DEFAULT_RANGE = "24h";
|
||||
private static final Map<String, Duration> RANGES =
|
||||
Map.of("24h", Duration.ofHours(24), "7d", Duration.ofDays(7));
|
||||
|
||||
private final HistoryRepository repository;
|
||||
private final Clock clock;
|
||||
|
||||
public HistoryService(HistoryRepository repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param range {@code 24h} or {@code 7d}; null means 24h
|
||||
* @throws IllegalArgumentException for an unknown series or range
|
||||
*/
|
||||
public HistoryView history(String seriesId, String range) throws IOException {
|
||||
Series series = Series.fromId(seriesId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("unknown series: " + seriesId));
|
||||
Duration length = RANGES.get(range == null ? DEFAULT_RANGE : range);
|
||||
if (length == null) {
|
||||
throw new IllegalArgumentException("unknown range: " + range);
|
||||
}
|
||||
Instant to = clock.instant();
|
||||
return new HistoryView(series.id(), series.unit(),
|
||||
repository.history(series, to.minus(length), to, MAX_POINTS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/** Looks up one measurement of one device in a snapshot and reads typed fields. */
|
||||
final class SnapshotFields {
|
||||
|
||||
private SnapshotFields() {
|
||||
}
|
||||
|
||||
static Optional<Measurement> find(GatewaySnapshot snapshot, String device, String measurement) {
|
||||
return snapshot.devices().stream()
|
||||
.filter(d -> d.name().equals(device))
|
||||
.map(DeviceSnapshot::measurements)
|
||||
.flatMap(List::stream)
|
||||
.filter(m -> m.measurement().equals(measurement))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
static Integer whole(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
static boolean on(Map<String, Double> fields, String name) {
|
||||
Double value = fields.get(name);
|
||||
return value != null && value != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The house's electricity now (F7). Powers in W; grid + import / − export, battery + discharge /
|
||||
* − charge. Null when the source device is not read.
|
||||
*/
|
||||
public record EnergyView(Instant time, Double pvPowerW, Double gridPowerW, Double batteryPowerW,
|
||||
Double batterySocPct, Double loadPowerW, Today today) {
|
||||
|
||||
/** Day totals in kWh since local midnight; PV adds Deye and Fronius. */
|
||||
public record Today(Double pvKwh, Double gridBoughtKwh, Double gridSoldKwh, Double loadKwh) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The gateway's latest state of every device, as the backend sees it. */
|
||||
public record GatewaySnapshot(List<DeviceSnapshot> devices) {
|
||||
|
||||
public record DeviceSnapshot(String name, boolean up, Instant lastSuccess, String lastError,
|
||||
List<Measurement> measurements) {
|
||||
}
|
||||
|
||||
/** One reading the gateway wrote to InfluxDB, e.g. heat_pump or deye. */
|
||||
public record Measurement(String database, String measurement, Map<String, Double> fields, Instant time) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Health of the chain phone → backend → gateway → devices (F5). */
|
||||
public record HealthView(Instant time, boolean gatewayReachable, List<DeviceHealth> devices) {
|
||||
|
||||
public record DeviceHealth(String name, boolean up, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** What the heat pump does now (F1). Values are null when the gateway has not read them yet. */
|
||||
public record HeatPumpView(Instant time, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Pumps pumps, Temperatures temperatures, Targets targets, Setpoints setpoints) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
public record Pumps(boolean co, boolean buffer, boolean circulation, boolean ground, boolean cwu) {
|
||||
}
|
||||
|
||||
public record Temperatures(Double outdoor, Double indoor, Double coBuffer, Double cwu,
|
||||
Double groundIn, Double groundOut, Double pumpOutlet, Double outdoorAvg) {
|
||||
}
|
||||
|
||||
/** Targets the controller works to right now (economy or comfort period). */
|
||||
public record Targets(Double room, Double co, Double cwu) {
|
||||
}
|
||||
|
||||
/** The four setpoints F3 will change; whole °C. */
|
||||
public record Setpoints(Integer coEconomy, Integer coComfort, Integer cwuEconomy, Integer cwuComfort) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** One series over a time range, already averaged into chart-sized steps. */
|
||||
public record HistoryView(String series, String unit, List<Point> points) {
|
||||
|
||||
public record Point(Instant t, double v) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
/** The history series the UI may ask for (F2); an allowlist, so no query is built from input. */
|
||||
public enum Series {
|
||||
OUTDOOR_TEMP("outdoor_temp", "°C"),
|
||||
INDOOR_TEMP("indoor_temp", "°C"),
|
||||
CO_BUFFER_TEMP("co_buffer_temp", "°C"),
|
||||
CWU_TEMP("cwu_temp", "°C"),
|
||||
GROUND_IN_TEMP("ground_in_temp", "°C"),
|
||||
GROUND_OUT_TEMP("ground_out_temp", "°C"),
|
||||
PUMP_OUTLET_TEMP("pump_outlet_temp", "°C"),
|
||||
GRID_POWER("grid_power", "W"),
|
||||
PV_POWER("pv_power", "W"),
|
||||
LOAD_POWER("load_power", "W"),
|
||||
BATTERY_SOC("battery_soc", "%");
|
||||
|
||||
private final String id;
|
||||
private final String unit;
|
||||
|
||||
Series(String id, String unit) {
|
||||
this.id = id;
|
||||
this.unit = unit;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String unit() {
|
||||
return unit;
|
||||
}
|
||||
|
||||
public static Optional<Series> fromId(String id) {
|
||||
return Arrays.stream(values()).filter(series -> series.id.equals(id)).findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export async function getJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`${url}: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// A write is sent once and never retried. Resolves with the {status, message, restoreAt}
|
||||
// answer, also for a refusal; rejects when the gateway is unreachable (503) or the network fails.
|
||||
export async function postJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
if (response.status === 503) throw new Error(`${url}: 503`);
|
||||
const answer = await response.json().catch(() => null);
|
||||
if (!answer?.status) throw new Error(`${url}: ${response.status}`);
|
||||
return answer;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ageMinutes, ago } from "./format.js";
|
||||
import heatPump from "./heat-pump.js";
|
||||
import energy from "./energy.js";
|
||||
import history from "./history.js";
|
||||
import settings from "./settings.js";
|
||||
import status from "./status.js";
|
||||
|
||||
const REFRESH_MS = 30000;
|
||||
const STALE_MINUTES = 10;
|
||||
const screens = { "heat-pump": heatPump, energy, history, settings, status };
|
||||
const root = document.getElementById("screen");
|
||||
const tabs = document.querySelectorAll("#tabs button");
|
||||
const cache = {}; // last good data per screen, kept while the backend is unreachable
|
||||
|
||||
let current = screens[location.hash.slice(1)] ? location.hash.slice(1) : "heat-pump";
|
||||
|
||||
function notice(offline, data) {
|
||||
if (offline) {
|
||||
const last = data
|
||||
? ` Pokazuję ostatnie dane (${ago(data.time)}).`
|
||||
: " Nie można pobrać danych z domu. Aplikacja spróbuje znowu za chwilę.";
|
||||
return `<div class="banner bad" role="alert">Brak połączenia.${last}</div>`;
|
||||
}
|
||||
if (data.time && ageMinutes(data.time) >= STALE_MINUTES) {
|
||||
return `<div class="banner warn">Dane są nieaktualne: ${ago(data.time)}.</div>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function draw(name, offline) {
|
||||
if (name !== current) return;
|
||||
const data = cache[name];
|
||||
root.innerHTML = notice(offline, data) + (data ? screens[name].view(data) : "");
|
||||
screens[name].bind?.(root, data, () => refresh(name));
|
||||
}
|
||||
|
||||
async function refresh(name) {
|
||||
let offline = false;
|
||||
try {
|
||||
cache[name] = await screens[name].load();
|
||||
} catch {
|
||||
offline = true;
|
||||
}
|
||||
draw(name, offline);
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
current = name;
|
||||
location.hash = name;
|
||||
for (const tab of tabs) {
|
||||
if (tab.dataset.screen === name) tab.setAttribute("aria-current", "page");
|
||||
else tab.removeAttribute("aria-current");
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
if (cache[name]) draw(name, false);
|
||||
else root.innerHTML = `<p class="muted">Ładowanie…</p>`;
|
||||
refresh(name);
|
||||
}
|
||||
|
||||
for (const tab of tabs) tab.addEventListener("click", () => show(tab.dataset.screen));
|
||||
setInterval(() => refresh(current), REFRESH_MS);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) refresh(current);
|
||||
});
|
||||
show(current);
|
||||
@@ -0,0 +1,100 @@
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const W = 340, H = 220;
|
||||
const MARGIN = { left: 48, right: 22, top: 24, bottom: 26 };
|
||||
const Y_TICKS = 4; // about; the step is rounded
|
||||
const DAY_NAMES = ["nd", "pn", "wt", "śr", "cz", "pt", "sb"];
|
||||
const DAY_MS = 86400000;
|
||||
const HOURS_BETWEEN_TICKS = 6;
|
||||
|
||||
// Local midnights (or every 6th full hour) inside the range, so the axis reads like a clock.
|
||||
function timeTicks(t0, t1, range) {
|
||||
const ticks = [];
|
||||
const date = new Date(t0);
|
||||
date.setMinutes(0, 0, 0);
|
||||
if (range === "7d") date.setHours(0);
|
||||
else date.setHours(Math.ceil(date.getHours() / HOURS_BETWEEN_TICKS) * HOURS_BETWEEN_TICKS);
|
||||
for (; date.getTime() <= t1; range === "7d" ? date.setDate(date.getDate() + 1) : date.setHours(date.getHours() + HOURS_BETWEEN_TICKS)) {
|
||||
if (date.getTime() >= t0) ticks.push(date.getTime());
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// 1, 2 or 5 times a power of ten, so the value labels are round numbers.
|
||||
function niceStep(rough) {
|
||||
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
||||
return [1, 2, 5, 10].map((m) => m * magnitude).find((s) => s >= rough);
|
||||
}
|
||||
|
||||
const xText = (px, anchor, text) => `<text class="sub" x="${px}" y="${H - 6}" text-anchor="${anchor}">${text}</text>`;
|
||||
|
||||
function hourLabels(ticks, x) {
|
||||
return ticks.map((t) => xText(x(t), "middle", hour(new Date(t)))).join("");
|
||||
}
|
||||
|
||||
// A weekday name sits in the middle of its day, between two midnight lines.
|
||||
function dayLabels(midnights, t0, t1, x) {
|
||||
const edges = [t0, ...midnights, t1];
|
||||
return edges.slice(0, -1).map((from, i) => {
|
||||
const to = edges[i + 1];
|
||||
if (to - from < DAY_MS / 3) return "";
|
||||
return xText((x(from) + x(to)) / 2, "middle", DAY_NAMES[new Date(from + 1).getDay()]);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// Time and value axes for a series of {t, v} points; returns the scales and the SVG markup.
|
||||
export function lineChart(points, range, unit) {
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const values = points.map((p) => p.v);
|
||||
const [t0, t1] = [Math.min(...times), Math.max(...times)];
|
||||
let [lo, hi] = [Math.min(...values), Math.max(...values)];
|
||||
if (hi - lo < 1) [lo, hi] = [lo - 0.5, hi + 0.5];
|
||||
const step = niceStep((hi - lo) / Y_TICKS);
|
||||
[lo, hi] = [Math.floor(lo / step) * step, Math.ceil(hi / step) * step];
|
||||
|
||||
const x = (t) => MARGIN.left + ((t - t0) / (t1 - t0 || 1)) * (W - MARGIN.left - MARGIN.right);
|
||||
const y = (v) => H - MARGIN.bottom - ((v - lo) / (hi - lo)) * (H - MARGIN.top - MARGIN.bottom);
|
||||
|
||||
const digits = step < 1 ? 1 : 0;
|
||||
const yLabels = Array.from({ length: Math.round((hi - lo) / step) + 1 }, (_, i) => {
|
||||
const v = lo + i * step;
|
||||
return `<line class="axis" x1="${MARGIN.left}" x2="${W - MARGIN.right}" y1="${y(v)}" y2="${y(v)}"/>
|
||||
<text class="sub" x="${MARGIN.left - 6}" y="${y(v) + 4}" text-anchor="end">${number(v, digits)}</text>`;
|
||||
}).join("");
|
||||
const ticks = timeTicks(t0, t1, range);
|
||||
const grid = ticks.map((t) => `<line class="axis" x1="${x(t)}" x2="${x(t)}" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>`).join("");
|
||||
const xLabels = range === "7d" ? dayLabels(ticks, t0, t1, x) : hourLabels(ticks, x);
|
||||
const path = points.map((p, i) => `${i ? "L" : "M"}${x(times[i]).toFixed(1)} ${y(p.v).toFixed(1)}`).join("");
|
||||
|
||||
const svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Wykres">
|
||||
<text class="sub" x="2" y="12">${unit}</text>
|
||||
${yLabels}${grid}${xLabels}<path class="line" d="${path}"/>
|
||||
<g id="cursor" hidden><line class="cursor" y1="${MARGIN.top}" y2="${H - MARGIN.bottom}"/>
|
||||
<circle class="mark" r="5"/></g></svg>`;
|
||||
return { svg, x, y, min: Math.min(...values), max: Math.max(...values) };
|
||||
}
|
||||
|
||||
// Shows the point nearest to the finger or mouse in the readout element.
|
||||
export function trackPointer(svg, readout, chart, { points, unit }) {
|
||||
const cursor = svg.querySelector("#cursor");
|
||||
const times = points.map((p) => Date.parse(p.t));
|
||||
const show = (event) => {
|
||||
const box = svg.getBoundingClientRect();
|
||||
const px = ((event.clientX - box.left) / box.width) * W;
|
||||
let best = 0;
|
||||
times.forEach((time, i) => {
|
||||
if (Math.abs(chart.x(time) - px) < Math.abs(chart.x(times[best]) - px)) best = i;
|
||||
});
|
||||
const [cx, cy] = [chart.x(times[best]), chart.y(points[best].v)];
|
||||
cursor.hidden = false;
|
||||
cursor.querySelector("line").setAttribute("x1", cx);
|
||||
cursor.querySelector("line").setAttribute("x2", cx);
|
||||
cursor.querySelector("circle").setAttribute("cx", cx);
|
||||
cursor.querySelector("circle").setAttribute("cy", cy);
|
||||
readout.textContent = `${dayAndHour(new Date(times[best]))}: ${number(points[best].v, unit === "W" || unit === "%" ? 0 : 1)} ${unit}`;
|
||||
};
|
||||
svg.addEventListener("pointerdown", show);
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (event.buttons || event.pointerType === "mouse") show(event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { kwh, percent, power } from "./format.js";
|
||||
|
||||
const FLOWING_W = 10;
|
||||
|
||||
// Nodes sit around the house; an arrow runs from the source to the sink of each flow.
|
||||
const NODES = {
|
||||
pv: { x: 68, y: 40, label: "Słońce" },
|
||||
grid: { x: 292, y: 40, label: "Sieć" },
|
||||
house: { x: 180, y: 125, label: "Dom" },
|
||||
battery: { x: 180, y: 215, label: "Bateria" },
|
||||
};
|
||||
|
||||
function node(id, value) {
|
||||
const { x, y, label } = NODES[id];
|
||||
return `<rect class="box" x="${x - 58}" y="${y - 30}" width="116" height="60" rx="10"/>
|
||||
<text x="${x}" y="${y - 7}" text-anchor="middle" font-size="15" class="sub">${label}</text>
|
||||
<text x="${x}" y="${y + 18}" text-anchor="middle" font-size="20" font-weight="700">${value}</text>`;
|
||||
}
|
||||
|
||||
function flow(from, to, watts) {
|
||||
if (watts == null || Math.abs(watts) < FLOWING_W) return "";
|
||||
const [source, sink] = watts > 0 ? [from, to] : [to, from];
|
||||
const a = NODES[source], b = NODES[sink];
|
||||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const t1 = 40 / length, t2 = 1 - 46 / length;
|
||||
const point = (t, axis) => a[axis] + (b[axis] - a[axis]) * t;
|
||||
return `<line class="flow" x1="${point(t1, "x")}" y1="${point(t1, "y")}"
|
||||
x2="${point(t2, "x")}" y2="${point(t2, "y")}" marker-end="url(#arrow)"/>`;
|
||||
}
|
||||
|
||||
function flowPicture(e) {
|
||||
return `<svg class="schematic" viewBox="0 0 360 260" role="img" aria-label="Przepływ energii w domu">
|
||||
<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5"
|
||||
orient="auto"><path d="M0 0 10 5 0 10z" fill="var(--accent)"/></marker></defs>
|
||||
${flow("pv", "house", e.pvPowerW)}${flow("grid", "house", e.gridPowerW)}
|
||||
${flow("battery", "house", e.batteryPowerW)}
|
||||
${node("pv", power(e.pvPowerW))}${node("grid", power(magnitude(e.gridPowerW)))}
|
||||
${node("house", power(e.loadPowerW))}${node("battery", percent(e.batterySocPct))}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const magnitude = (watts) => (watts == null ? null : Math.abs(watts));
|
||||
|
||||
function gridText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez wymiany";
|
||||
return watts > 0 ? "pobór" : "oddawanie";
|
||||
}
|
||||
|
||||
function batteryText(watts) {
|
||||
if (watts == null) return "—";
|
||||
if (Math.abs(watts) < FLOWING_W) return "bez przepływu";
|
||||
return watts > 0 ? `rozładowuje się, ${power(watts)}` : `ładuje się, ${power(-watts)}`;
|
||||
}
|
||||
|
||||
const tile = (label, value, note = "") =>
|
||||
`<div class="tile"><div class="muted">${label}</div><div class="big">${value}</div>
|
||||
<div class="muted">${note}</div></div>`;
|
||||
|
||||
function view(e) {
|
||||
const today = e.today;
|
||||
return `<section class="card"><h1>Energia teraz</h1>${flowPicture(e)}</section>
|
||||
<section class="card"><div class="grid2">
|
||||
${tile("Produkcja PV", power(e.pvPowerW), "Deye + Fronius")}
|
||||
${tile("Zużycie domu", power(e.loadPowerW))}
|
||||
${tile("Sieć", power(magnitude(e.gridPowerW)), gridText(e.gridPowerW))}
|
||||
${tile("Bateria", percent(e.batterySocPct), batteryText(e.batteryPowerW))}
|
||||
</div></section>
|
||||
<section class="card"><h2>Dzisiaj</h2><div class="grid2">
|
||||
${tile("Produkcja PV", kwh(today.pvKwh))}
|
||||
${tile("Zużycie", kwh(today.loadKwh))}
|
||||
${tile("Pobrano z sieci", kwh(today.gridBoughtKwh))}
|
||||
${tile("Oddano do sieci", kwh(today.gridSoldKwh))}
|
||||
</div></section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/energy"), view };
|
||||
@@ -0,0 +1,39 @@
|
||||
// Polish number and time formats; every function accepts null and shows a dash.
|
||||
const DASH = "—";
|
||||
|
||||
const decimals = (digits) => new Intl.NumberFormat("pl-PL", {
|
||||
minimumFractionDigits: digits, maximumFractionDigits: digits,
|
||||
});
|
||||
const formats = [decimals(0), decimals(1), decimals(2)];
|
||||
|
||||
// The typographic minus is wider than the hyphen, so -3 is not read as a dash or a range.
|
||||
export function number(value, digits = 0) {
|
||||
return value == null ? DASH : formats[digits].format(value).replace("-", "\u2212");
|
||||
}
|
||||
|
||||
export const celsius = (value) => (value == null ? DASH : `${number(value, 1)} °C`);
|
||||
export const percent = (value) => (value == null ? DASH : `${number(value)} %`);
|
||||
export const kwh = (value) => (value == null ? DASH : `${number(value, 1)} kWh`);
|
||||
|
||||
export function power(watts) {
|
||||
if (watts == null) return DASH;
|
||||
return Math.abs(watts) >= 1000 ? `${number(watts / 1000, 1)} kW` : `${number(watts)} W`;
|
||||
}
|
||||
|
||||
export function ageMinutes(iso, now = Date.now()) {
|
||||
return Math.max(0, Math.floor((now - Date.parse(iso)) / 60000));
|
||||
}
|
||||
|
||||
export function ago(iso) {
|
||||
if (iso == null) return "brak odczytu";
|
||||
const minutes = ageMinutes(iso);
|
||||
if (minutes < 1) return "przed chwilą";
|
||||
if (minutes < 60) return `${minutes} min temu`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 48) return `${hours} godz. temu`;
|
||||
return `${Math.floor(hours / 24)} dni temu`;
|
||||
}
|
||||
|
||||
export const hour = (date) => date.toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" });
|
||||
export const weekday = (date) => date.toLocaleDateString("pl-PL", { weekday: "short" });
|
||||
export const dayAndHour = (date) => `${weekday(date)} ${hour(date)}`;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { celsius, hour } from "./format.js";
|
||||
import { schematic } from "./schematic.js";
|
||||
|
||||
const HEADLINES = {
|
||||
IDLE: "Pompa odpoczywa",
|
||||
HEATING_CWU: "Grzeje wodę (CWU)",
|
||||
HEATING_CO: "Grzeje dom (CO)",
|
||||
HEATING_BOTH: "Grzeje dom i wodę",
|
||||
};
|
||||
|
||||
const PUMP_NAMES = {
|
||||
ground: "Pompa gruntowa", buffer: "Pompa bufora", co: "Pompa CO",
|
||||
cwu: "Pompa CWU", circulation: "Pompa cyrkulacji",
|
||||
};
|
||||
|
||||
const row = (label, value) => `<div><span>${label}</span><b>${value}</b></div>`;
|
||||
|
||||
function pumpRows(pumps) {
|
||||
return Object.entries(PUMP_NAMES)
|
||||
.map(([key, name]) => row(name, pumps[key] ? "działa" : "stoi")).join("");
|
||||
}
|
||||
|
||||
function view(data) {
|
||||
const { targets, temperatures: t } = data;
|
||||
const alarm = data.alarm
|
||||
? `<div class="banner bad" role="alert">ALARM pompy ciepła. Sprawdź sterownik w domu.</div>` : "";
|
||||
return `${alarm}
|
||||
<section class="card">
|
||||
<p class="headline">${HEADLINES[data.state]}</p>
|
||||
<p class="muted">Odczyt z ${hour(new Date(data.time))}</p>
|
||||
${schematic(data)}
|
||||
</section>
|
||||
<section class="card rows"><h2>Pompy</h2>${pumpRows(data.pumps)}</section>
|
||||
<section class="card rows"><h2>Temperatury</h2>
|
||||
${row("Na zewnątrz", celsius(t.outdoor))}
|
||||
${row("Średnia zewnętrzna", celsius(t.outdoorAvg))}
|
||||
</section>
|
||||
<section class="card rows"><h2>Cele teraz</h2>
|
||||
${row("Pokój", celsius(targets.room))}
|
||||
${row("Woda grzewcza CO", celsius(targets.co))}
|
||||
${row("Ciepła woda CWU", celsius(targets.cwu))}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/heat-pump"), view };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { number } from "./format.js";
|
||||
import { lineChart, trackPointer } from "./chart.js";
|
||||
|
||||
// Ids as in Series.java.
|
||||
const SERIES = {
|
||||
outdoor_temp: "Temperatura na zewnątrz",
|
||||
indoor_temp: "Temperatura w domu",
|
||||
co_buffer_temp: "Bufor CO",
|
||||
cwu_temp: "Ciepła woda (CWU)",
|
||||
ground_in_temp: "Gruntowe: do pompy",
|
||||
ground_out_temp: "Gruntowe: z pompy",
|
||||
pump_outlet_temp: "Wyjście pompy ciepła",
|
||||
grid_power: "Moc z sieci (+ pobór, − oddawanie)",
|
||||
pv_power: "Produkcja PV",
|
||||
load_power: "Zużycie domu",
|
||||
battery_soc: "Bateria (naładowanie)",
|
||||
};
|
||||
const RANGES = { "24h": "24 godziny", "7d": "7 dni" };
|
||||
|
||||
const choice = { series: "cwu_temp", range: "24h" };
|
||||
let chart = null; // the one drawn by the last view, for the pointer handler
|
||||
|
||||
const options = () => Object.entries(SERIES)
|
||||
.map(([id, name]) => `<option value="${id}"${id === choice.series ? " selected" : ""}>${name}</option>`).join("");
|
||||
|
||||
const rangeButtons = () => Object.entries(RANGES)
|
||||
.map(([id, name]) => `<button data-range="${id}" aria-pressed="${id === choice.range}">${name}</button>`).join("");
|
||||
|
||||
function body(data) {
|
||||
chart = null;
|
||||
if (data.points.length < 2) return `<p class="muted">Za mało danych w tym okresie.</p>`;
|
||||
chart = lineChart(data.points, choice.range, data.unit);
|
||||
const digits = data.unit === "°C" ? 1 : 0;
|
||||
return `${chart.svg}
|
||||
<p class="readout" id="readout">Dotknij wykresu, aby zobaczyć wartość.</p>
|
||||
<p class="muted">Najmniej ${number(chart.min, digits)} ${data.unit} · najwięcej ${number(chart.max, digits)} ${data.unit}</p>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<section class="card"><h1>Historia</h1>
|
||||
<select id="series" aria-label="Wielkość">${options()}</select>
|
||||
<div class="seg">${rangeButtons()}</div>
|
||||
${body(data)}</section>`;
|
||||
|
||||
function bind(root, data, reload) {
|
||||
root.querySelector("#series").addEventListener("change", (event) => {
|
||||
choice.series = event.target.value;
|
||||
reload();
|
||||
});
|
||||
for (const button of root.querySelectorAll("[data-range]")) {
|
||||
button.addEventListener("click", () => {
|
||||
choice.range = button.dataset.range;
|
||||
reload();
|
||||
});
|
||||
}
|
||||
if (chart) trackPointer(root.querySelector("svg.chart"), root.querySelector("#readout"), chart, data);
|
||||
}
|
||||
|
||||
const load = () => getJson(`api/history?series=${choice.series}&range=${choice.range}`);
|
||||
|
||||
export default { load, view, bind };
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Dom Krosno</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="screen" aria-live="polite"></main>
|
||||
<nav id="tabs" aria-label="Ekrany">
|
||||
<button data-screen="heat-pump"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3 4 5 6.5 5 10a5 5 0 0 1-10 0c0-2 1-3.5 2-5 1 1 1.5 2 2 2.5C11.5 8.5 11 6 12 3z"/></svg>Pompa ciepła</button>
|
||||
<button data-screen="energy"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 5 13h6l-1 9 8-11h-6z"/></svg>Energia</button>
|
||||
<button data-screen="history"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 18 9 11l4 4 8-10" fill="none" stroke-width="2.5"/></svg>Historia</button>
|
||||
<button data-screen="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10" fill="none" stroke-width="2.5"/><circle cx="16" cy="7" r="2.5"/><circle cx="8" cy="17" r="2.5"/></svg>Ustawienia</button>
|
||||
<button data-screen="status"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke-width="2.5"/><path d="m8 12 3 3 5-6" fill="none" stroke-width="2.5"/></svg>Stan</button>
|
||||
</nav>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { celsius, number } from "./format.js";
|
||||
|
||||
// The installation as one picture: ground loop → heat pump → CO buffer → house, and the CWU
|
||||
// tank. The solar collector has no sensor, so it is drawn without values.
|
||||
|
||||
const on = (flag) => (flag ? " on" : "");
|
||||
|
||||
function pump(x, y, running) {
|
||||
return `<g class="${running ? "on" : ""}" transform="translate(${x} ${y})">
|
||||
<circle class="pump" r="13"/>
|
||||
<path class="spokes" d="M-8 0H8M0-8V8"/></g>`;
|
||||
}
|
||||
|
||||
function box(x, y, width, height, lines) {
|
||||
const text = lines.map(([content, cls], i) =>
|
||||
`<text x="${x + 10}" y="${y + 22 + i * 20}" class="${cls ?? ""}" font-size="${cls ? 15.5 : 17}"
|
||||
font-weight="${cls ? 400 : 700}">${content}</text>`).join("");
|
||||
return `<rect class="box" x="${x}" y="${y}" width="${width}" height="${height}" rx="10"/>${text}`;
|
||||
}
|
||||
|
||||
export function schematic(view) {
|
||||
const { temperatures: t, pumps, state } = view;
|
||||
const heatsCo = state === "HEATING_CO" || state === "HEATING_BOTH";
|
||||
const heatsCwu = state === "HEATING_CWU" || state === "HEATING_BOTH";
|
||||
const compressor = view.compressorPercent == null ? "—" : `${number(view.compressorPercent)} %`;
|
||||
return `<svg class="schematic" viewBox="0 0 360 460" role="img"
|
||||
aria-label="Schemat instalacji: odwierty, pompa ciepła, bufor CO, dom i zbiornik CWU">
|
||||
<path class="pipe cold${on(pumps.ground)}" d="M150 78V130M210 78V130"/>
|
||||
<path class="pipe${on(heatsCo)}" d="M100 218V262M100 346V376"/>
|
||||
<path class="pipe${on(heatsCwu)}" d="M260 218V262"/>
|
||||
${box(15, 10, 330, 68, [["Odwierty (dolne źródło)"],
|
||||
[`Do pompy ${celsius(t.groundIn)}`, "sub"], [`Z pompy ${celsius(t.groundOut)}`, "sub"]])}
|
||||
${box(15, 130, 330, 90, [["Pompa ciepła"],
|
||||
[`Sprężarka ${compressor} · grzałka ${view.heaterOn ? "włączona" : "wyłączona"}`, "sub"],
|
||||
[`Wyjście pompy ${celsius(t.pumpOutlet)}`, "sub"]])}
|
||||
${box(15, 262, 165, 84, [["Bufor CO"], [celsius(t.coBuffer), "sub"], [`Cel ${celsius(view.targets.co)}`, "sub"]])}
|
||||
${box(190, 262, 155, 84, [["Zbiornik CWU"], [celsius(t.cwu), "sub"], [`Cel ${celsius(view.targets.cwu)}`, "sub"]])}
|
||||
${box(15, 376, 165, 70, [["Dom (grzejniki)"], [`Wewnątrz ${celsius(t.indoor)}`, "sub"], [`Cel ${celsius(view.targets.room)}`, "sub"]])}
|
||||
${box(190, 376, 155, 70, [["Kolektor"], ["na dachu", "sub"], ["bez czujnika", "sub"]])}
|
||||
<path class="pipe" d="M270 346V376" stroke-dasharray="3 7"/>
|
||||
${pump(210, 104, pumps.ground)}
|
||||
${pump(100, 240, pumps.buffer)}
|
||||
${pump(100, 361, pumps.co)}
|
||||
${pump(260, 240, pumps.cwu)}
|
||||
${pump(145, 361, pumps.circulation)}
|
||||
</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { getJson, postJson } from "./api.js";
|
||||
import { number, hour, dayAndHour } from "./format.js";
|
||||
|
||||
const NAMES = {
|
||||
cwu_economy: "Ciepła woda, oszczędnie",
|
||||
cwu_comfort: "Ciepła woda, komfort",
|
||||
co_economy: "Ogrzewanie domu, oszczędnie",
|
||||
co_comfort: "Ogrzewanie domu, komfort",
|
||||
};
|
||||
const DURATIONS = [
|
||||
{ hours: 1, label: "1 godz." }, { hours: 2, label: "2 godz." },
|
||||
{ hours: 4, label: "4 godz." }, { hours: null, label: "Do odwołania" },
|
||||
];
|
||||
const OFFLINE = "Dom nie odpowiada, spróbuj za chwilę.";
|
||||
const RESULT_TEXT = {
|
||||
REJECTED: "Pompa nie przyjęła tej zmiany. Nic nie zostało zmienione.",
|
||||
FAILED: "Nie wiadomo, czy zmiana doszła do pompy. Sprawdź wartości poniżej, zanim spróbujesz ponownie.",
|
||||
};
|
||||
|
||||
// Screen state outlives the 30 s refresh, so a half-made change is not lost.
|
||||
// draft: the one setpoint being changed; confirming: the summary is shown, waiting for "Tak".
|
||||
const state = { draft: null, hours: 2, preset: null, confirming: false, busy: false, result: null };
|
||||
|
||||
const name = (id) => NAMES[id] ?? id;
|
||||
const degrees = (value) => `${number(value, 1).replace(/,0$/, "")} °C`;
|
||||
const disabled = () => (state.busy ? " disabled" : "");
|
||||
const find = (data, id) => data.setpoints.find((s) => s.name === id);
|
||||
|
||||
function when(iso) {
|
||||
const date = new Date(iso);
|
||||
return date.toDateString() === new Date().toDateString() ? hour(date) : dayAndHour(date);
|
||||
}
|
||||
|
||||
function restores(data) {
|
||||
if (!data.restores.length) return "";
|
||||
const lines = data.restores.map((r) =>
|
||||
`<p>${name(r.setpoint)}: <b>${degrees(r.appliedValue)}</b> do ${when(r.due)}, potem <b>${degrees(r.originalValue)}</b>.</p>`);
|
||||
return `<section class="card"><h2>Zmiany czasowe</h2>${lines.join("")}</section>`;
|
||||
}
|
||||
|
||||
function resultBox() {
|
||||
const r = state.result;
|
||||
if (!r) return "";
|
||||
if (r.status === "APPLIED") {
|
||||
const back = r.restoreAt ? ` Wrócimy do poprzedniej wartości o ${when(r.restoreAt)}.` : "";
|
||||
return `<div class="banner ok" role="status">Zapisano.${back}</div>`;
|
||||
}
|
||||
const detail = r.message ? `<br><span class="detail">${r.message}</span>` : "";
|
||||
return `<div class="banner bad" role="alert">${r.text ?? RESULT_TEXT[r.status]}${detail}</div>`;
|
||||
}
|
||||
|
||||
function confirmButtons(yes, action) {
|
||||
return `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="${action}"${disabled()}>${yes}</button></div>`;
|
||||
}
|
||||
|
||||
function presets(data) {
|
||||
const buttons = data.presets.map((p) =>
|
||||
`<button class="big" data-preset="${p.id}"${disabled()}>${p.label}</button>`).join("");
|
||||
return `<section class="card"><h2>Szybkie ustawienia</h2><div class="stack">${buttons}</div></section>`;
|
||||
}
|
||||
|
||||
function presetConfirm(data) {
|
||||
const preset = data.presets.find((p) => p.id === state.preset);
|
||||
const effect = preset.description ? `<p class="lead">${preset.description}</p>` : "";
|
||||
return `<section class="card"><h2>${preset.label}</h2>${effect}<p>Potwierdzasz?</p>
|
||||
${confirmButtons("Tak, ustaw", "preset-ok")}</section>`;
|
||||
}
|
||||
|
||||
function stepper(s) {
|
||||
const shown = state.draft?.name === s.name ? state.draft.value : s.value;
|
||||
const label = name(s.name);
|
||||
return `<div class="setpoint${shown === s.value ? "" : " changed"}"><span class="name">${label}</span>
|
||||
<div class="step">
|
||||
<button data-step="-1" data-name="${s.name}" aria-label="Mniej: ${label}"${disabled()}${shown <= s.min ? " disabled" : ""}>−</button>
|
||||
<output>${degrees(shown)}</output>
|
||||
<button data-step="1" data-name="${s.name}" aria-label="Więcej: ${label}"${disabled()}${shown >= s.max ? " disabled" : ""}>+</button>
|
||||
</div><span class="muted">od ${degrees(s.min)} do ${degrees(s.max)}</span></div>`;
|
||||
}
|
||||
|
||||
function saveBox(data) {
|
||||
const d = state.draft;
|
||||
if (!d) return "";
|
||||
const s = find(data, d.name);
|
||||
const options = DURATIONS.map((o) =>
|
||||
`<button data-hours="${o.hours ?? ""}" aria-pressed="${o.hours === state.hours}"${disabled()}>${o.label}</button>`).join("");
|
||||
const action = state.confirming
|
||||
? `<p class="lead">${saveSummary(s)}</p>${confirmButtons("Tak, zapisz", "save-ok")}`
|
||||
: `<div class="seg"><button data-act="cancel"${disabled()}>Anuluj</button>
|
||||
<button class="primary" data-act="save"${disabled()}>Zapisz</button></div>`;
|
||||
return `<div class="savebox"><p class="lead">${name(d.name)}: z ${degrees(s.value)} na <b>${degrees(d.value)}</b></p>
|
||||
<p>Na jak długo?</p><div class="seg wrap">${options}</div>${action}</div>`;
|
||||
}
|
||||
|
||||
function saveSummary(s) {
|
||||
const value = degrees(state.draft.value);
|
||||
if (!state.hours) return `Ustawię ${value} do odwołania. Poprzednia wartość (${degrees(s.value)}) nie wróci sama.`;
|
||||
const back = when(new Date(Date.now() + state.hours * 3600000).toISOString());
|
||||
return `Ustawię ${value}. O ${back} wrócimy do ${degrees(s.value)}.`;
|
||||
}
|
||||
|
||||
function body(data) {
|
||||
if (state.preset) return resultBox() + presetConfirm(data);
|
||||
return `${resultBox()}${restores(data)}${presets(data)}
|
||||
<section class="card"><h2>Nastawy</h2>${data.setpoints.map(stepper).join("")}${saveBox(data)}</section>`;
|
||||
}
|
||||
|
||||
const view = (data) => `<div id="settings">${body(data)}</div>`;
|
||||
|
||||
function change(data, el) {
|
||||
const { step, name: id, hours, preset, act } = el.dataset;
|
||||
state.result = null;
|
||||
if (step) {
|
||||
const s = find(data, id);
|
||||
const from = state.draft?.name === id ? state.draft.value : s.value;
|
||||
const value = Math.min(s.max, Math.max(s.min, from + Number(step) * s.step));
|
||||
state.draft = value === s.value ? null : { name: id, value };
|
||||
state.confirming = false;
|
||||
} else if (hours !== undefined) {
|
||||
state.hours = hours === "" ? null : Number(hours);
|
||||
state.confirming = false;
|
||||
} else if (preset) {
|
||||
state.preset = preset;
|
||||
} else if (act === "cancel") {
|
||||
Object.assign(state, { draft: null, preset: null, confirming: false });
|
||||
} else if (act === "save") {
|
||||
state.confirming = true;
|
||||
}
|
||||
}
|
||||
|
||||
function bind(root, data, reload) {
|
||||
const box = root.querySelector("#settings");
|
||||
|
||||
async function send(url, payload) {
|
||||
state.busy = true;
|
||||
box.innerHTML = body(data);
|
||||
try {
|
||||
state.result = await postJson(url, payload);
|
||||
if (state.result.status === "APPLIED") state.draft = null;
|
||||
} catch {
|
||||
state.result = { status: "FAILED", text: OFFLINE };
|
||||
}
|
||||
Object.assign(state, { busy: false, preset: null, confirming: false });
|
||||
window.scrollTo(0, 0);
|
||||
reload();
|
||||
}
|
||||
|
||||
box.addEventListener("click", (event) => {
|
||||
const el = event.target.closest("button");
|
||||
if (!el || state.busy) return;
|
||||
const act = el.dataset.act;
|
||||
if (act === "save-ok") {
|
||||
const { name: setpoint, value } = state.draft;
|
||||
send("api/setpoints", { setpoint, value, hours: state.hours ?? undefined });
|
||||
} else if (act === "preset-ok") {
|
||||
send(`api/presets/${state.preset}`);
|
||||
} else {
|
||||
change(data, el);
|
||||
box.innerHTML = body(data);
|
||||
box.querySelector(".savebox")?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/setpoints"), view, bind };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getJson } from "./api.js";
|
||||
import { ago } from "./format.js";
|
||||
|
||||
const DEVICE_NAMES = {
|
||||
"heat-pump": "Pompa ciepła",
|
||||
"house-meter": "Licznik domu",
|
||||
bme280: "Czujnik pogody",
|
||||
deye: "Falownik Deye (bateria, PV)",
|
||||
fronius: "Falownik Fronius (PV)",
|
||||
};
|
||||
|
||||
const dot = (up) => `<span class="dot ${up ? "" : "down"}" aria-hidden="true"></span>`;
|
||||
|
||||
function device(d) {
|
||||
const last = d.lastSuccess ? `ostatni odczyt ${ago(d.lastSuccess)}` : "brak odczytu";
|
||||
const detail = d.up ? last : `nie odpowiada, ${last}`;
|
||||
return `<div><span>${dot(d.up)}${DEVICE_NAMES[d.name] ?? d.name}<br>
|
||||
<span class="muted">${detail}</span></span><b>${d.up ? "działa" : "brak"}</b></div>`;
|
||||
}
|
||||
|
||||
const devices = (data) => (data.devices.length
|
||||
? data.devices.map(device).join("")
|
||||
: `<p class="muted">Lista pojawi się, gdy dom odpowie.</p>`);
|
||||
|
||||
function view(data) {
|
||||
const gateway = data.gatewayReachable
|
||||
? `${dot(true)}Dom odpowiada` : `${dot(false)}Dom nie odpowiada`;
|
||||
return `<section class="card"><h2>Połączenie z domem</h2><p class="headline">${gateway}</p></section>
|
||||
<section class="card rows"><h2>Urządzenia</h2>${devices(data)}</section>`;
|
||||
}
|
||||
|
||||
export default { load: () => getJson("api/health"), view };
|
||||
@@ -0,0 +1,102 @@
|
||||
:root {
|
||||
--bg: #f1f5f2; --card: #ffffff; --text: #14201a; --muted: #4a5a52; --line: #cfd9d3;
|
||||
--accent: #0d6b45; --on-accent: #ffffff; --ok: #0d6b45; --warn: #8a5a00; --bad: #b3261e;
|
||||
--warn-bg: #fff1cc; --bad-bg: #fde0dd; --hot: #c8501a; --cold: #1b6fb5; --off: #8d9a93;
|
||||
--box: #e8f0eb; --ok-bg: #d9f2e4;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1713; --card: #19241e; --text: #f0f6f2; --muted: #b4c4ba; --line: #34463c;
|
||||
--accent: #4fcf94; --on-accent: #06210f; --ok: #4fcf94; --warn: #f2c14e; --bad: #ff8a80;
|
||||
--warn-bg: #3d3210; --bad-bg: #4a1f1c; --hot: #ff9a62; --cold: #6bb6f2; --off: #7b8b82;
|
||||
--box: #223229; --ok-bg: #153d2a;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font-size: 18px; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: system-ui, "Segoe UI", Roboto, sans-serif; line-height: 1.35;
|
||||
}
|
||||
main { max-width: 640px; margin: 0 auto; padding: 16px 12px 96px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
h2 { font-size: 1.05rem; margin: 0 0 8px; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; margin-bottom: 12px; }
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
.headline { font-size: 1.5rem; font-weight: 700; margin: 0 0 4px; }
|
||||
.banner { border-radius: 12px; padding: 12px 14px; margin-bottom: 12px; font-weight: 600; }
|
||||
.banner.bad { background: var(--bad-bg); color: var(--bad); border: 2px solid var(--bad); }
|
||||
.banner.warn { background: var(--warn-bg); color: var(--warn); border: 2px solid var(--warn); }
|
||||
.rows > div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--line); }
|
||||
.rows > div:first-child { border-top: 0; }
|
||||
.rows b { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.tile { background: var(--box); border-radius: 12px; padding: 12px; }
|
||||
.tile .big { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; margin-right: 8px; vertical-align: -1px; background: var(--ok); }
|
||||
.dot.down { background: var(--bad); }
|
||||
nav {
|
||||
position: fixed; left: 0; right: 0; bottom: 0; display: flex; background: var(--card);
|
||||
border-top: 1px solid var(--line); padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
nav button {
|
||||
flex: 1; min-height: 64px; border: 0; background: none; color: var(--muted); font: inherit;
|
||||
font-size: 0.7rem; font-weight: 600; white-space: nowrap; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 2px; padding: 4px 2px;
|
||||
}
|
||||
nav svg { width: 26px; height: 26px; fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-linecap: round; }
|
||||
nav button[aria-current="page"] { color: var(--accent); box-shadow: inset 0 3px 0 var(--accent); }
|
||||
button:focus-visible, select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
.seg { display: flex; gap: 8px; margin: 8px 0; }
|
||||
.seg button, select {
|
||||
flex: 1; min-height: 48px; border: 2px solid var(--line); background: var(--card); color: var(--text);
|
||||
border-radius: 12px; font: inherit; font-weight: 600;
|
||||
}
|
||||
select { width: 100%; padding: 0 10px; }
|
||||
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
||||
svg text { fill: var(--text); font-family: inherit; }
|
||||
svg .sub { fill: var(--muted); }
|
||||
.schematic { width: 100%; height: auto; display: block; }
|
||||
.box { fill: var(--box); stroke: var(--line); stroke-width: 1.5; }
|
||||
.pipe { stroke: var(--off); stroke-width: 5; fill: none; }
|
||||
.pipe.on { stroke: var(--hot); }
|
||||
.pipe.cold.on { stroke: var(--cold); }
|
||||
.pump { fill: var(--card); stroke: var(--off); stroke-width: 3; }
|
||||
.on .pump { stroke: var(--ok); }
|
||||
.spokes { stroke: var(--off); stroke-width: 3; transform-origin: center; transform-box: fill-box; }
|
||||
.on .spokes { stroke: var(--ok); animation: spin 2s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .on .spokes { animation: none; } }
|
||||
.flow { stroke: var(--accent); stroke-width: 4; }
|
||||
.chart text { font-size: 14.5px; }
|
||||
.chart [hidden] { display: none; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linejoin: round; }
|
||||
.chart .cursor { stroke: var(--muted); stroke-width: 1; }
|
||||
.chart .mark { fill: var(--accent); }
|
||||
.chart { touch-action: pan-y; width: 100%; height: auto; display: block; }
|
||||
.readout { min-height: 2.7em; font-weight: 600; }
|
||||
.banner.ok { background: var(--ok-bg); color: var(--ok); border: 2px solid var(--ok); }
|
||||
.detail { font-weight: 400; font-size: 0.85rem; }
|
||||
.lead { font-size: 1.1rem; font-weight: 600; margin: 8px 0; }
|
||||
.stack { display: grid; gap: 10px; }
|
||||
button.big, .seg button.primary {
|
||||
min-height: 64px; border: 2px solid var(--accent); border-radius: 12px; font: inherit;
|
||||
font-size: 1.1rem; font-weight: 700; background: var(--card); color: var(--accent);
|
||||
}
|
||||
.seg button.primary { background: var(--accent); color: var(--on-accent); }
|
||||
button:disabled { opacity: 0.45; }
|
||||
.seg.wrap { flex-wrap: wrap; }
|
||||
.seg.wrap button { flex: 1 1 40%; }
|
||||
.setpoint { padding: 10px 0; border-top: 1px solid var(--line); }
|
||||
.setpoint:first-of-type { border-top: 0; }
|
||||
.setpoint .name { display: block; font-weight: 600; }
|
||||
.setpoint.changed { border-left: 5px solid var(--accent); padding-left: 10px; }
|
||||
.step { display: flex; align-items: center; gap: 10px; margin: 6px 0 2px; }
|
||||
.step button {
|
||||
width: 64px; height: 64px; border: 2px solid var(--accent); border-radius: 12px; background: var(--card);
|
||||
color: var(--accent); font: inherit; font-size: 2rem; font-weight: 700; line-height: 1;
|
||||
}
|
||||
.step output { flex: 1; text-align: center; font-size: 1.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.savebox { border-top: 2px solid var(--accent); margin-top: 8px; padding-top: 8px; }
|
||||
.savebox { scroll-margin-bottom: 84px; }
|
||||
@@ -0,0 +1,193 @@
|
||||
package home.backend;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.adapters.http.ApiServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** The assembled backend against a stub gateway and a stub InfluxDB, both on localhost. */
|
||||
class BackendTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private HttpServer gateway;
|
||||
private HttpServer influx;
|
||||
private ApiServer backend;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
gateway = stub(Map.of(
|
||||
"/health", "src/test/resources/gateway/health-all-up.json",
|
||||
"/snapshot", "src/test/resources/gateway/snapshot-all-up.json"));
|
||||
influx = stub(Map.of("/query", "src/test/resources/influx/influx-query-cwu-6h.json"));
|
||||
var settings = new Settings(url(gateway), url(influx), 0, Duration.ofSeconds(2), Duration.ofSeconds(2));
|
||||
backend = Backend.assemble(settings, Clock.systemUTC());
|
||||
backend.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
backend.stop();
|
||||
gateway.stop(0);
|
||||
influx.stop(0);
|
||||
}
|
||||
|
||||
private static HttpServer stub(Map<String, String> bodies) throws IOException {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
bodies.forEach((path, file) -> server.createContext(path, exchange -> {
|
||||
byte[] body = Files.readAllBytes(Path.of(file));
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
}));
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
private static URI url(HttpServer server) {
|
||||
return URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
var request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + backend.port() + path)).build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** The names of every field, nested ones as a.b; array elements are not descended into. */
|
||||
private static TreeSet<String> fieldNames(JsonNode node, String prefix, TreeSet<String> names) {
|
||||
node.fieldNames().forEachRemaining(name -> {
|
||||
names.add(prefix + name);
|
||||
fieldNames(node.get(name), prefix + name + ".", names);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
private void assertServesApiShapeOf(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
JsonNode expected = JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json")));
|
||||
assertEquals(fieldNames(expected, "", new TreeSet<>()),
|
||||
fieldNames(JSON.readTree(response.body()), "", new TreeSet<>()), path);
|
||||
}
|
||||
|
||||
/** The body equals the documented example, value by value. */
|
||||
private void assertServesExactly(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode(), path);
|
||||
assertEquals(JSON.readTree(Files.readString(Path.of("src/test/resources/api/" + example + ".json"))),
|
||||
JSON.readTree(response.body()), path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHeatPumpFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesEnergyFromTheGatewayReadings() throws Exception {
|
||||
assertServesExactly("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHealthOfEveryGatewayDevice() throws Exception {
|
||||
assertServesApiShapeOf("/api/health", "health");
|
||||
JsonNode health = JSON.readTree(get("/api/health").body());
|
||||
|
||||
assertTrue(health.get("gatewayReachable").asBoolean());
|
||||
List<String> names = new ArrayList<>();
|
||||
health.get("devices").forEach(device -> {
|
||||
names.add(device.get("name").asText());
|
||||
assertTrue(device.get("up").asBoolean(), names.getLast());
|
||||
});
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"), names);
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesHistoryFromInflux() throws Exception {
|
||||
assertServesApiShapeOf("/api/history?series=cwu_temp&range=24h", "history");
|
||||
JsonNode points = JSON.readTree(get("/api/history?series=cwu_temp&range=24h").body()).get("points");
|
||||
assertFalse(points.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheRealWebUi() throws Exception {
|
||||
HttpResponse<String> index = get("/");
|
||||
HttpResponse<String> script = get("/app.js");
|
||||
|
||||
assertEquals(200, index.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", index.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals(Files.readString(Path.of("src/main/resources/web/index.html")), index.body());
|
||||
// Browsers refuse ES modules served with any other type.
|
||||
assertEquals(200, script.statusCode());
|
||||
assertEquals("text/javascript; charset=utf-8", script.headers().firstValue("Content-Type").orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsNeedBothUrls() {
|
||||
var e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("INFLUX_URL", "https://influx.example")));
|
||||
assertTrue(e.getMessage().contains("GATEWAY_URL"));
|
||||
e = assertThrows(IllegalArgumentException.class,
|
||||
() -> Settings.fromEnvironment(Map.of("GATEWAY_URL", "http://gw:8080")));
|
||||
assertTrue(e.getMessage().contains("INFLUX_URL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsHaveDefaults() {
|
||||
var settings = Settings.fromEnvironment(
|
||||
Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example"));
|
||||
|
||||
assertEquals(8080, settings.port());
|
||||
assertEquals(Duration.ofSeconds(5), settings.gatewayTimeout());
|
||||
assertEquals(Duration.ofSeconds(10), settings.influxTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsRejectBadValues() {
|
||||
var urls = Map.of("GATEWAY_URL", "http://gw:8080", "INFLUX_URL", "https://influx.example");
|
||||
for (Map<String, String> bad : List.of(
|
||||
Map.of("GATEWAY_URL", "gw:8080", "INFLUX_URL", "https://influx.example"),
|
||||
Map.of("GATEWAY_URL", "ftp://gw", "INFLUX_URL", "https://influx.example"))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(bad));
|
||||
}
|
||||
for (String name : List.of("PORT", "GATEWAY_TIMEOUT", "INFLUX_TIMEOUT")) {
|
||||
for (String value : List.of("abc", "-1", "0x10", "99999999")) {
|
||||
var env = new HashMap<>(urls);
|
||||
env.put(name, value);
|
||||
var e = assertThrows(IllegalArgumentException.class, () -> Settings.fromEnvironment(env), name + value);
|
||||
assertTrue(e.getMessage().contains(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package home.backend.adapters.gateway;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GatewayHttpClientTest {
|
||||
|
||||
private HttpServer server;
|
||||
private GatewayHttpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.start();
|
||||
client = new GatewayHttpClient(URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private void serve(String path, int status, String body) {
|
||||
server.createContext(path, exchange -> {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
private void serveFiles(int healthStatus, String healthFile, String snapshotFile) throws IOException {
|
||||
serve("/health", healthStatus, resource(healthFile));
|
||||
serve("/snapshot", 200, resource(snapshotFile));
|
||||
}
|
||||
|
||||
private static String resource(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/gateway", name));
|
||||
}
|
||||
|
||||
private static DeviceSnapshot device(List<DeviceSnapshot> devices, String name) {
|
||||
return devices.stream().filter(d -> d.name().equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsEveryDeviceWithItsMeasurements() throws Exception {
|
||||
serveFiles(200, "health-all-up.json", "snapshot-all-up.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertEquals(List.of("bme280", "deye", "fronius", "heat-pump", "house-meter"),
|
||||
devices.stream().map(DeviceSnapshot::name).toList());
|
||||
assertTrue(devices.stream().allMatch(DeviceSnapshot::up));
|
||||
DeviceSnapshot pump = device(devices, "heat-pump");
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), pump.lastSuccess());
|
||||
assertNull(pump.lastError());
|
||||
assertEquals(List.of("POMPA", "heat_pump"), pump.measurements().stream().map(Measurement::measurement).toList());
|
||||
var measurement = pump.measurements().get(1);
|
||||
assertEquals("Dom_Pompa", measurement.database());
|
||||
assertEquals(4.3, measurement.fields().get("outdoor_temp"), 1e-9);
|
||||
assertEquals(45.0, measurement.fields().get("setpoint_cwu_comfort"), 1e-9);
|
||||
assertEquals(Instant.parse("2026-10-23T22:26:00Z"), measurement.time());
|
||||
assertEquals(645.0, device(devices, "deye").measurements().get(0).fields().get("grid_power_w"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDownDeviceAnswering503IsStillASnapshot() throws Exception {
|
||||
serveFiles(503, "health-one-down.json", "snapshot-one-down.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
assertTrue(device(devices, "heat-pump").up());
|
||||
DeviceSnapshot deye = device(devices, "deye");
|
||||
assertFalse(deye.up());
|
||||
assertEquals("Connect timed out", deye.lastError());
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), deye.lastSuccess());
|
||||
// The last good reading stays available, with its own time.
|
||||
var last = deye.measurements().get(0);
|
||||
assertEquals(570.0, last.fields().get("load_power_w"));
|
||||
assertEquals(Instant.parse("2026-10-23T22:20:00Z"), last.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeviceThatNeverSucceededHasNoLastSuccessAndNoMeasurements() throws Exception {
|
||||
serveFiles(503, "health-never-succeeded.json", "snapshot-never-succeeded.json");
|
||||
|
||||
var devices = client.snapshot().devices();
|
||||
|
||||
var pump = device(devices, "heat-pump");
|
||||
assertFalse(pump.up());
|
||||
assertNull(pump.lastSuccess());
|
||||
assertEquals("Connection refused", pump.lastError());
|
||||
assertTrue(pump.measurements().isEmpty());
|
||||
assertTrue(device(devices, "deye").up());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresPropertiesItDoesNotKnow() throws Exception {
|
||||
serve("/health", 200, """
|
||||
{"status":"UP","version":"2","devices":[{"name":"x","status":"UP","lastSuccess":null,"lastError":null,"extra":1}]}""");
|
||||
serve("/snapshot", 200, """
|
||||
[{"name":"x","new":true,"readings":[{"database":"d","measurement":"m","tags":{},
|
||||
"fields":{"a":1.0},"time":"2026-10-24T10:00:00Z","unit":"C"}]}]""");
|
||||
|
||||
assertEquals(1, client.snapshot().devices().get(0).measurements().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenSnapshotAnswers503() throws Exception {
|
||||
serve("/health", 200, resource("health-all-up.json"));
|
||||
serve("/snapshot", 503, "[]");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnServerError() {
|
||||
serve("/health", 500, "{\"error\":\"boom\"}");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOnBadJson() {
|
||||
serve("/health", 200, "<html>not json</html>");
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsSlowerThanTheTimeout() {
|
||||
server.createContext("/health", exchange -> {
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException ignored) {
|
||||
// the client gave up; nothing to answer
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
assertThrows(HttpTimeoutException.class, client::snapshot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsWhenTheGatewayIsUnreachable() {
|
||||
server.stop(0);
|
||||
|
||||
assertThrows(IOException.class, client::snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package home.backend.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.application.EnergyService;
|
||||
import home.backend.application.GatewayClient;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ApiServerTest {
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Instant HEALTH_TIME = Instant.parse("2026-10-23T22:14:00Z");
|
||||
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private GatewaySnapshot snapshot;
|
||||
private boolean gatewayDown;
|
||||
private boolean historyDown;
|
||||
private ApiServer server;
|
||||
|
||||
@BeforeEach
|
||||
void start() throws IOException {
|
||||
snapshot = exampleSnapshot();
|
||||
GatewayClient gateway = () -> {
|
||||
if (gatewayDown) {
|
||||
throw new IOException("tunnel down");
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
HistoryRepository history = (series, from, to, max) -> {
|
||||
if (historyDown) {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(new Point(Instant.parse("2026-10-23T10:00:00Z"), 14.1),
|
||||
new Point(Instant.parse("2026-10-23T11:00:00Z"), 38.5),
|
||||
new Point(Instant.parse("2026-10-23T12:00:00Z"), 44.2));
|
||||
};
|
||||
Clock clock = Clock.fixed(HEALTH_TIME, ZoneOffset.UTC);
|
||||
server = new ApiServer(0, new HeatPumpService(gateway, clock), new EnergyService(gateway, clock),
|
||||
new HealthService(gateway, clock), new HistoryService(history, clock));
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
private static GatewaySnapshot exampleSnapshot() {
|
||||
Map<String, Double> pump = new HashMap<>(Map.of("outdoor_temp", 4.3, "indoor_temp", 21.2,
|
||||
"co_buffer_temp", 16.9, "cwu_temp", 13.6, "ground_in_temp", 16.0, "ground_out_temp", 14.3,
|
||||
"pump_outlet_temp", 19.7, "outdoor_avg_temp", 4.8, "target_room_temp", 18.0));
|
||||
pump.putAll(Map.of("target_co_temp", 31.0, "target_cwu_temp", 8.0, "pump_mode", 0.0,
|
||||
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
|
||||
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
|
||||
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
"grid_power_w", 645.0, "battery_power_w", -24.0, "battery_soc_pct", 99.0,
|
||||
"load_power_w", 570.0, "grid_bought_today_kwh", 16.8, "grid_sold_today_kwh", 22.3,
|
||||
"load_today_kwh", 24.2);
|
||||
return new GatewaySnapshot(List.of(
|
||||
new DeviceSnapshot("heat-pump", true, pumpTime, null,
|
||||
List.of(new Measurement("Dom_Pompa", "heat_pump", pump, energyTime))),
|
||||
new DeviceSnapshot("deye", false, null, "connect timed out",
|
||||
List.of(new Measurement("home", "deye", deye, energyTime))),
|
||||
new DeviceSnapshot("fronius", true, energyTime, null,
|
||||
List.of(new Measurement("home", "fronius",
|
||||
Map.of("pv_power_w", 0.0, "energy_day_wh", 4400.0), energyTime)))));
|
||||
}
|
||||
|
||||
private HttpResponse<String> get(String path) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
|
||||
.build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static JsonNode example(String name) throws IOException {
|
||||
try (InputStream in = ApiServerTest.class.getResourceAsStream("/api/" + name + ".json")) {
|
||||
return JSON.readTree(in);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertMatchesExample(String path, String example) throws Exception {
|
||||
HttpResponse<String> response = get(path);
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("application/json", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertEquals("no-store", response.headers().firstValue("Cache-Control").orElseThrow());
|
||||
assertEquals(example(example), JSON.readTree(response.body()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/heat-pump", "heat-pump");
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/energy", "energy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthMatchesExample() throws Exception {
|
||||
snapshot = new GatewaySnapshot(snapshot.devices().subList(0, 2));
|
||||
assertMatchesExample("/api/health", "health");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyMatchesExample() throws Exception {
|
||||
assertMatchesExample("/api/history?series=cwu_temp&range=24h", "history");
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreachableGatewayIs503WithJsonError() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
for (String path : List.of("/api/heat-pump", "/api/energy")) {
|
||||
HttpResponse<String> response = get(path);
|
||||
assertEquals(503, response.statusCode());
|
||||
assertEquals("gateway unreachable", JSON.readTree(response.body()).get("error").asText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthStaysOkWhenGatewayIsUnreachable() throws Exception {
|
||||
gatewayDown = true;
|
||||
|
||||
HttpResponse<String> response = get("/api/health");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(false, JSON.readTree(response.body()).get("gatewayReachable").asBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownSeriesOrRangeIs400() throws Exception {
|
||||
assertEquals(400, get("/api/history?series=nope").statusCode());
|
||||
assertEquals(400, get("/api/history").statusCode());
|
||||
assertEquals(400, get("/api/history?series=cwu_temp&range=30d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void influxFailureIs502() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/history?series=cwu_temp&range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiAcceptsOnlyGet() throws Exception {
|
||||
HttpRequest post = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + "/api/health"))
|
||||
.POST(HttpRequest.BodyPublishers.noBody()).build();
|
||||
|
||||
assertEquals(405, client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownApiPathIs404() throws Exception {
|
||||
assertEquals(404, get("/api/nothing").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesTheRealIndexAtRoot() throws Exception {
|
||||
HttpResponse<String> response = get("/");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("text/html; charset=utf-8", response.headers().firstValue("Content-Type").orElseThrow());
|
||||
assertTrue(response.body().contains("Dom Krosno"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void servesStaticFilesWithContentTypes() throws Exception {
|
||||
Map<String, String> types = Map.of(
|
||||
"/index.html", "text/html; charset=utf-8",
|
||||
"/test.css", "text/css; charset=utf-8",
|
||||
"/test.js", "text/javascript; charset=utf-8",
|
||||
"/test.svg", "image/svg+xml",
|
||||
"/test.png", "image/png",
|
||||
"/test.ico", "image/x-icon");
|
||||
|
||||
for (Map.Entry<String, String> entry : types.entrySet()) {
|
||||
HttpResponse<String> response = get(entry.getKey());
|
||||
assertEquals(200, response.statusCode(), entry.getKey());
|
||||
assertEquals(entry.getValue(), response.headers().firstValue("Content-Type").orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingUnknownTypeAndDirectoriesAre404() throws Exception {
|
||||
for (String path : List.of("/missing.css", "/sub/data.txt", "/sub", "/sub/", "/web")) {
|
||||
assertEquals(404, get(path).statusCode(), path);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pathTraversalIsRefused() throws Exception {
|
||||
// secret.css sits next to web/ on the test classpath
|
||||
for (String path : List.of("/../secret.css", "/%2e%2e/secret.css", "/sub/../../secret.css",
|
||||
"/%2e%2e%2fsecret.css", "/..%5csecret.css")) {
|
||||
assertEquals(404, rawGet(path), path);
|
||||
}
|
||||
}
|
||||
|
||||
/** The JDK client normalises dot segments, so send the request line as is. */
|
||||
private int rawGet(String path) throws IOException {
|
||||
try (Socket socket = new Socket("localhost", server.port())) {
|
||||
socket.getOutputStream().write(("GET " + path + " HTTP/1.1\r\nHost: localhost\r\n"
|
||||
+ "Connection: close\r\n\r\n").getBytes(StandardCharsets.US_ASCII));
|
||||
String statusLine = new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)
|
||||
.lines().findFirst().orElseThrow();
|
||||
return Integer.parseInt(statusLine.split(" ")[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InfluxHistoryRepositoryTest {
|
||||
|
||||
private static final Instant FROM = Instant.parse("2026-10-24T00:00:00Z");
|
||||
private static final Instant TO = Instant.parse("2026-10-24T06:00:00Z");
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(5);
|
||||
|
||||
private HttpServer server;
|
||||
private final List<Map<String, String>> requests = new ArrayList<>();
|
||||
private final Map<String, String> bodyByField = new HashMap<>();
|
||||
private int status = 200;
|
||||
private String defaultBody = "{\"results\":[{\"statement_id\":0}]}";
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
|
||||
server.createContext("/query", exchange -> {
|
||||
Map<String, String> params = params(exchange.getRequestURI().getRawQuery());
|
||||
requests.add(params);
|
||||
String body = bodyByField.entrySet().stream()
|
||||
.filter(e -> params.get("q").contains("\"" + e.getKey() + "\""))
|
||||
.map(Map.Entry::getValue).findFirst().orElse(defaultBody);
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksTheLegacyDatabaseForATemperatureWithQuotedFieldName() throws IOException {
|
||||
defaultBody = fixture("influx-query-cwu-6h.json");
|
||||
|
||||
List<Point> points = repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 360);
|
||||
|
||||
assertEquals(1, requests.size());
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("ms", requests.get(0).get("epoch"));
|
||||
assertEquals("SELECT mean(\"Temp cwu\") FROM \"POMPA\" WHERE time >= 1792800000000ms"
|
||||
+ " AND time < 1792821600000ms GROUP BY time(1m) fill(none)", requests.get(0).get("q"));
|
||||
assertEquals(7, points.size());
|
||||
assertEquals(new Point(Instant.ofEpochMilli(1792771200000L), 43.76666666666667), points.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksTheElectricityDatabaseForGridPower() throws IOException {
|
||||
repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72);
|
||||
|
||||
assertEquals("electricity", requests.get(0).get("db"));
|
||||
assertEquals("SELECT mean(\"total_power_active\") FROM \"PRAD2\" WHERE time >= 1792800000000ms"
|
||||
+ " AND time < 1792821600000ms GROUP BY time(5m) fill(none)", requests.get(0).get("q"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundsTheStepUpToWholeMinutes() throws IOException {
|
||||
repository(TIMEOUT).history(Series.LOAD_POWER, FROM, TO, 100);
|
||||
|
||||
assertTrue(requests.get(0).get("q").contains("GROUP BY time(4m)"), requests.get(0).get("q"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsPvPowerAcrossMeasurementsAndKeepsBucketsWithOnePart() throws IOException {
|
||||
bodyByField.put("pv1_power_w", series(1000, 100.0, 2000, 200.0));
|
||||
bodyByField.put("pv2_power_w", series(1000, 50.0));
|
||||
bodyByField.put("pv_power_w", series(2000, 5.0, 3000, 7.0));
|
||||
|
||||
List<Point> points = repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72);
|
||||
|
||||
assertEquals(3, requests.size());
|
||||
assertEquals(List.of(
|
||||
new Point(Instant.ofEpochMilli(1000), 150.0),
|
||||
new Point(Instant.ofEpochMilli(2000), 205.0),
|
||||
new Point(Instant.ofEpochMilli(3000), 7.0)), points);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sortsPointsByTime() throws IOException {
|
||||
defaultBody = series(3000, 3.0, 1000, 1.0, 2000, 2.0);
|
||||
|
||||
List<Point> points = repository(TIMEOUT).history(Series.BATTERY_SOC, FROM, TO, 72);
|
||||
|
||||
assertEquals(List.of(1000L, 2000L, 3000L), points.stream().map(p -> p.t().toEpochMilli()).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesAnEmptyList() throws IOException {
|
||||
defaultBody = fixture("influx-query-empty.json");
|
||||
|
||||
assertEquals(List.of(), repository(TIMEOUT).history(Series.PV_POWER, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void topLevelErrorIsAnIOException() throws IOException {
|
||||
defaultBody = fixture("influx-query-error.json");
|
||||
|
||||
IOException e = assertThrows(IOException.class,
|
||||
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
assertTrue(e.getMessage().contains("error parsing query"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void perResultErrorIsAnIOException() {
|
||||
defaultBody = "{\"results\":[{\"statement_id\":0,\"error\":\"database not found: electricity\"}]}";
|
||||
|
||||
IOException e = assertThrows(IOException.class,
|
||||
() -> repository(TIMEOUT).history(Series.GRID_POWER, FROM, TO, 72));
|
||||
assertTrue(e.getMessage().contains("database not found"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpErrorIsAnIOException() {
|
||||
status = 503;
|
||||
|
||||
IOException e = assertThrows(IOException.class,
|
||||
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
assertTrue(e.getMessage().contains("503"), e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badJsonIsAnIOException() {
|
||||
defaultBody = "<html>not json";
|
||||
|
||||
assertThrows(IOException.class, () -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void slowServerTimesOut() {
|
||||
server.removeContext("/query");
|
||||
server.createContext("/query", exchange -> {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> repository(Duration.ofMillis(200)).history(Series.CWU_TEMP, FROM, TO, 72));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnEmptyRange() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> repository(TIMEOUT).history(Series.CWU_TEMP, FROM, FROM, 72));
|
||||
}
|
||||
|
||||
private InfluxHistoryRepository repository(Duration timeout) {
|
||||
URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
return new InfluxHistoryRepository(new InfluxSettings(base, timeout));
|
||||
}
|
||||
|
||||
private static String series(Object... timeAndValue) {
|
||||
StringBuilder rows = new StringBuilder();
|
||||
for (int i = 0; i < timeAndValue.length; i += 2) {
|
||||
rows.append(i == 0 ? "" : ",").append('[').append(timeAndValue[i]).append(',')
|
||||
.append(timeAndValue[i + 1]).append(']');
|
||||
}
|
||||
return "{\"results\":[{\"statement_id\":0,\"series\":[{\"name\":\"m\",\"columns\":[\"time\",\"mean\"],"
|
||||
+ "\"values\":[" + rows + "]}]}]}";
|
||||
}
|
||||
|
||||
private static String fixture(String name) throws IOException {
|
||||
return Files.readString(Path.of("src/test/resources/influx", name));
|
||||
}
|
||||
|
||||
private static Map<String, String> params(String rawQuery) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
for (String pair : rawQuery.split("&")) {
|
||||
String[] kv = pair.split("=", 2);
|
||||
params.put(kv[0], URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.EnergyView;
|
||||
import home.backend.domain.GatewaySnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ServicesTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-10-24T12:00:00Z");
|
||||
private static final Instant READ = Instant.parse("2026-10-24T11:59:50Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
|
||||
private static DeviceSnapshot device(String name, String database, String measurement,
|
||||
Map<String, Double> fields) {
|
||||
return new DeviceSnapshot(name, true, READ, null,
|
||||
List.of(new Measurement(database, measurement, fields, READ)));
|
||||
}
|
||||
|
||||
private static GatewayClient gateway(DeviceSnapshot... devices) {
|
||||
return () -> new GatewaySnapshot(List.of(devices));
|
||||
}
|
||||
|
||||
private static GatewayClient unreachable() {
|
||||
return () -> {
|
||||
throw new IOException("tunnel down");
|
||||
};
|
||||
}
|
||||
|
||||
private static HeatPumpView heatPump(Map<String, Double> fields) throws IOException {
|
||||
return new HeatPumpService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", fields)), CLOCK)
|
||||
.current();
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpStateComesFromPumpMode() throws IOException {
|
||||
assertEquals(State.IDLE, heatPump(Map.of("pump_mode", 0.0)).state());
|
||||
assertEquals(State.HEATING_CO, heatPump(Map.of("pump_mode", 1.0)).state());
|
||||
assertEquals(State.HEATING_CWU, heatPump(Map.of("pump_mode", 2.0)).state());
|
||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
|
||||
"output_cwu", 100.0, "output_co", 0.0, "alarm", 4.0, "setpoint_cwu_economy", 8.0));
|
||||
|
||||
assertEquals(READ, view.time());
|
||||
assertEquals(66, view.compressorPercent());
|
||||
assertTrue(view.heaterOn());
|
||||
assertTrue(view.alarm());
|
||||
assertTrue(view.pumps().cwu());
|
||||
assertFalse(view.pumps().co());
|
||||
assertEquals(8, view.setpoints().cwuEconomy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMissingFieldsAreNull() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of());
|
||||
|
||||
assertNull(view.state());
|
||||
assertNull(view.compressorPercent());
|
||||
assertNull(view.temperatures().outdoor());
|
||||
assertNull(view.targets().cwu());
|
||||
assertNull(view.setpoints().coComfort());
|
||||
assertFalse(view.alarm());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpTemperaturesDropBinaryNoiseOfTheGatewayScaling() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("indoor_temp", 212 * 0.1, "target_co_temp", 310 * 0.1,
|
||||
"outdoor_temp", -43 * 0.1));
|
||||
|
||||
assertEquals(21.2, view.temperatures().indoor());
|
||||
assertEquals(31.0, view.targets().co());
|
||||
assertEquals(-4.3, view.temperatures().outdoor());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpIgnoresMeasurementFromAnotherDatabase() throws IOException {
|
||||
HeatPumpService service = new HeatPumpService(gateway(
|
||||
device("heat-pump", "other", "heat_pump", Map.of("pump_mode", 1.0))), CLOCK);
|
||||
|
||||
HeatPumpView view = service.current();
|
||||
|
||||
assertNull(view.state());
|
||||
assertEquals(NOW, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPropagatesGatewayFailure() {
|
||||
assertThrows(IOException.class, () -> new HeatPumpService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
private static EnergyView energy(DeviceSnapshot... devices) throws IOException {
|
||||
return new EnergyService(gateway(devices), CLOCK).current();
|
||||
}
|
||||
|
||||
private static final Map<String, Double> DEYE = Map.of("pv1_power_w", 1000.0, "pv2_power_w", 500.0,
|
||||
"pv_today_kwh", 10.0, "grid_power_w", -200.0, "battery_soc_pct", 80.0, "load_power_w", 900.0);
|
||||
private static final Map<String, Double> FRONIUS = Map.of("pv_power_w", 2000.0, "energy_day_wh", 4400.0);
|
||||
|
||||
@Test
|
||||
void energySumsPvOfBothInverters() throws IOException {
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE),
|
||||
device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(3500.0, view.pvPowerW());
|
||||
assertEquals(14.4, view.today().pvKwh());
|
||||
assertEquals(-200.0, view.gridPowerW());
|
||||
assertEquals(80.0, view.batterySocPct());
|
||||
assertNull(view.batteryPowerW());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutFroniusUsesDeyeOnly() throws IOException {
|
||||
EnergyView view = energy(device("deye", "home", "deye", DEYE));
|
||||
|
||||
assertEquals(1500.0, view.pvPowerW());
|
||||
assertEquals(10.0, view.today().pvKwh());
|
||||
assertEquals(READ, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutDeyeKeepsFroniusPvAndNullsTheRest() throws IOException {
|
||||
EnergyView view = energy(device("fronius", "home", "fronius", FRONIUS));
|
||||
|
||||
assertEquals(2000.0, view.pvPowerW());
|
||||
assertEquals(4.4, view.today().pvKwh());
|
||||
assertNull(view.gridPowerW());
|
||||
assertNull(view.today().loadKwh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyWithoutSourcesIsNullAtClockTime() throws IOException {
|
||||
EnergyView view = energy();
|
||||
|
||||
assertNull(view.pvPowerW());
|
||||
assertNull(view.today().pvKwh());
|
||||
assertEquals(NOW, view.time());
|
||||
}
|
||||
|
||||
@Test
|
||||
void energyPropagatesGatewayFailure() {
|
||||
assertThrows(IOException.class, () -> new EnergyService(unreachable(), CLOCK).current());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthListsDevices() {
|
||||
DeviceSnapshot down = new DeviceSnapshot("deye", false, null, "connect timed out", List.of());
|
||||
|
||||
HealthView view = new HealthService(gateway(device("heat-pump", "Dom_Pompa", "heat_pump", Map.of()),
|
||||
down), CLOCK).current();
|
||||
|
||||
assertTrue(view.gatewayReachable());
|
||||
assertEquals(NOW, view.time());
|
||||
assertEquals(2, view.devices().size());
|
||||
assertEquals("connect timed out", view.devices().get(1).lastError());
|
||||
assertFalse(view.devices().get(1).up());
|
||||
assertEquals(READ, view.devices().get(0).lastSuccess());
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthWithUnreachableGatewayHasNoDevices() {
|
||||
HealthView view = new HealthService(unreachable(), CLOCK).current();
|
||||
|
||||
assertFalse(view.gatewayReachable());
|
||||
assertTrue(view.devices().isEmpty());
|
||||
}
|
||||
|
||||
/** Remembers the request and answers with one point. */
|
||||
private static final class RecordingHistory implements HistoryRepository {
|
||||
Series series;
|
||||
Instant from;
|
||||
Instant to;
|
||||
int maxPoints;
|
||||
|
||||
@Override
|
||||
public List<Point> history(Series series, Instant from, Instant to, int maxPoints) {
|
||||
this.series = series;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.maxPoints = maxPoints;
|
||||
return List.of(new Point(from, 1.5));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyDefaultsToLast24Hours() throws IOException {
|
||||
RecordingHistory repository = new RecordingHistory();
|
||||
|
||||
HistoryView view = new HistoryService(repository, CLOCK).history("cwu_temp", null);
|
||||
|
||||
assertEquals("cwu_temp", view.series());
|
||||
assertEquals("°C", view.unit());
|
||||
assertEquals(1, view.points().size());
|
||||
assertEquals(Series.CWU_TEMP, repository.series);
|
||||
assertEquals(NOW, repository.to);
|
||||
assertEquals(NOW.minus(Duration.ofHours(24)), repository.from);
|
||||
assertEquals(300, repository.maxPoints);
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyOfSevenDays() throws IOException {
|
||||
RecordingHistory repository = new RecordingHistory();
|
||||
|
||||
new HistoryService(repository, CLOCK).history("pv_power", "7d");
|
||||
|
||||
assertEquals(NOW.minus(Duration.ofDays(7)), repository.from);
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRejectsUnknownSeriesAndRange() {
|
||||
HistoryService service = new HistoryService(new RecordingHistory(), CLOCK);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("nope", "24h"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history(null, "24h"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", "30d"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.history("cwu_temp", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyPropagatesRepositoryFailure() {
|
||||
HistoryService service = new HistoryService((series, from, to, max) -> {
|
||||
throw new IOException("influx down");
|
||||
}, CLOCK);
|
||||
|
||||
assertThrows(IOException.class, () -> service.history("cwu_temp", "24h"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class SeriesTest {
|
||||
|
||||
@Test
|
||||
void findsSeriesByItsId() {
|
||||
assertEquals(Optional.of(Series.CWU_TEMP), Series.fromId("cwu_temp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownIdFindsNothing() {
|
||||
assertEquals(Optional.empty(), Series.fromId("DROP MEASUREMENT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"pvPowerW": 0.0,
|
||||
"gridPowerW": 645.0,
|
||||
"batteryPowerW": -24.0,
|
||||
"batterySocPct": 99.0,
|
||||
"loadPowerW": 570.0,
|
||||
"today": { "pvKwh": 34.3, "gridBoughtKwh": 16.8, "gridSoldKwh": 22.3, "loadKwh": 24.2 }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"time": "2026-10-23T22:14:00Z",
|
||||
"gatewayReachable": true,
|
||||
"devices": [
|
||||
{ "name": "heat-pump", "up": true, "lastSuccess": "2026-10-23T22:13:35Z", "lastError": null },
|
||||
{ "name": "deye", "up": false, "lastSuccess": null, "lastError": "connect timed out" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"time": "2026-10-23T22:26:00Z",
|
||||
"state": "IDLE",
|
||||
"compressorPercent": 0,
|
||||
"heaterOn": false,
|
||||
"alarm": false,
|
||||
"pumps": { "co": false, "buffer": false, "circulation": false, "ground": false, "cwu": false },
|
||||
"temperatures": {
|
||||
"outdoor": 4.3, "indoor": 21.2, "coBuffer": 16.9, "cwu": 13.6,
|
||||
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
||||
},
|
||||
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"series": "cwu_temp",
|
||||
"unit": "°C",
|
||||
"points": [
|
||||
{ "t": "2026-10-23T10:00:00Z", "v": 14.1 },
|
||||
{ "t": "2026-10-23T11:00:00Z", "v": 38.5 },
|
||||
{ "t": "2026-10-23T12:00:00Z", "v": 44.2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"UP","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"DOWN","lastSuccess":null,"lastError":"Connection refused"},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"DOWN","devices":[{"name":"bme280","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"deye","status":"DOWN","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out"},{"name":"fronius","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"heat-pump","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null},{"name":"house-meter","status":"UP","lastSuccess":"2026-10-23T22:26:00Z","lastError":null}]}
|
||||
@@ -0,0 +1 @@
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
|
||||
@@ -0,0 +1 @@
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":null,"lastError":"Connection refused","readings":[]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T22:20:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,"total_line_current":2.55,"p1_power_active":55.94,"total_import_demand_power_active":452.07},"time":"2026-10-23T22:26:00Z"}]}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:20:00Z","lastError":"Connect timed out","readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:20:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_volLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"POMPA","columns":["time","mean"],"values":[[1792771200000,43.76666666666667],[1792774800000,42.40000000000001],[1792778400000,26.733333333333334],[1792782000000,19.15833333333333],[1792785600000,13.799999999999997],[1792789200000,13.58333333333333],[1792792800000,13.65]]}]}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"results":[{"statement_id":0}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"error parsing query: found SELEC, expected SELECT, DELETE, SHOW, CREATE, DROP, EXPLAIN, GRANT, REVOKE, ALTER, SET, KILL at line 1, char 1"}
|
||||
@@ -0,0 +1 @@
|
||||
secret
|
||||
@@ -0,0 +1 @@
|
||||
x
|
||||
@@ -0,0 +1 @@
|
||||
body{}
|
||||
@@ -0,0 +1 @@
|
||||
ICO
|
||||
@@ -0,0 +1 @@
|
||||
a
|
||||
@@ -0,0 +1 @@
|
||||
PNG
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"/>
|
||||
|
After Width: | Height: | Size: 42 B |
@@ -0,0 +1,29 @@
|
||||
# ADR-008: Backend and web UI stack
|
||||
|
||||
Status: proposed · 2026-10-24 (taken alone during the night of M1/M3 work; to confirm)
|
||||
|
||||
## Context
|
||||
|
||||
M3 needs the backend in Kraków (phone API, history from InfluxDB, live state from the gateway)
|
||||
and a mobile-first web UI served by it (ADR-004). ADR-001 asks for the least code that works;
|
||||
the gateway already shows the stack is enough (ADR-007).
|
||||
|
||||
## Decision
|
||||
|
||||
- **Backend**: a separate Maven project in `backend/`, Java 21, no framework: JDK
|
||||
`HttpServer` with virtual threads, JDK `HttpClient` for the gateway and InfluxDB (InfluxQL
|
||||
`/query`), Jackson. Layers as in the spec. One fat jar, run in Docker.
|
||||
- **API**: JSON under `/api/`: `heat-pump`, `energy`, `history?series=&range=`, `health`. The
|
||||
records in `home.backend.domain` and the examples in `backend/src/test/resources/api/` are
|
||||
the contract between backend and UI.
|
||||
- **Web UI**: static HTML, CSS and plain JavaScript in `backend/src/main/resources/web/`, no
|
||||
build step, no npm. Charts and the schematic are small hand-written SVG. Polish texts.
|
||||
- **History**: series are an allowlist (`Series`); temperatures come from the legacy
|
||||
`Dom_Pompa.POMPA` fields, which already hold years of data.
|
||||
- **Auth**: not in the app; Traefik in front decides (the login method is still open, M3).
|
||||
|
||||
## Consequences
|
||||
|
||||
- No JavaScript toolchain to install, update or audit; the UI is a few files a person can read.
|
||||
- Richer interactions later may need a library; revisit then (M6 decides native or not).
|
||||
- `scripts/check.sh` builds and tests both projects.
|
||||
+23
-22
@@ -5,42 +5,41 @@ Closed milestones shrink to one line here; details stay in git history and in de
|
||||
|
||||
## Done
|
||||
|
||||
- (none yet) · baseline `e345063`: read-only, sends 8 sensor values to InfluxDB every 300 s;
|
||||
no control of the controller. Runs on the Pi.
|
||||
- M0 Foundation · `v1.0.0`: `scripts/check.sh` gate in CI and pre-push, deploy with health check
|
||||
and rollback, `/ship` and `/close-milestone`. Baseline (`e345063`) still does the work: read-only,
|
||||
8 heat pump values to InfluxDB every 300 s.
|
||||
|
||||
## Current: M0 Foundation
|
||||
## Current: M1 Gateway: all readings
|
||||
|
||||
No behaviour change.
|
||||
Done when: a week on the Pi without manual action, Grafana unchanged. Existing measurements keep
|
||||
their names and fields; new data goes into new measurements (ADR-005). The heat pump stays at
|
||||
300 s polls while the Frisko app is still in use (MK01 takes one client).
|
||||
|
||||
- [x] Docs structure: `CLAUDE.md`, `CONTRIBUTING.md`, `docs/`
|
||||
- [x] `scripts/check.sh`: build, tests, doc size limits, secret scan
|
||||
- [x] Git `pre-push` hook and CI both run `scripts/check.sh`; remove `.drone.yml` and `Dockerfile`
|
||||
- [ ] Deploy: `scripts/deploy-gateway.sh` copies the jar to the Pi, restarts, checks `/health`,
|
||||
rolls back on failure. Written and tested against stubs; open until its first run on the Pi
|
||||
- [x] Skills: `/ship`, `/close-milestone`
|
||||
|
||||
## Feasibility checks (after M0, before M1)
|
||||
|
||||
- [x] C1 Gateway sharing: MK01 takes one client; our poll drops my parents' Frisko app.
|
||||
- [x] C2 Frisko app is used rarely: keep 300 s polls until our UI replaces it, then the backend
|
||||
is the only client, with a "release gateway" switch for the app.
|
||||
- [x] C5 Parents raise the economy temperature (CWU 4143, CO 4131) to force heating; no tariff.
|
||||
- [x] C6 Energy sources readable locally: Deye via Solarman logger, Fronius Solar API, house meter.
|
||||
- [x] C3 Backup of the 14 app-editable settings: `research/frisko/settings-backup-2026-10-23.json`.
|
||||
- [x] C4 Write works: FC6 4143 8 → 9 → 8 (my request), echo OK, read-back after 1 s, no password.
|
||||
- [ ] Skeleton: `Reading`, ports `Device`, `ReadingSink`, `ModbusClient`, `ModbusTransport`
|
||||
- [ ] Modbus core (ADR-007): FC3/FC4 codec, TCP transport, Solarman V5 transport
|
||||
- [ ] Heat pump: legacy `Dom_Pompa.POMPA` fields unchanged; 4112–4119, 4201–4203, 4221–4228
|
||||
and the setpoints into `heat_pump`
|
||||
- [ ] House meter SDM630 (`electricity.PRAD2`) and BME280 via IIO (`measurement.MEASUREMENT`)
|
||||
- [ ] Deye and Fronius into `electricity.deye`, `electricity.fronius`
|
||||
- [ ] Influx writer: line protocol over JDK `HttpClient`, file buffer (fixes D2, D8)
|
||||
- [ ] Poller: one virtual thread per device, snapshot, watchdog without `/health` polling (D1)
|
||||
- [ ] HTTP: `/health` per device, `/snapshot`
|
||||
- [ ] Config file outside the jar (D3), wire `Main`, drop modbus4j, influxdb-java and the
|
||||
baseline classes (D4, D5)
|
||||
- [ ] On the Pi, each step approved: D7, config file and unit, IIO overlay and reboot, stop
|
||||
`rpi-measurements` before the gateway polls the meter, deploy, then retire it
|
||||
|
||||
## Next
|
||||
|
||||
| Milestone | Scope | Done when |
|
||||
|---|---|---|
|
||||
| M1 Gateway: all readings | Fix D7 first. Baseline becomes the gateway: heat pump incl. state registers (4112–4119, 4201–4203, 4221–4228), Deye, Fronius, house meter and BME280 (moved from `rpi-measurements`, same schemas; then retire it) into Influx with a buffer; snapshot; config outside the jar; fix D1–D5 | A week on the Pi without manual action, Grafana unchanged |
|
||||
| M2 Understand the controller | Timing (safe poll rate, response times), which pumps run in which state, open questions in `docs/frisko/` | `docs/frisko/` reviewed; F3 limits agreed |
|
||||
| M3 Backend and web UI, read-only | Backend in Docker in Kraków: history from Influx, snapshot from the gateway; F1, F2, F5, F7 with the schematic and charts after `research/ui/`; CI builds its image; Traefik route and auth (decide the login method first) | My father sees the heat pump and the energy on his phone |
|
||||
| M4 Setpoints | F3 through gateway commands with timed restore (ADR-006); CWU first, then CO; S1–S6 | Father boosts hot water from his phone, value restored on time |
|
||||
| M5 Alarms | F4; decide the channel first (e.g. ntfy, Telegram) | Simulated alarm reaches the phone |
|
||||
| M6 Native app? | Decide: keep the web UI, WebView wrapper or React Native | Decision recorded |
|
||||
|
||||
## Known defects in the baseline (input for M1)
|
||||
## Known defects in the baseline
|
||||
|
||||
- D1 Self-restart works only while something polls `/health` (`HealthMonitor`).
|
||||
- D2 Influx client logs every request (`LogLevel.FULL`), ~420 journal lines/hour.
|
||||
@@ -48,6 +47,8 @@ No behaviour change.
|
||||
- D4 Dead code and config: `pollIntervalMs`, `windowSeconds`, `ModbusCachedWrapper`, write path.
|
||||
- D5 Single and batch reads use different addresses for registers ≥ 4100 (`ModbusRtuTcpRepository`).
|
||||
- D6 Development runs wrote test fields into the production `Dom_Pompa` database.
|
||||
- D8 Each field is written as its own point with its own timestamp, so one poll can split into
|
||||
points 1 ms apart (`InfluxMetricModbusSender`); write one point per poll.
|
||||
- D7 Pi network: all 880 DNS errors of the week came in one hour (19.10, 10:00), likely an outage,
|
||||
not config. But the Pi's WireGuard routes most of `192.168.0.0/24` into the tunnel (Kraków uses
|
||||
the same subnet), so it reaches only `.10` and `.150` locally. Fix: Pi `AllowedIPs` only
|
||||
|
||||
@@ -92,6 +92,10 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<configuration>
|
||||
<!-- Test logs go to target/surefire-reports; a failure still prints its summary. -->
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"address": "192.168.0.150",
|
||||
"port": 502,
|
||||
"useMock": false,
|
||||
"pollIntervalMs": 30000,
|
||||
"windowSeconds": 300,
|
||||
"influxSendIntervalSeconds": 300,
|
||||
"timeoutMs": 5000,
|
||||
"maxRetries": 2,
|
||||
"retryBackoffMs": 1000,
|
||||
"influx_db": {
|
||||
"address": "https://influxdb.mariuszpacek.pl",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"registry_mapping": [
|
||||
{
|
||||
"registry": 4065,
|
||||
"desc": "External temperature",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4070,
|
||||
"desc": "Temp in",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4071,
|
||||
"desc": "Temp out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4072,
|
||||
"desc": "Temp pc out",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4067,
|
||||
"desc": "Temp co",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4069,
|
||||
"desc": "Temp cwu",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4066,
|
||||
"desc": "Temp wew",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
},
|
||||
{
|
||||
"registry": 4199,
|
||||
"desc": "Temp zsr",
|
||||
"unitId": 1,
|
||||
"scale": 0.1,
|
||||
"dataType": "TWO_BYTE_INT_SIGNED",
|
||||
"signedLegacyWrap": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,4 +55,5 @@ if (( failed )); then
|
||||
fi
|
||||
|
||||
mvn -B -q clean package
|
||||
mvn -B -q -f backend/pom.xml clean package
|
||||
echo "check: OK"
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Health check and restart script for home-pump service
|
||||
# Run this via cron every 2 minutes: */2 * * * * /home/pi/home-pump/scripts/healthcheck-monitor.sh
|
||||
|
||||
SERVICE_NAME="home-pump.service"
|
||||
HEALTH_ENDPOINT="http://localhost:8080/health"
|
||||
LOG_FILE="/var/log/home-pump-monitor.log"
|
||||
MAX_RESTART_ATTEMPTS=3
|
||||
RESTART_WINDOW_SECONDS=600
|
||||
|
||||
RESTART_COUNT_FILE="/tmp/home-pump-restart-count"
|
||||
RESTART_TIMESTAMP_FILE="/tmp/home-pump-restart-timestamp"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Function to check if service is running
|
||||
is_service_running() {
|
||||
systemctl is-active --quiet "$SERVICE_NAME"
|
||||
}
|
||||
|
||||
# Function to get restart count in the current window
|
||||
get_restart_count() {
|
||||
if [ -f "$RESTART_COUNT_FILE" ]; then
|
||||
cat "$RESTART_COUNT_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get last restart timestamp
|
||||
get_last_restart_time() {
|
||||
if [ -f "$RESTART_TIMESTAMP_FILE" ]; then
|
||||
cat "$RESTART_TIMESTAMP_FILE"
|
||||
else
|
||||
echo "0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to reset restart counter if window expired
|
||||
reset_counter_if_expired() {
|
||||
local last_restart=$(get_last_restart_time)
|
||||
local now=$(date +%s)
|
||||
local diff=$((now - last_restart))
|
||||
|
||||
if [ "$diff" -gt "$RESTART_WINDOW_SECONDS" ]; then
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to increment restart counter
|
||||
increment_restart_count() {
|
||||
local count=$(get_restart_count)
|
||||
count=$((count + 1))
|
||||
echo "$count" > "$RESTART_COUNT_FILE"
|
||||
date +%s > "$RESTART_TIMESTAMP_FILE"
|
||||
}
|
||||
|
||||
# Check if service is running
|
||||
if ! is_service_running; then
|
||||
log "ERROR: Service $SERVICE_NAME is not running. Attempting to start..."
|
||||
sudo systemctl start "$SERVICE_NAME"
|
||||
sleep 5
|
||||
if is_service_running; then
|
||||
log "SUCCESS: Service restarted successfully"
|
||||
else
|
||||
log "CRITICAL: Failed to start service $SERVICE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check health endpoint
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
log "WARNING: Health check failed with HTTP code: $HTTP_CODE"
|
||||
|
||||
# Reset counter if restart window expired
|
||||
reset_counter_if_expired
|
||||
|
||||
# Check if we can restart
|
||||
restart_count=$(get_restart_count)
|
||||
|
||||
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
|
||||
log "CRITICAL: Maximum restart attempts ($MAX_RESTART_ATTEMPTS) reached in the last $RESTART_WINDOW_SECONDS seconds. Manual intervention required."
|
||||
# Send alert (could be email, Pushover, etc.)
|
||||
# Example: echo "Home pump service needs manual intervention" | mail -s "Critical: Home Pump Alert" admin@example.com
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Attempting to restart service (attempt $((restart_count + 1))/$MAX_RESTART_ATTEMPTS)..."
|
||||
increment_restart_count
|
||||
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
sleep 10
|
||||
|
||||
# Verify restart
|
||||
if is_service_running; then
|
||||
NEW_HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_ENDPOINT" --max-time 10)
|
||||
if [ "$NEW_HTTP_CODE" == "200" ]; then
|
||||
log "SUCCESS: Service restarted and health check passed"
|
||||
else
|
||||
log "WARNING: Service restarted but health check still failing (HTTP $NEW_HTTP_CODE)"
|
||||
fi
|
||||
else
|
||||
log "ERROR: Failed to restart service"
|
||||
fi
|
||||
else
|
||||
# Health check passed, reset counter
|
||||
echo "0" > "$RESTART_COUNT_FILE"
|
||||
fi
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# DEPRECATED: This script is no longer used
|
||||
#
|
||||
# Previously this script was used with cron to monitor and restart the service.
|
||||
# Now systemd handles all restart logic automatically.
|
||||
#
|
||||
# The application monitors health and exits with status 1 when failures exceed threshold,
|
||||
# causing systemd to automatically restart the service (Restart=on-failure).
|
||||
#
|
||||
# This approach is simpler and doesn't require external monitoring.
|
||||
# The service unit (home-pump.service) handles all restart logic.
|
||||
|
||||
echo "This script is deprecated. Systemd automat handles service restarts."
|
||||
exit 0
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Installation script for home-pump service on Raspberry Pi/Ubuntu
|
||||
|
||||
set -e
|
||||
|
||||
echo "Home Pump Service Installation Script"
|
||||
echo "======================================"
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/home/pi/home-pump"
|
||||
SERVICE_FILE="scripts/home-pump.service"
|
||||
JAR_FILE="target/home-pump-1.0.0.jar"
|
||||
|
||||
# Check if project directory exists
|
||||
if [ ! -d "$PROJECT_DIR" ]; then
|
||||
echo "Error: Project directory $PROJECT_DIR not found"
|
||||
echo "Please adjust PROJECT_DIR in this script or create the directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if JAR file exists
|
||||
if [ ! -f "$JAR_FILE" ]; then
|
||||
echo "Error: JAR file not found at $JAR_FILE"
|
||||
echo "Please build the project first using: mvn clean package"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
echo "Installing systemd service..."
|
||||
cp "$SERVICE_FILE" /etc/systemd/system/home-pump.service
|
||||
systemctl daemon-reload
|
||||
echo "✓ Systemd service installed"
|
||||
|
||||
# Enable service to start on boot
|
||||
systemctl enable home-pump.service
|
||||
echo "✓ Service enabled to start on boot"
|
||||
|
||||
# Start the service
|
||||
systemctl start home-pump.service
|
||||
echo "✓ Service started"
|
||||
|
||||
# Wait a bit and check status
|
||||
sleep 3
|
||||
if systemctl is-active --quiet home-pump.service; then
|
||||
echo ""
|
||||
echo "✓✓✓ Installation completed successfully! ✓✓✓"
|
||||
echo ""
|
||||
echo "Service status:"
|
||||
systemctl status home-pump.service --no-pager -l
|
||||
echo ""
|
||||
echo "How it works:"
|
||||
echo " - Application monitors health internally"
|
||||
echo " - On consecutive failures (default 3), app exits with error code"
|
||||
echo " - Systemd automatically restarts the service (Restart=on-failure)"
|
||||
echo " - No external monitoring script needed"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " - View logs: journalctl -u home-pump.service -f"
|
||||
echo " - Check status: systemctl status home-pump.service"
|
||||
echo " - Restart service: sudo systemctl restart home-pump.service"
|
||||
echo " - Stop service: sudo systemctl stop home-pump.service"
|
||||
echo " - Health check: curl http://localhost:8080/health"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ Warning: Service installed but not running properly"
|
||||
echo "Check logs with: journalctl -u home-pump.service -n 50"
|
||||
fi
|
||||
@@ -1,31 +0,0 @@
|
||||
const http = require('http');
|
||||
const data = JSON.stringify({value: 60});
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 7000,
|
||||
path: '/api/registry/4202/write',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log('STATUS', res.statusCode);
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => console.log('BODY', chunk));
|
||||
res.on('end', () => {
|
||||
// fetch registries
|
||||
http.get('http://localhost:7000/api/registries', (res2) => {
|
||||
console.log('REG STATUS', res2.statusCode);
|
||||
let body = '';
|
||||
res2.setEncoding('utf8');
|
||||
res2.on('data', c => body += c);
|
||||
res2.on('end', () => console.log('REG BODY', body));
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => console.error('REQ ERR', e));
|
||||
req.write(data);
|
||||
req.end();
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Quick test script for local development
|
||||
|
||||
echo "Testing health endpoint..."
|
||||
curl -v http://localhost:8080/health
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "Expected response when healthy:"
|
||||
echo '{"status":"UP","lastSuccessfulRead":"2026-02-06T19:30:45Z","message":"Last successful read 15 seconds ago"}'
|
||||
|
||||
echo ""
|
||||
echo "Expected response when unhealthy (503):"
|
||||
echo '{"status":"DOWN","lastSuccessfulRead":"2026-02-06T19:20:45Z","message":"No successful read for 620 seconds. Last error: Connection timeout"}'
|
||||
@@ -0,0 +1,83 @@
|
||||
package home.gateway.adapters.bme280;
|
||||
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The BME280 through the kernel IIO driver. The driver exposes only processed values
|
||||
* ({@code in_*_input}), never raw and scale: temperature in milli-degC, pressure in kPa and
|
||||
* relative humidity in milli-%RH.
|
||||
*/
|
||||
public final class Bme280Device implements Device {
|
||||
|
||||
public static final Path DEFAULT_BASE_DIR = Path.of("/sys/bus/iio/devices");
|
||||
|
||||
private static final String DRIVER_NAME = "bme280";
|
||||
private static final Map<String, String> TAGS = Map.of("location", "dom-szczepancowa");
|
||||
private static final double HPA_PER_KPA = 10;
|
||||
private static final double MILLI = 1000;
|
||||
|
||||
private final Path baseDir;
|
||||
|
||||
public Bme280Device(Path baseDir) {
|
||||
this.baseDir = baseDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "bme280";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return Duration.ofSeconds(300);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
Path device = findDevice();
|
||||
Map<String, Double> fields = Map.of(
|
||||
"temperature", round(value(device, "in_temp_input") / MILLI),
|
||||
"pressure", round(value(device, "in_pressure_input") * HPA_PER_KPA),
|
||||
"humidity", round(value(device, "in_humidityrelative_input") / MILLI));
|
||||
return List.of(new Reading("measurement", "MEASUREMENT", TAGS, fields, now));
|
||||
}
|
||||
|
||||
private Path findDevice() throws IOException {
|
||||
try (Stream<Path> entries = Files.list(baseDir)) {
|
||||
for (Path entry : entries.sorted().toList()) {
|
||||
if (isBme280(entry)) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IOException("no IIO device named " + DRIVER_NAME + " under " + baseDir);
|
||||
}
|
||||
|
||||
private static boolean isBme280(Path entry) throws IOException {
|
||||
Path nameFile = entry.resolve("name");
|
||||
return Files.isRegularFile(nameFile) && Files.readString(nameFile).strip().equals(DRIVER_NAME);
|
||||
}
|
||||
|
||||
private static double value(Path device, String file) throws IOException {
|
||||
String text = Files.readString(device.resolve(file)).strip();
|
||||
try {
|
||||
return Double.parseDouble(text);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IOException(file + " is not a number: '" + text + "'", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static double round(double value) {
|
||||
return Math.round(value * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package home.gateway.adapters.deye;
|
||||
|
||||
import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.DoubleUnaryOperator;
|
||||
|
||||
/** The Deye hybrid inverter; registers and scales are verified in research/energy/README.md. */
|
||||
public final class DeyeDevice implements Device {
|
||||
|
||||
private static final int UNIT_ID = 1;
|
||||
|
||||
private record Block(int address, int count) {
|
||||
}
|
||||
|
||||
private record Field(String name, int register, DoubleUnaryOperator decode) {
|
||||
}
|
||||
|
||||
/** One request per block, in this order; the serial (3–7) is identity only and is skipped. */
|
||||
private static final List<Block> BLOCKS = List.of(
|
||||
new Block(514, 2), new Block(520, 2), new Block(526, 1), new Block(529, 1),
|
||||
new Block(586, 3), new Block(590, 1), new Block(598, 3), new Block(622, 4),
|
||||
new Block(650, 4), new Block(672, 2));
|
||||
|
||||
private static final List<Field> FIELDS = List.of(
|
||||
new Field("battery_charge_today_kwh", 514, scaled(10)),
|
||||
new Field("battery_discharge_today_kwh", 515, scaled(10)),
|
||||
new Field("grid_bought_today_kwh", 520, scaled(10)),
|
||||
new Field("grid_sold_today_kwh", 521, scaled(10)),
|
||||
new Field("load_today_kwh", 526, scaled(10)),
|
||||
new Field("pv_today_kwh", 529, scaled(10)),
|
||||
new Field("battery_temperature_c", 586, x -> (x - 1000) / 10),
|
||||
new Field("battery_voltage_v", 587, scaled(100)),
|
||||
new Field("battery_soc_pct", 588, scaled(1)),
|
||||
new Field("battery_power_w", 590, DeyeDevice::signed),
|
||||
new Field("grid_voltage_l1_v", 598, scaled(10)),
|
||||
new Field("grid_voltage_l2_v", 599, scaled(10)),
|
||||
new Field("grid_voltage_l3_v", 600, scaled(10)),
|
||||
new Field("grid_power_l1_w", 622, DeyeDevice::signed),
|
||||
new Field("grid_power_l2_w", 623, DeyeDevice::signed),
|
||||
new Field("grid_power_l3_w", 624, DeyeDevice::signed),
|
||||
new Field("grid_power_w", 625, DeyeDevice::signed),
|
||||
new Field("load_power_l1_w", 650, scaled(1)),
|
||||
new Field("load_power_l2_w", 651, scaled(1)),
|
||||
new Field("load_power_l3_w", 652, scaled(1)),
|
||||
new Field("load_power_w", 653, scaled(1)),
|
||||
new Field("pv1_power_w", 672, scaled(1)),
|
||||
new Field("pv2_power_w", 673, scaled(1)));
|
||||
|
||||
private final ModbusClient modbus;
|
||||
|
||||
public DeyeDevice(ModbusClient modbus) {
|
||||
this.modbus = modbus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "deye";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return Duration.ofSeconds(60);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
Map<Integer, Integer> words = readBlocks();
|
||||
Map<String, Double> fields = new LinkedHashMap<>();
|
||||
for (Field field : FIELDS) {
|
||||
fields.put(field.name(), field.decode().applyAsDouble(words.get(field.register())));
|
||||
}
|
||||
return List.of(new Reading("electricity", "deye", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
}
|
||||
|
||||
private Map<Integer, Integer> readBlocks() throws IOException {
|
||||
Map<Integer, Integer> words = new HashMap<>();
|
||||
for (Block block : BLOCKS) {
|
||||
int[] values = modbus.readHoldingRegisters(UNIT_ID, block.address(), block.count());
|
||||
if (values.length != block.count()) {
|
||||
throw new IOException("Deye block " + block.address() + " returned "
|
||||
+ values.length + " registers, expected " + block.count());
|
||||
}
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
words.put(block.address() + i, values[i]);
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
private static DoubleUnaryOperator scaled(int divisor) {
|
||||
return x -> x / divisor;
|
||||
}
|
||||
|
||||
private static double signed(double word) {
|
||||
return (short) (int) word;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package home.gateway.adapters.fronius;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fronius Symo over the local Solar API V1. One GET of the power flow gives PV power and the
|
||||
* day, year and total energy, so the inverter endpoint is not needed.
|
||||
*/
|
||||
public final class FroniusDevice implements Device {
|
||||
|
||||
private static final String POWER_FLOW_PATH = "/solar_api/v1/GetPowerFlowRealtimeData.fcgi";
|
||||
|
||||
/** Every value can be null while the inverter sleeps, so each one is optional. */
|
||||
private static final Map<String, String> VALUE_FIELDS = Map.of(
|
||||
"P_PV", "pv_power_w", "E_Day", "energy_day_wh",
|
||||
"E_Year", "energy_year_wh", "E_Total", "energy_total_wh");
|
||||
|
||||
private final HttpClient http = HttpClient.newHttpClient();
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final URI powerFlow;
|
||||
private final Duration timeout;
|
||||
|
||||
public FroniusDevice(URI baseUri, Duration timeout) {
|
||||
this.powerFlow = baseUri.resolve(POWER_FLOW_PATH);
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "fronius";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return Duration.ofSeconds(60);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
JsonNode root = fetch();
|
||||
requireStatusOk(root);
|
||||
JsonNode site = root.path("Body").path("Data").path("Site");
|
||||
Map<String, Double> fields = new HashMap<>();
|
||||
VALUE_FIELDS.forEach((source, target) -> {
|
||||
JsonNode value = site.path(source);
|
||||
if (value.isNumber()) {
|
||||
fields.put(target, value.doubleValue());
|
||||
}
|
||||
});
|
||||
if (fields.isEmpty()) {
|
||||
throw new IOException("Fronius reply has no power or energy value");
|
||||
}
|
||||
// The inverter reports no PV power while it sleeps; it produces nothing then.
|
||||
fields.putIfAbsent("pv_power_w", 0.0);
|
||||
return List.of(new Reading("electricity", "fronius",
|
||||
Map.of("location", "dom-szczepancowa"), fields, now));
|
||||
}
|
||||
|
||||
private JsonNode fetch() throws IOException {
|
||||
HttpRequest request = HttpRequest.newBuilder(powerFlow).timeout(timeout).GET().build();
|
||||
try {
|
||||
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("Fronius answered HTTP " + response.statusCode());
|
||||
}
|
||||
return json.readTree(response.body());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while reading Fronius", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireStatusOk(JsonNode root) throws IOException {
|
||||
JsonNode status = root.path("Head").path("Status");
|
||||
if (status.path("Code").asInt(-1) != 0) {
|
||||
throw new IOException("Fronius status " + status.path("Code") + ": "
|
||||
+ status.path("Reason").asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The Frisko heat pump controller: every poll yields the legacy POMPA point and the full heat_pump point. */
|
||||
public final class HeatPumpDevice implements Device {
|
||||
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
|
||||
private final ModbusClient modbus;
|
||||
private final HeatPumpSettings settings;
|
||||
|
||||
public HeatPumpDevice(ModbusClient modbus, HeatPumpSettings settings) {
|
||||
this.modbus = modbus;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "heat-pump";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return settings.interval();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
Map<Integer, Integer> words = readWords();
|
||||
Map<String, Double> all = new HashMap<>();
|
||||
Map<String, Double> legacy = new HashMap<>();
|
||||
for (Register register : RegisterCatalog.ALL) {
|
||||
Integer word = words.get(register.address());
|
||||
if (word == null) {
|
||||
continue;
|
||||
}
|
||||
double value = register.scale().decode(word);
|
||||
all.put(register.field(), value);
|
||||
String legacyName = RegisterCatalog.LEGACY_NAMES.get(register.field());
|
||||
if (legacyName != null) {
|
||||
legacy.put(legacyName, value);
|
||||
}
|
||||
}
|
||||
return List.of(
|
||||
new Reading(DATABASE, "POMPA", Map.of(), legacy, now),
|
||||
new Reading(DATABASE, "heat_pump", Map.of(), all, now));
|
||||
}
|
||||
|
||||
private Map<Integer, Integer> readWords() throws IOException {
|
||||
Map<Integer, Integer> words = new HashMap<>();
|
||||
for (RegisterBlock block : settings.blocks()) {
|
||||
int[] values = modbus.readHoldingRegisters(settings.unitId(), block.start(), block.count());
|
||||
if (values.length != block.count()) {
|
||||
throw new IOException("block " + block.start() + " returned " + values.length
|
||||
+ " registers, expected " + block.count());
|
||||
}
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
words.put(block.start() + i, values[i]);
|
||||
}
|
||||
}
|
||||
return words;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public record HeatPumpSettings(int unitId, Duration interval, List<RegisterBlock> blocks) {
|
||||
|
||||
/** Gap-free blocks covering the whole catalog; the controller is never asked for unknown registers. */
|
||||
public static final List<RegisterBlock> PRODUCTION_BLOCKS = List.of(
|
||||
new RegisterBlock(4065, 3),
|
||||
new RegisterBlock(4069, 5),
|
||||
new RegisterBlock(4112, 8),
|
||||
new RegisterBlock(4131, 2),
|
||||
new RegisterBlock(4143, 2),
|
||||
new RegisterBlock(4199, 1),
|
||||
new RegisterBlock(4201, 3),
|
||||
new RegisterBlock(4221, 8));
|
||||
|
||||
public HeatPumpSettings {
|
||||
blocks = List.copyOf(blocks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
record Register(int address, String field, Scale scale) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
/** Contiguous registers read with one request. */
|
||||
public record RegisterBlock(int start, int count) {
|
||||
|
||||
boolean contains(int address) {
|
||||
return address >= start && address < start + count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static home.gateway.adapters.heatpump.Scale.TENTHS;
|
||||
import static home.gateway.adapters.heatpump.Scale.UNITS;
|
||||
|
||||
/** Registers of the Frisko RX910-SE21 that we read (docs/frisko/rx910-se21.md). Addresses are raw. */
|
||||
final class RegisterCatalog {
|
||||
|
||||
static final List<Register> ALL = List.of(
|
||||
new Register(4065, "outdoor_temp", TENTHS),
|
||||
new Register(4066, "indoor_temp", TENTHS),
|
||||
new Register(4067, "co_buffer_temp", TENTHS),
|
||||
new Register(4069, "cwu_temp", TENTHS),
|
||||
new Register(4070, "ground_in_temp", TENTHS),
|
||||
new Register(4071, "ground_out_temp", TENTHS),
|
||||
new Register(4072, "pump_outlet_temp", TENTHS),
|
||||
new Register(4073, "pressure_switch", UNITS),
|
||||
new Register(4112, "output_co", UNITS),
|
||||
new Register(4113, "output_buffer", UNITS),
|
||||
new Register(4114, "output_compressor", UNITS),
|
||||
new Register(4115, "output_heater", UNITS),
|
||||
new Register(4116, "output_circulation", UNITS),
|
||||
new Register(4117, "output_ground", UNITS),
|
||||
new Register(4118, "output_cwu", UNITS),
|
||||
new Register(4119, "output_cooling", UNITS),
|
||||
new Register(4131, "setpoint_co_economy", UNITS),
|
||||
new Register(4132, "setpoint_co_comfort", UNITS),
|
||||
new Register(4143, "setpoint_cwu_economy", UNITS),
|
||||
new Register(4144, "setpoint_cwu_comfort", UNITS),
|
||||
new Register(4199, "outdoor_avg_temp", TENTHS),
|
||||
new Register(4201, "target_room_temp", TENTHS),
|
||||
new Register(4202, "target_co_temp", TENTHS),
|
||||
new Register(4203, "target_cwu_temp", TENTHS),
|
||||
new Register(4221, "alarm", UNITS),
|
||||
new Register(4222, "reg_4222", UNITS),
|
||||
new Register(4223, "reg_4223", UNITS),
|
||||
new Register(4224, "reg_4224", UNITS),
|
||||
new Register(4225, "reg_4225", UNITS),
|
||||
new Register(4226, "program", UNITS),
|
||||
new Register(4227, "pump_mode", UNITS),
|
||||
new Register(4228, "zb_state", UNITS));
|
||||
|
||||
/** Field names of the POMPA measurement that Grafana already uses. */
|
||||
static final Map<String, String> LEGACY_NAMES = Map.of(
|
||||
"outdoor_temp", "External temperature",
|
||||
"indoor_temp", "Temp wew",
|
||||
"co_buffer_temp", "Temp co",
|
||||
"cwu_temp", "Temp cwu",
|
||||
"ground_in_temp", "Temp in",
|
||||
"ground_out_temp", "Temp out",
|
||||
"pump_outlet_temp", "Temp pc out",
|
||||
"outdoor_avg_temp", "Temp zsr");
|
||||
|
||||
private RegisterCatalog() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package home.gateway.adapters.heatpump;
|
||||
|
||||
/** How a decoded register magnitude maps to its value. Multiplying by 0.1 keeps the baseline's numbers bit for bit. */
|
||||
enum Scale {
|
||||
TENTHS(0.1),
|
||||
UNITS(1.0);
|
||||
|
||||
private final double factor;
|
||||
|
||||
Scale(double factor) {
|
||||
this.factor = factor;
|
||||
}
|
||||
|
||||
/** Registers are sign-magnitude: bit 15 is the sign, the low 15 bits the magnitude. */
|
||||
double decode(int word) {
|
||||
double value = (word & 0x7FFF) * factor;
|
||||
return (word & 0x8000) != 0 ? -value : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package home.gateway.adapters.http;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.gateway.application.DeviceState;
|
||||
import home.gateway.application.Snapshot;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* Read-only JSON API: {@code /health} for monitoring, {@code /snapshot} for the app. Only critical
|
||||
* devices decide the {@code /health} status code, because the deploy script rolls back on a non-200
|
||||
* and a dead optional device is no reason for that. The body status tells the three cases apart.
|
||||
*/
|
||||
public class GatewayHttpServer implements AutoCloseable {
|
||||
|
||||
private record DeviceHealth(String name, String status, Instant lastSuccess, String lastError) {
|
||||
}
|
||||
|
||||
private record Health(String status, List<DeviceHealth> devices) {
|
||||
}
|
||||
|
||||
private record Error(String error) {
|
||||
}
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
|
||||
|
||||
private final Snapshot snapshot;
|
||||
private final Clock clock;
|
||||
private final Set<String> criticalDevices;
|
||||
private final HttpServer server;
|
||||
|
||||
public GatewayHttpServer(Snapshot snapshot, Clock clock, int port,
|
||||
Set<String> criticalDevices) throws IOException {
|
||||
this.snapshot = snapshot;
|
||||
this.clock = clock;
|
||||
this.criticalDevices = criticalDevices;
|
||||
this.server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||
server.createContext("/", this::route);
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.getAddress().getPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private void route(HttpExchange exchange) throws IOException {
|
||||
try (exchange) {
|
||||
var path = exchange.getRequestURI().getPath();
|
||||
if (!path.equals("/health") && !path.equals("/snapshot")) {
|
||||
respond(exchange, 404, new Error("not found"));
|
||||
} else if (!exchange.getRequestMethod().equals("GET")) {
|
||||
exchange.getResponseHeaders().set("Allow", "GET");
|
||||
respond(exchange, 405, new Error("method not allowed"));
|
||||
} else if (path.equals("/health")) {
|
||||
health(exchange);
|
||||
} else {
|
||||
respond(exchange, 200, snapshot.states());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void health(HttpExchange exchange) throws IOException {
|
||||
var now = clock.instant();
|
||||
var devices = snapshot.states().stream().map(state -> deviceHealth(state, now)).toList();
|
||||
boolean criticalUp = devices.stream()
|
||||
.filter(device -> criticalDevices.contains(device.name()))
|
||||
.allMatch(GatewayHttpServer::isUp);
|
||||
boolean allUp = devices.stream().allMatch(GatewayHttpServer::isUp);
|
||||
var status = !criticalUp ? "DOWN" : allUp ? "UP" : "DEGRADED";
|
||||
respond(exchange, criticalUp ? 200 : 503, new Health(status, devices));
|
||||
}
|
||||
|
||||
private static boolean isUp(DeviceHealth device) {
|
||||
return device.status().equals("UP");
|
||||
}
|
||||
|
||||
private static DeviceHealth deviceHealth(DeviceState state, Instant now) {
|
||||
return new DeviceHealth(state.name(), state.isHealthy(now) ? "UP" : "DOWN",
|
||||
state.lastSuccess(), state.lastError());
|
||||
}
|
||||
|
||||
private void respond(HttpExchange exchange, int status, Object body) throws IOException {
|
||||
byte[] bytes = JSON.writeValueAsBytes(body);
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.gateway.adapters.influx;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* @param spoolCapBytes per database; the oldest lines are dropped beyond it
|
||||
* @param batchSize maximum lines per HTTP request
|
||||
*/
|
||||
public record InfluxSettings(URI baseUri, Duration requestTimeout, Path spoolDirectory,
|
||||
long spoolCapBytes, int batchSize) {
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package home.gateway.adapters.influx;
|
||||
|
||||
import home.gateway.application.ReadingSink;
|
||||
import home.gateway.domain.Reading;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Writes readings to InfluxDB 1.x through a spool on disk: {@link #write} only appends to
|
||||
* the spool, {@link #flush} (called on a schedule) delivers it.
|
||||
*/
|
||||
public final class InfluxWriter implements ReadingSink {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InfluxWriter.class);
|
||||
private static final String SPOOL_SUFFIX = ".lp";
|
||||
private static final int NO_ANSWER = 0;
|
||||
|
||||
private record Answer(int status, String body) {
|
||||
}
|
||||
|
||||
private final InfluxSettings settings;
|
||||
private final HttpClient http;
|
||||
private final Map<String, Spool> spools = new ConcurrentHashMap<>();
|
||||
private volatile boolean reachable = true;
|
||||
private volatile int batchSize;
|
||||
|
||||
public InfluxWriter(InfluxSettings settings) {
|
||||
this.settings = settings;
|
||||
this.batchSize = settings.batchSize();
|
||||
this.http = HttpClient.newBuilder().connectTimeout(settings.requestTimeout()).build();
|
||||
try {
|
||||
Files.createDirectories(settings.spoolDirectory());
|
||||
spooledDatabases().forEach(this::spool);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Cannot open the spool " + settings.spoolDirectory(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(List<Reading> readings) {
|
||||
readings.stream().collect(Collectors.groupingBy(Reading::database))
|
||||
.forEach(this::spoolDatabase);
|
||||
}
|
||||
|
||||
/** Sends the pending lines of every database; what fails stays for the next call. */
|
||||
public void flush() {
|
||||
spools.forEach(this::flushDatabase);
|
||||
}
|
||||
|
||||
private void spoolDatabase(String database, List<Reading> readings) {
|
||||
List<String> lines = readings.stream().map(LineProtocol::encode)
|
||||
.flatMap(Optional::stream).toList();
|
||||
if (lines.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
spool(database).append(lines);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.error("Cannot spool {} lines for {}", lines.size(), database, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void flushDatabase(String database, Spool spool) {
|
||||
AtomicInteger dropped = new AtomicInteger();
|
||||
try {
|
||||
Spool.Batch batch;
|
||||
int settled;
|
||||
do {
|
||||
batch = spool.peek(batchSize);
|
||||
settled = deliver(database, batch.lines(), dropped);
|
||||
spool.settle(batch, settled);
|
||||
} while (settled == batch.lines().size() && !batch.lines().isEmpty());
|
||||
spool.compact();
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.error("Cannot flush {}", database, e);
|
||||
}
|
||||
if (dropped.get() > 1) {
|
||||
log.warn("Dropped {} more lines InfluxDB rejected for {}", dropped.get() - 1, database);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns how many leading lines are settled, delivered or dropped. A rejected batch is
|
||||
* split until the bad line is alone, so one bad line cannot cost its neighbours or block
|
||||
* the queue; resending accepted points is harmless, as the same series and time overwrite.
|
||||
* 413 only says the request was too big: the batch size shrinks and nothing is dropped.
|
||||
*/
|
||||
private int deliver(String database, List<String> lines, AtomicInteger dropped) {
|
||||
if (lines.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
Answer answer = post(database, lines);
|
||||
if (answer.status() == 204) {
|
||||
return lines.size();
|
||||
}
|
||||
if (!isRejection(answer.status())) {
|
||||
return 0;
|
||||
}
|
||||
if (lines.size() == 1) {
|
||||
return answer.status() == 413 ? 0 : drop(database, lines.get(0), answer, dropped);
|
||||
}
|
||||
int half = lines.size() / 2;
|
||||
if (answer.status() == 413) {
|
||||
batchSize = Math.min(batchSize, half);
|
||||
}
|
||||
int first = deliver(database, lines.subList(0, half), dropped);
|
||||
return first < half ? first
|
||||
: half + deliver(database, lines.subList(half, lines.size()), dropped);
|
||||
}
|
||||
|
||||
/** Only the first drop of a flush is logged in full, so a systematic conflict cannot flood the journal. */
|
||||
private static int drop(String database, String line, Answer answer, AtomicInteger dropped) {
|
||||
if (dropped.incrementAndGet() == 1) {
|
||||
log.warn("Dropped a line InfluxDB rejected for {} ({}: {}): {}", database,
|
||||
answer.status(), answer.body().strip(), line);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 400, 413 and 422 mean the request is bad. Other 4xx (401, 404, 429) are fixed outside, so
|
||||
* their lines wait in the spool, bounded by its cap.
|
||||
*/
|
||||
private static boolean isRejection(int status) {
|
||||
return status == 400 || status == 413 || status == 422;
|
||||
}
|
||||
|
||||
private Answer post(String database, List<String> lines) {
|
||||
URI uri = URI.create(settings.baseUri().toString().replaceAll("/+$", "")
|
||||
+ "/write?db=" + URLEncoder.encode(database, StandardCharsets.UTF_8)
|
||||
+ "&precision=ms");
|
||||
HttpRequest request = HttpRequest.newBuilder(uri).timeout(settings.requestTimeout())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(
|
||||
String.join("\n", lines) + "\n", StandardCharsets.UTF_8))
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response =
|
||||
http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
markReachable(response.statusCode() < 500);
|
||||
return new Answer(response.statusCode(), response.body());
|
||||
} catch (IOException e) {
|
||||
markReachable(false);
|
||||
return new Answer(NO_ANSWER, "");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new Answer(NO_ANSWER, "");
|
||||
}
|
||||
}
|
||||
|
||||
private void markReachable(boolean now) {
|
||||
if (reachable != now) {
|
||||
reachable = now;
|
||||
if (now) {
|
||||
log.info("InfluxDB is reachable again");
|
||||
} else {
|
||||
log.warn("InfluxDB is unreachable; readings are kept in the spool");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Spool spool(String database) {
|
||||
return spools.computeIfAbsent(database, name -> {
|
||||
try {
|
||||
return new Spool(spoolFile(name), settings.spoolCapBytes());
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Path spoolFile(String database) {
|
||||
return settings.spoolDirectory().resolve(
|
||||
URLEncoder.encode(database, StandardCharsets.UTF_8) + SPOOL_SUFFIX);
|
||||
}
|
||||
|
||||
private List<String> spooledDatabases() throws IOException {
|
||||
try (Stream<Path> files = Files.list(settings.spoolDirectory())) {
|
||||
return files.map(file -> file.getFileName().toString())
|
||||
.filter(name -> name.endsWith(SPOOL_SUFFIX))
|
||||
.map(name -> name.substring(0, name.length() - SPOOL_SUFFIX.length()))
|
||||
.map(name -> URLDecoder.decode(name, StandardCharsets.UTF_8))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package home.gateway.adapters.influx;
|
||||
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.TreeMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Encodes a {@link Reading} as one InfluxDB 1.x line protocol line. */
|
||||
final class LineProtocol {
|
||||
|
||||
private LineProtocol() {
|
||||
}
|
||||
|
||||
/** Empty when no field has a finite value, because InfluxDB rejects NaN and infinity. */
|
||||
static Optional<String> encode(Reading reading) {
|
||||
String fields = new TreeMap<>(reading.fields()).entrySet().stream()
|
||||
.filter(field -> Double.isFinite(field.getValue()))
|
||||
.map(field -> escapeKey(field.getKey()) + "=" + number(field.getValue()))
|
||||
.collect(Collectors.joining(","));
|
||||
if (fields.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(escapeMeasurement(reading.measurement()) + tags(reading.tags())
|
||||
+ " " + fields + " " + reading.time().toEpochMilli());
|
||||
}
|
||||
|
||||
private static String tags(Map<String, String> tags) {
|
||||
return new TreeMap<>(tags).entrySet().stream()
|
||||
.map(tag -> "," + escapeKey(tag.getKey()) + "=" + escapeKey(tag.getValue()))
|
||||
.collect(Collectors.joining());
|
||||
}
|
||||
|
||||
/** Plain decimal with a dot, never an exponent and never an "i" suffix: always a float. */
|
||||
private static String number(double value) {
|
||||
String plain = BigDecimal.valueOf(value).toPlainString();
|
||||
return plain.contains(".") ? plain : plain + ".0";
|
||||
}
|
||||
|
||||
private static String escapeMeasurement(String text) {
|
||||
return text.replace(",", "\\,").replace(" ", "\\ ");
|
||||
}
|
||||
|
||||
private static String escapeKey(String text) {
|
||||
return text.replace(",", "\\,").replace("=", "\\=").replace(" ", "\\ ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package home.gateway.adapters.influx;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The pending lines of one database, one per row of an append-only file. Draining reads
|
||||
* forward from the first undelivered byte and only remembers how far it got; the delivered
|
||||
* head is cut off once per flush ({@link #compact}), so the cost of a flush is proportional
|
||||
* to what was delivered, not to the size of the spool. A crash forgets the delivered mark and
|
||||
* resends, which is harmless: the same series and time overwrite. Every access is serialized
|
||||
* and short, so the network is never touched under the lock.
|
||||
*/
|
||||
final class Spool {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(Spool.class);
|
||||
|
||||
/** Cutting down to this share of the cap keeps the cap from rewriting the file on every append. */
|
||||
private static final double CAP_TARGET = 0.9;
|
||||
|
||||
/** Lines read from byte {@code from} of the spool (counted from its first byte ever). */
|
||||
record Batch(List<String> lines, long from) {
|
||||
}
|
||||
|
||||
private final Path file;
|
||||
private final long capBytes;
|
||||
/** Position, in bytes since the spool was opened, of the file's first byte. */
|
||||
private long start;
|
||||
/** Position up to which lines are settled; never behind {@code start}. */
|
||||
private long delivered;
|
||||
|
||||
Spool(Path file, long capBytes) throws IOException {
|
||||
this.file = file;
|
||||
this.capBytes = capBytes;
|
||||
if (Files.exists(file)) {
|
||||
dropTornLine();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void append(List<String> lines) throws IOException {
|
||||
Files.writeString(file, String.join("\n", lines) + "\n",
|
||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
if (Files.size(file) > capBytes) {
|
||||
dropOldest();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized Batch peek(int maxLines) throws IOException {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (Files.exists(file)) {
|
||||
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||
in.skipNBytes(delivered - start);
|
||||
ByteArrayOutputStream line = new ByteArrayOutputStream();
|
||||
for (int b = in.read(); b >= 0 && lines.size() < maxLines; b = in.read()) {
|
||||
if (b == '\n') {
|
||||
lines.add(line.toString(StandardCharsets.UTF_8));
|
||||
line.reset();
|
||||
} else {
|
||||
line.write(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Batch(List.copyOf(lines), delivered);
|
||||
}
|
||||
|
||||
/** Marks the batch's first {@code count} lines as settled; the cap may have cut them already. */
|
||||
synchronized void settle(Batch batch, int count) {
|
||||
long end = batch.from();
|
||||
for (String line : batch.lines().subList(0, count)) {
|
||||
end += line.getBytes(StandardCharsets.UTF_8).length + 1;
|
||||
}
|
||||
delivered = Math.max(delivered, end);
|
||||
}
|
||||
|
||||
/** Cuts the settled head off the file: one copy of the undelivered tail per flush. */
|
||||
synchronized void compact() throws IOException {
|
||||
if (delivered > start) {
|
||||
cutHead(delivered - start);
|
||||
}
|
||||
}
|
||||
|
||||
private void dropOldest() throws IOException {
|
||||
long size = Files.size(file);
|
||||
long cut = findLineStart(size - (long) (capBytes * CAP_TARGET));
|
||||
long lost = Math.max(0, start + cut - delivered);
|
||||
cutHead(cut);
|
||||
log.warn("Spool {} is over {} bytes: dropped its oldest {} undelivered bytes",
|
||||
file.getFileName(), capBytes, lost);
|
||||
}
|
||||
|
||||
/** The first line start at or after {@code position}, or the end of the file. */
|
||||
private long findLineStart(long position) throws IOException {
|
||||
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||
in.skipNBytes(Math.max(0, position - 1));
|
||||
long offset = Math.max(0, position - 1);
|
||||
for (int b = in.read(); b >= 0; b = in.read()) {
|
||||
offset++;
|
||||
if (b == '\n') {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
|
||||
private void cutHead(long bytes) throws IOException {
|
||||
if (bytes >= Files.size(file)) {
|
||||
Files.delete(file);
|
||||
} else {
|
||||
Path temp = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
try (InputStream in = Files.newInputStream(file);
|
||||
OutputStream out = Files.newOutputStream(temp)) {
|
||||
in.skipNBytes(bytes);
|
||||
in.transferTo(out);
|
||||
}
|
||||
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE,
|
||||
StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
start += bytes;
|
||||
delivered = Math.max(delivered, start);
|
||||
}
|
||||
|
||||
/** A crash may leave a fragment after the last newline, which would glue onto the next append. */
|
||||
private void dropTornLine() throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ,
|
||||
StandardOpenOption.WRITE)) {
|
||||
long end = endOfLastLine(channel);
|
||||
if (end < channel.size()) {
|
||||
channel.truncate(end);
|
||||
}
|
||||
}
|
||||
if (Files.size(file) == 0) {
|
||||
Files.delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
private static long endOfLastLine(FileChannel channel) throws IOException {
|
||||
ByteBuffer chunk = ByteBuffer.allocate(8192);
|
||||
long position = channel.size();
|
||||
while (position > 0) {
|
||||
int length = (int) Math.min(chunk.capacity(), position);
|
||||
position -= length;
|
||||
chunk.clear().limit(length);
|
||||
while (chunk.hasRemaining() && channel.read(chunk, position + chunk.position()) >= 0) {
|
||||
continue;
|
||||
}
|
||||
for (int i = length - 1; i >= 0; i--) {
|
||||
if (chunk.get(i) == '\n') {
|
||||
return position + i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import home.gateway.adapters.meter.Sdm630Registers.Register;
|
||||
import home.gateway.adapters.modbus.ModbusClient;
|
||||
import home.gateway.application.Device;
|
||||
import home.gateway.domain.Reading;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/** The Eastron SDM630 house meter: all input registers, one point per poll. */
|
||||
public final class HouseMeterDevice implements Device {
|
||||
|
||||
private static final int UNIT_ID = 1;
|
||||
private static final int MAX_REGISTERS_PER_REQUEST = 125;
|
||||
private static final Duration INTERVAL = Duration.ofSeconds(60);
|
||||
private static final int DECIMALS = 2;
|
||||
|
||||
private record Block(int address, int count, List<Register> registers) {
|
||||
}
|
||||
|
||||
private final ModbusClient modbus;
|
||||
private final List<Block> blocks = blocksOf(Sdm630Registers.INPUT);
|
||||
|
||||
public HouseMeterDevice(ModbusClient modbus) {
|
||||
this.modbus = modbus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return "house-meter";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration interval() {
|
||||
return INTERVAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Reading> read(Instant now) throws IOException {
|
||||
Map<String, Double> fields = new LinkedHashMap<>();
|
||||
for (Block block : blocks) {
|
||||
readBlock(block, fields);
|
||||
}
|
||||
if (fields.isEmpty()) {
|
||||
throw new IOException("house meter returned no finite value");
|
||||
}
|
||||
return List.of(new Reading("electricity", "PRAD2", Map.of("location", "dom-szczepancowa"),
|
||||
fields, now));
|
||||
}
|
||||
|
||||
private void readBlock(Block block, Map<String, Double> fields) throws IOException {
|
||||
int[] words = modbus.readInputRegisters(UNIT_ID, block.address(), block.count());
|
||||
if (words.length != block.count()) {
|
||||
throw new IOException("house meter block at " + block.address() + " returned "
|
||||
+ words.length + " registers, expected " + block.count());
|
||||
}
|
||||
for (Register register : block.registers()) {
|
||||
int offset = register.address() - block.address();
|
||||
float value = Float.intBitsToFloat((words[offset] << 16) | words[offset + 1]);
|
||||
// The old Python code lost the whole point on NaN or infinity; one bad value
|
||||
// should not cost the other 70.
|
||||
if (Float.isFinite(value)) {
|
||||
fields.put(register.name(), round(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Same as Python's {@code round(float(x), 2)}: the exact binary value, ties to even. */
|
||||
private static double round(float value) {
|
||||
return new BigDecimal(value).setScale(DECIMALS, RoundingMode.HALF_EVEN).doubleValue();
|
||||
}
|
||||
|
||||
private static List<Block> blocksOf(List<Register> registers) {
|
||||
Map<Integer, List<Register>> byBatch = new TreeMap<>();
|
||||
for (Register register : registers) {
|
||||
byBatch.computeIfAbsent(register.batch(), batch -> new ArrayList<>()).add(register);
|
||||
}
|
||||
List<Block> result = new ArrayList<>();
|
||||
for (List<Register> batch : byBatch.values()) {
|
||||
int first = batch.getFirst().address();
|
||||
int count = batch.getLast().address() + Sdm630Registers.WORDS_PER_VALUE - first;
|
||||
if (count > MAX_REGISTERS_PER_REQUEST) {
|
||||
throw new IllegalStateException("block at " + first + " has " + count
|
||||
+ " registers, limit " + MAX_REGISTERS_PER_REQUEST);
|
||||
}
|
||||
result.add(new Block(first, count, batch));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package home.gateway.adapters.meter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The SDM630 input registers, as sdm_modbus 0.4.4 reads them in {@code read_all()}. Names are the
|
||||
* field names of {@code electricity.PRAD2}. Every value is a float32 in two registers, unscaled.
|
||||
* The batch number is the library's: registers of one batch are fetched with one request.
|
||||
*/
|
||||
final class Sdm630Registers {
|
||||
|
||||
static final int WORDS_PER_VALUE = 2;
|
||||
|
||||
record Register(String name, int address, int batch) {
|
||||
}
|
||||
|
||||
static final List<Register> INPUT = List.of(
|
||||
new Register("p1_voltage", 0x0000, 1),
|
||||
new Register("p2_voltage", 0x0002, 1),
|
||||
new Register("p3_voltage", 0x0004, 1),
|
||||
new Register("p1_current", 0x0006, 1),
|
||||
new Register("p2_current", 0x0008, 1),
|
||||
new Register("p3_current", 0x000a, 1),
|
||||
new Register("p1_power_active", 0x000c, 1),
|
||||
new Register("p2_power_active", 0x000e, 1),
|
||||
new Register("p3_power_active", 0x0010, 1),
|
||||
new Register("p1_power_apparent", 0x0012, 1),
|
||||
new Register("p2_power_apparent", 0x0014, 1),
|
||||
new Register("p3_power_apparent", 0x0016, 1),
|
||||
new Register("p1_power_reactive", 0x0018, 1),
|
||||
new Register("p2_power_reactive", 0x001a, 1),
|
||||
new Register("p3_power_reactive", 0x001c, 1),
|
||||
new Register("p1_power_factor", 0x001e, 1),
|
||||
new Register("p2_power_factor", 0x0020, 1),
|
||||
new Register("p3_power_factor", 0x0022, 1),
|
||||
new Register("p1_phase_angle", 0x0024, 1),
|
||||
new Register("p2_phase_angle", 0x0026, 1),
|
||||
new Register("p3_phase_angle", 0x0028, 1),
|
||||
new Register("voltage_ln", 0x002a, 1),
|
||||
new Register("current_ln", 0x002e, 1),
|
||||
new Register("total_line_current", 0x0030, 1),
|
||||
new Register("total_power_active", 0x0034, 1),
|
||||
new Register("total_power_apparent", 0x0038, 1),
|
||||
new Register("total_power_reactive", 0x003c, 1),
|
||||
new Register("total_power_factor", 0x003e, 1),
|
||||
new Register("total_phase_angle", 0x0042, 1),
|
||||
new Register("frequency", 0x0046, 1),
|
||||
new Register("import_energy_active", 0x0048, 1),
|
||||
new Register("export_energy_active", 0x004a, 1),
|
||||
new Register("import_energy_reactive", 0x004c, 1),
|
||||
new Register("export_energy_reactive", 0x004e, 1),
|
||||
new Register("total_energy_apparent", 0x0050, 2),
|
||||
new Register("total_current", 0x0052, 2),
|
||||
new Register("total_import_demand_power_active", 0x0054, 2),
|
||||
new Register("maximum_import_demand_power_apparent", 0x0056, 2),
|
||||
new Register("import_demand_power_active", 0x0058, 2),
|
||||
new Register("maximum_import_demand_power_active", 0x005a, 2),
|
||||
new Register("export_demand_power_active", 0x005c, 2),
|
||||
new Register("maximum_export_demand_power_active", 0x005e, 2),
|
||||
new Register("total_demand_power_apparent", 0x0064, 2),
|
||||
new Register("maximum_demand_power_apparent", 0x0066, 2),
|
||||
new Register("neutral_demand_current", 0x0068, 2),
|
||||
new Register("maximum_neutral_demand_current", 0x006a, 2),
|
||||
new Register("p12_voltage", 0x00c8, 3),
|
||||
new Register("p23_voltage", 0x00ca, 3),
|
||||
new Register("p31_voltage", 0x00cc, 3),
|
||||
new Register("voltage_ll", 0x00ce, 3),
|
||||
new Register("neutral_current", 0x00e0, 3),
|
||||
new Register("p1n_voltage_thd", 0x00ea, 3),
|
||||
new Register("p2n_voltage_thd", 0x00ec, 3),
|
||||
new Register("p3n_voltage_thd", 0x00ee, 3),
|
||||
new Register("p1_current_thd", 0x00f0, 3),
|
||||
new Register("p2_current_thd", 0x00f2, 3),
|
||||
new Register("p3_current_thd", 0x00f4, 3),
|
||||
new Register("voltage_ln_thd", 0x00f8, 3),
|
||||
new Register("current_thd", 0x00fa, 3),
|
||||
new Register("p1_demand_current", 0x0102, 3),
|
||||
new Register("p2_demand_current", 0x0104, 3),
|
||||
new Register("p3_demand_current", 0x0106, 3),
|
||||
new Register("maximum_p1_demand_current", 0x0108, 3),
|
||||
new Register("maximum_p2_demand_current", 0x010a, 3),
|
||||
new Register("maximum_p3_demand_current", 0x010c, 3),
|
||||
new Register("p12_voltage_thd", 0x014e, 4),
|
||||
new Register("p23_voltage_thd", 0x0150, 4),
|
||||
new Register("p31_voltage_thd", 0x0152, 4),
|
||||
new Register("voltage_ll_thd", 0x0154, 4),
|
||||
new Register("total_energy_active", 0x0156, 4),
|
||||
new Register("total_energy_reactive", 0x0158, 4)
|
||||
);
|
||||
|
||||
private Sdm630Registers() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Register reads; values are raw 16-bit words (0–65535), decoding belongs to the device. */
|
||||
public interface ModbusClient {
|
||||
|
||||
int[] readHoldingRegisters(int unitId, int address, int count) throws IOException;
|
||||
|
||||
int[] readInputRegisters(int unitId, int address, int count) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** The device answered a request with a Modbus exception response. */
|
||||
public class ModbusException extends IOException {
|
||||
|
||||
private final int function;
|
||||
private final int code;
|
||||
|
||||
public ModbusException(int function, int code) {
|
||||
super("Modbus exception on function " + function + ": " + describe(code));
|
||||
this.function = function;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int function() {
|
||||
return function;
|
||||
}
|
||||
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
private static String describe(int code) {
|
||||
String name = switch (code) {
|
||||
case 0x01 -> "illegal function";
|
||||
case 0x02 -> "illegal data address";
|
||||
case 0x03 -> "illegal data value";
|
||||
case 0x04 -> "server device failure";
|
||||
case 0x05 -> "acknowledge";
|
||||
case 0x06 -> "server busy";
|
||||
case 0x0A -> "gateway path unavailable";
|
||||
case 0x0B -> "gateway target failed to respond";
|
||||
default -> "unknown code";
|
||||
};
|
||||
return name + " (0x%02X)".formatted(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Carries one Modbus PDU to a unit and returns the response PDU (function code first). */
|
||||
public interface ModbusTransport extends Closeable {
|
||||
|
||||
byte[] exchange(int unitId, byte[] requestPdu) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
/** Modbus read functions FC3 and FC4 encoded and decoded as PDUs over a transport. */
|
||||
public class PduModbusClient implements ModbusClient {
|
||||
|
||||
private static final int READ_HOLDING_REGISTERS = 0x03;
|
||||
private static final int READ_INPUT_REGISTERS = 0x04;
|
||||
private static final int EXCEPTION_FLAG = 0x80;
|
||||
private static final int MAX_QUANTITY = 125;
|
||||
private static final int MAX_ADDRESS = 0xFFFF;
|
||||
|
||||
private final ModbusTransport transport;
|
||||
|
||||
public PduModbusClient(ModbusTransport transport) {
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] readHoldingRegisters(int unitId, int address, int count) throws IOException {
|
||||
return read(READ_HOLDING_REGISTERS, unitId, address, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] readInputRegisters(int unitId, int address, int count) throws IOException {
|
||||
return read(READ_INPUT_REGISTERS, unitId, address, count);
|
||||
}
|
||||
|
||||
private int[] read(int function, int unitId, int address, int count) throws IOException {
|
||||
if (address < 0 || address > MAX_ADDRESS) {
|
||||
throw new IllegalArgumentException("Register address out of range 0..65535: " + address);
|
||||
}
|
||||
if (count < 1 || count > MAX_QUANTITY) {
|
||||
throw new IllegalArgumentException("Register count out of range 1..125: " + count);
|
||||
}
|
||||
byte[] request = ByteBuffer.allocate(5)
|
||||
.put((byte) function).putShort((short) address).putShort((short) count).array();
|
||||
return decode(function, count, transport.exchange(unitId, request));
|
||||
}
|
||||
|
||||
private static int[] decode(int function, int count, byte[] response) throws IOException {
|
||||
if (response.length < 2) {
|
||||
throw new IOException("Modbus response too short: " + response.length + " bytes");
|
||||
}
|
||||
ByteBuffer buffer = ByteBuffer.wrap(response);
|
||||
int received = buffer.get() & 0xFF;
|
||||
if (received == (function | EXCEPTION_FLAG)) {
|
||||
throw new ModbusException(function, buffer.get() & 0xFF);
|
||||
}
|
||||
if (received != function) {
|
||||
throw new IOException("Modbus response function 0x%02X does not match request 0x%02X"
|
||||
.formatted(received, function));
|
||||
}
|
||||
int byteCount = buffer.get() & 0xFF;
|
||||
if (byteCount != 2 * count) {
|
||||
throw new IOException("Modbus byte count " + byteCount + ", expected " + 2 * count);
|
||||
}
|
||||
if (buffer.remaining() != byteCount) {
|
||||
throw new IOException("Modbus response carries " + buffer.remaining()
|
||||
+ " data bytes, byte count says " + byteCount);
|
||||
}
|
||||
int[] words = new int[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
words[i] = buffer.getShort() & 0xFFFF;
|
||||
}
|
||||
return words;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package home.gateway.adapters.modbus;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Modbus TCP (MBAP) over one long-lived socket. The MK01 gateway serves a single client and a
|
||||
* second connection drops the first, so the socket is reused and requests never overlap.
|
||||
*/
|
||||
public final class TcpTransport implements ModbusTransport {
|
||||
|
||||
public record Settings(String host, int port, Duration connectTimeout, Duration readTimeout,
|
||||
Duration minimumPause) {
|
||||
}
|
||||
|
||||
private static final int HEADER_LENGTH = 7;
|
||||
|
||||
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Settings settings;
|
||||
private Socket socket;
|
||||
private DataInputStream in;
|
||||
private int transactionId;
|
||||
private long lastResponseNanos;
|
||||
|
||||
public TcpTransport(Settings settings) {
|
||||
this.settings = settings;
|
||||
this.lastResponseNanos = System.nanoTime() - settings.minimumPause().toNanos();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] exchange(int unitId, byte[] requestPdu) throws IOException {
|
||||
lock.lock();
|
||||
try {
|
||||
return exchangeLocked(unitId, requestPdu);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] exchangeLocked(int unitId, byte[] requestPdu) throws IOException {
|
||||
try {
|
||||
awaitPause();
|
||||
connectIfNeeded();
|
||||
int id = nextTransactionId();
|
||||
socket.getOutputStream().write(frame(id, unitId, requestPdu));
|
||||
byte[] response = readResponse(id, unitId);
|
||||
lastResponseNanos = System.nanoTime();
|
||||
return response;
|
||||
} catch (IOException e) {
|
||||
closeSocket();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lock.lock();
|
||||
try {
|
||||
closeSocket();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitPause() throws InterruptedIOException {
|
||||
long remaining = settings.minimumPause().toNanos() - (System.nanoTime() - lastResponseNanos);
|
||||
if (remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(Duration.ofNanos(remaining));
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new InterruptedIOException("interrupted while waiting between requests");
|
||||
}
|
||||
}
|
||||
|
||||
private void connectIfNeeded() throws IOException {
|
||||
if (socket != null) {
|
||||
return;
|
||||
}
|
||||
Socket candidate = new Socket();
|
||||
try {
|
||||
candidate.setTcpNoDelay(true);
|
||||
candidate.setSoTimeout((int) settings.readTimeout().toMillis());
|
||||
candidate.connect(new InetSocketAddress(settings.host(), settings.port()),
|
||||
(int) settings.connectTimeout().toMillis());
|
||||
} catch (IOException e) {
|
||||
candidate.close();
|
||||
throw e;
|
||||
}
|
||||
socket = candidate;
|
||||
in = new DataInputStream(candidate.getInputStream());
|
||||
}
|
||||
|
||||
private int nextTransactionId() {
|
||||
transactionId = (transactionId + 1) & 0xFFFF;
|
||||
return transactionId;
|
||||
}
|
||||
|
||||
private static byte[] frame(int transactionId, int unitId, byte[] pdu) {
|
||||
return ByteBuffer.allocate(HEADER_LENGTH + pdu.length)
|
||||
.putShort((short) transactionId)
|
||||
.putShort((short) 0)
|
||||
.putShort((short) (pdu.length + 1))
|
||||
.put((byte) unitId)
|
||||
.put(pdu)
|
||||
.array();
|
||||
}
|
||||
|
||||
private byte[] readResponse(int expectedTransactionId, int expectedUnitId) throws IOException {
|
||||
ByteBuffer header = ByteBuffer.wrap(in.readNBytes(HEADER_LENGTH));
|
||||
if (header.remaining() < HEADER_LENGTH) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
int id = header.getShort() & 0xFFFF;
|
||||
int protocol = header.getShort() & 0xFFFF;
|
||||
int length = header.getShort() & 0xFFFF;
|
||||
int unitId = header.get() & 0xFF;
|
||||
if (protocol != 0 || id != expectedTransactionId || unitId != expectedUnitId || length < 2) {
|
||||
throw new IOException("unexpected MBAP header: transaction %d, protocol %d, length %d, unit %d"
|
||||
.formatted(id, protocol, length, unitId));
|
||||
}
|
||||
byte[] pdu = in.readNBytes(length - 1);
|
||||
if (pdu.length < length - 1) {
|
||||
throw new IOException("connection closed by peer");
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private void closeSocket() {
|
||||
if (socket == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
// nothing useful to do with a socket that fails to close
|
||||
}
|
||||
socket = null;
|
||||
in = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package home.gateway.adapters.solarman;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Modbus RTU frame: unit id, PDU, CRC16 with the low byte first. */
|
||||
final class ModbusRtuFrame {
|
||||
|
||||
private static final int CRC_LENGTH = 2;
|
||||
private static final int MIN_LENGTH = 1 + 1 + CRC_LENGTH;
|
||||
|
||||
private ModbusRtuFrame() {
|
||||
}
|
||||
|
||||
static byte[] build(int unitId, byte[] pdu) {
|
||||
byte[] frame = new byte[1 + pdu.length + CRC_LENGTH];
|
||||
frame[0] = (byte) unitId;
|
||||
System.arraycopy(pdu, 0, frame, 1, pdu.length);
|
||||
int crc = crc16(frame, frame.length - CRC_LENGTH);
|
||||
frame[frame.length - 2] = (byte) crc;
|
||||
frame[frame.length - 1] = (byte) (crc >> 8);
|
||||
return frame;
|
||||
}
|
||||
|
||||
static boolean hasValidCrc(byte[] frame) {
|
||||
if (frame.length < MIN_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
int crc = crc16(frame, frame.length - CRC_LENGTH);
|
||||
return frame[frame.length - 2] == (byte) crc && frame[frame.length - 1] == (byte) (crc >> 8);
|
||||
}
|
||||
|
||||
static byte[] parse(byte[] frame, int expectedUnitId) throws IOException {
|
||||
if (!hasValidCrc(frame)) {
|
||||
throw new IOException("Modbus RTU frame has a bad CRC or is too short");
|
||||
}
|
||||
if ((frame[0] & 0xFF) != expectedUnitId) {
|
||||
throw new IOException("Modbus RTU frame from unit " + (frame[0] & 0xFF) + ", expected " + expectedUnitId);
|
||||
}
|
||||
byte[] pdu = new byte[frame.length - 1 - CRC_LENGTH];
|
||||
System.arraycopy(frame, 1, pdu, 0, pdu.length);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
static int crc16(byte[] data, int length) {
|
||||
int crc = 0xFFFF;
|
||||
for (int i = 0; i < length; i++) {
|
||||
crc ^= data[i] & 0xFF;
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package home.gateway.adapters.solarman;
|
||||
|
||||
import home.gateway.adapters.modbus.ModbusTransport;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* Modbus RTU carried in Solarman V5 frames over the Wi-Fi logger's TCP port (usually 8899).
|
||||
* One connection is kept and reused, one request at a time; after any error it is closed and
|
||||
* the next call reconnects. All V5 fields are little endian, the RTU frame inside is not.
|
||||
*/
|
||||
public final class SolarmanV5Transport implements ModbusTransport {
|
||||
|
||||
public record Settings(String host, int port, long loggerSerial, Duration connectTimeout, Duration readTimeout) {
|
||||
}
|
||||
|
||||
private static final int START = 0xA5;
|
||||
private static final int END = 0x15;
|
||||
private static final int REQUEST_CONTROL_CODE = 0x4510;
|
||||
private static final int RESPONSE_CONTROL_CODE = 0x1510;
|
||||
private static final int HEADER_LENGTH = 11;
|
||||
private static final int TRAILER_LENGTH = 2;
|
||||
private static final int REQUEST_PAYLOAD_PREFIX = 15;
|
||||
private static final int RESPONSE_PAYLOAD_PREFIX = 14;
|
||||
private static final int INVERTER_FRAME_TYPE = 0x02;
|
||||
private static final int DOUBLE_CRC_LENGTH = 2;
|
||||
|
||||
// Not synchronized: a virtual thread blocked in a monitor pins its carrier thread.
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Settings settings;
|
||||
private Socket socket;
|
||||
private int sequence = (int) (Math.random() * 256);
|
||||
|
||||
public SolarmanV5Transport(Settings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] exchange(int unitId, byte[] requestPdu) throws IOException {
|
||||
lock.lock();
|
||||
try {
|
||||
return exchangeLocked(unitId, requestPdu);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] exchangeLocked(int unitId, byte[] requestPdu) throws IOException {
|
||||
try {
|
||||
Socket connection = connection();
|
||||
int requestSequence = nextSequence();
|
||||
OutputStream out = connection.getOutputStream();
|
||||
out.write(requestFrame(requestSequence, ModbusRtuFrame.build(unitId, requestPdu)));
|
||||
out.flush();
|
||||
byte[] rtu = readResponseRtu(new DataInputStream(connection.getInputStream()), requestSequence);
|
||||
return ModbusRtuFrame.parse(rtu, unitId);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
closeConnection();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lock.lock();
|
||||
try {
|
||||
closeConnection();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private Socket connection() throws IOException {
|
||||
if (socket == null) {
|
||||
Socket candidate = new Socket();
|
||||
try {
|
||||
candidate.connect(new InetSocketAddress(settings.host(), settings.port()),
|
||||
(int) settings.connectTimeout().toMillis());
|
||||
candidate.setSoTimeout((int) settings.readTimeout().toMillis());
|
||||
} catch (IOException e) {
|
||||
candidate.close();
|
||||
throw e;
|
||||
}
|
||||
socket = candidate;
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
private void closeConnection() {
|
||||
if (socket != null) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ignored) {
|
||||
// already failing; the next call reconnects
|
||||
}
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only the low byte is echoed by the logger; it counts the high byte itself.
|
||||
private int nextSequence() {
|
||||
sequence = (sequence + 1) & 0xFF;
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private byte[] requestFrame(int requestSequence, byte[] rtu) {
|
||||
int payloadLength = REQUEST_PAYLOAD_PREFIX + rtu.length;
|
||||
ByteBuffer frame = ByteBuffer.allocate(HEADER_LENGTH + payloadLength + TRAILER_LENGTH)
|
||||
.order(ByteOrder.LITTLE_ENDIAN);
|
||||
frame.put((byte) START)
|
||||
.putShort((short) payloadLength)
|
||||
.putShort((short) REQUEST_CONTROL_CODE)
|
||||
.putShort((short) requestSequence)
|
||||
.putInt((int) settings.loggerSerial())
|
||||
.put((byte) INVERTER_FRAME_TYPE)
|
||||
.put(new byte[REQUEST_PAYLOAD_PREFIX - 1]) // sensor type and three timers, all zero
|
||||
.put(rtu);
|
||||
frame.put((byte) checksum(frame.array(), frame.position()));
|
||||
frame.put((byte) END);
|
||||
return frame.array();
|
||||
}
|
||||
|
||||
// The logger also sends frames nobody asked for (heartbeats, late answers); skip them until
|
||||
// the answer to this request arrives or the read times out.
|
||||
private byte[] readResponseRtu(DataInputStream in, int requestSequence) throws IOException {
|
||||
long deadline = System.nanoTime() + settings.readTimeout().toNanos();
|
||||
while (true) {
|
||||
if (System.nanoTime() > deadline) {
|
||||
throw new SocketTimeoutException("no V5 answer to sequence " + requestSequence);
|
||||
}
|
||||
byte[] header = new byte[HEADER_LENGTH];
|
||||
in.readFully(header);
|
||||
ByteBuffer head = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN);
|
||||
int payloadLength = head.getShort(1) & 0xFFFF;
|
||||
if ((header[0] & 0xFF) != START) {
|
||||
throw new IOException("V5 response does not start with 0xA5");
|
||||
}
|
||||
byte[] rest = new byte[payloadLength + TRAILER_LENGTH];
|
||||
in.readFully(rest);
|
||||
if (!isAnswerTo(head, requestSequence)) {
|
||||
continue;
|
||||
}
|
||||
if (payloadLength < RESPONSE_PAYLOAD_PREFIX) {
|
||||
throw new IOException("V5 response payload too short: " + payloadLength);
|
||||
}
|
||||
verify(head, header, rest);
|
||||
byte[] rtu = new byte[payloadLength - RESPONSE_PAYLOAD_PREFIX];
|
||||
System.arraycopy(rest, RESPONSE_PAYLOAD_PREFIX, rtu, 0, rtu.length);
|
||||
return withoutDoubleCrc(rtu);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnswerTo(ByteBuffer head, int requestSequence) {
|
||||
return (head.getShort(3) & 0xFFFF) == RESPONSE_CONTROL_CODE
|
||||
&& (head.getShort(5) & 0xFF) == requestSequence;
|
||||
}
|
||||
|
||||
private void verify(ByteBuffer head, byte[] header, byte[] rest) throws IOException {
|
||||
if (head.getInt(7) != (int) settings.loggerSerial()) {
|
||||
throw new IOException("V5 response from another logger");
|
||||
}
|
||||
byte[] checked = new byte[header.length + rest.length - TRAILER_LENGTH];
|
||||
System.arraycopy(header, 0, checked, 0, header.length);
|
||||
System.arraycopy(rest, 0, checked, header.length, rest.length - TRAILER_LENGTH);
|
||||
if ((rest[rest.length - 2] & 0xFF) != checksum(checked, checked.length)) {
|
||||
throw new IOException("V5 response has a bad checksum");
|
||||
}
|
||||
if ((rest[rest.length - 1] & 0xFF) != END) {
|
||||
throw new IOException("V5 response does not end with 0x15");
|
||||
}
|
||||
}
|
||||
|
||||
// Deye loggers append a second, always zero CRC. Check the trimmed frame first: a frame
|
||||
// followed by its own CRC also passes the CRC check, with the CRC taken as payload.
|
||||
private static byte[] withoutDoubleCrc(byte[] rtu) {
|
||||
if (rtu.length > DOUBLE_CRC_LENGTH) {
|
||||
byte[] trimmed = Arrays.copyOf(rtu, rtu.length - DOUBLE_CRC_LENGTH);
|
||||
if (ModbusRtuFrame.hasValidCrc(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return rtu;
|
||||
}
|
||||
|
||||
/** Sum of all bytes between the start byte and the checksum, modulo 256. */
|
||||
private static int checksum(byte[] frame, int end) {
|
||||
int sum = 0;
|
||||
for (int i = 1; i < end; i++) {
|
||||
sum += frame[i] & 0xFF;
|
||||
}
|
||||
return sum & 0xFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package home.gateway.application;
|
||||
|
||||
import home.gateway.domain.Command;
|
||||
import home.gateway.domain.CommandResult;
|
||||
import home.gateway.domain.Restore;
|
||||
import home.gateway.domain.Setpoint;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** What the HTTP adapter may ask of the setpoint service (F3); the service does every check. */
|
||||
public interface Commands {
|
||||
|
||||
CommandResult apply(Command command);
|
||||
|
||||
List<Setpoint> setpoints();
|
||||
|
||||
List<Restore> pendingRestores();
|
||||
}
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user