feat(gateway): change the Deye battery schedule and reserve (off by default)

Token-protected /battery/schedule and /battery/reserve: allowlisted Time
of Use changes checked as a whole against a fresh reading and the live
registers, FC16 per range, read-back, audit, rate limit per register.
The reserve is an app setting kept in reserve.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 15:23:33 +02:00
1 parent 8111405326
commit 619c3ee990
33 files changed
+2006 -108

No files matched your search

+24
View File
@@ -61,6 +61,30 @@ Do all of this before setting `commands.enabled` to true:
Then set `commands.enabled` and `docker restart home-pump-gateway`. If the restore file is corrupt at start, the gateway
keeps polling and serving, moves the file aside, and `/commands` answers 503 until a restart.
## Enabling battery commands
The Deye's program table and the backup reserve (ADR-013), off unless `deyeCommands.enabled`. It needs
`devices.deye.enabled` and the token file and firewall steps of "Enabling commands" (`commands.tokenFile` is
reused; `commands.enabled` may stay false). `deyeCommands`: `reserveFile` (`reserve.json` next to the
restore file, `{"pct": 50}`, replaced atomically; missing means 50, an unreadable file is logged and means 50),
`reserveFloorPct` (30; the reserve is never lower, nor below the inverter's shutdown SOC + 10),
`minIntervalSeconds` (60, per register) and `readBackDelayMillis` (1000). Same Bearer token and failed-login
throttle as `/commands`; `X-Requested-By` names the requester. While it is off, `/battery` answers 404.
- `GET /battery/reserve` answers `{"pct", "floorPct"}`; `POST /battery/reserve` `{"pct": 60}` changes it.
- `POST /battery/schedule` `{"enabled": true, "slots": [{"slot": 2, "start": 930, "soc": 90, "gridCharge": true}]}`;
every field is optional, slots are 1 to 6.
- 200 applied, 400 bad body, 422 rejected (no write), 409 the settings were not read in the last 30 minutes or
differ from that reading right before the write (nothing written), 429 too soon after the last write to a
register, 502 failed: the body's `read` holds the registers as read back, nothing is retried.
- Rules: SOC from the reserve to 100; grid charge written as 0 or 1 only; starts are HHMM, strictly increasing
from slot 1 to 6; an enabled schedule needs every slot SOC at or above the reserve; the reserve cannot be
raised above a slot SOC of an enabled schedule. The whole table is checked before anything is written,
neighbouring registers go in one FC16, the Time of Use switch last, the days (bits 1 to 7 of 146) stay as read.
- Audit: `electricity.deye_settings_audit` (tags `register`, `requested_by`, `status`; fields `old_value`,
`new_value`). After a write the settings are polled again 3 s later. No restore and no lease: these are
persistent settings, as in the Solarman app.
## Leases (ADR-011)
Every applied change, timed or "until changed", keeps a restore with a 15 minute lease. The restore runs at
+7
View File
@@ -24,6 +24,13 @@
{ "name": "cwu_comfort", "register": 4144, "min": 5, "max": 48, "step": 1 }
]
},
"deyeCommands": {
"enabled": false,
"reserveFile": "/home/pi/home-pump/reserve.json",
"reserveFloorPct": 30,
"minIntervalSeconds": 60,
"readBackDelayMillis": 1000
},
"devices": {
"heatPump": {
"enabled": true,
@@ -3,6 +3,7 @@ package home.gateway;
import home.gateway.adapters.bme280.Bme280Device;
import home.gateway.adapters.deye.DeyeDevice;
import home.gateway.adapters.deye.DeyeSettingsDevice;
import home.gateway.adapters.deye.ModbusBatteryRegisters;
import home.gateway.adapters.fronius.FroniusDevice;
import home.gateway.adapters.heatpump.HeatPumpDevice;
import home.gateway.adapters.heatpump.ModbusSetpointRegisters;
@@ -14,7 +15,10 @@ import home.gateway.adapters.modbus.PduModbusClient;
import home.gateway.adapters.modbus.TcpTransport;
import home.gateway.adapters.solarman.SolarmanV5Transport;
import home.gateway.adapters.store.CorruptRestoreFileException;
import home.gateway.adapters.store.JsonReserveStore;
import home.gateway.adapters.store.JsonRestoreStore;
import home.gateway.application.BatteryCommands;
import home.gateway.application.BatterySettings;
import home.gateway.application.CommandService;
import home.gateway.application.Commands;
import home.gateway.application.DisabledCommands;
@@ -62,6 +66,8 @@ public final class Gateway implements AutoCloseable {
private final Deque<AutoCloseable> resources = new ArrayDeque<>();
/** The one client of the controller: the MK01 drops a connection when a second one opens. */
private PduModbusClient heatPumpModbus;
/** The one client of the Deye's logger, shared by the pollers and the settings writes. */
private PduModbusClient deyeModbus;
private Gateway(GatewayConfig config, Clock clock, Runnable exit) {
this.config = config;
@@ -101,7 +107,8 @@ public final class Gateway implements AutoCloseable {
private void assemble() throws IOException {
List<Device> devices = devices();
requireCriticalDevicesEnabled(devices);
String token = config.commands().enabled() ? readToken(config.commands().tokenFile()) : null;
boolean writes = config.commands().enabled() || config.deyeCommands().enabled();
String token = writes ? readToken(config.commands().tokenFile()) : null;
InfluxWriter writer = new InfluxWriter(config.influx().settings());
scheduleFlush(writer);
@@ -118,9 +125,21 @@ public final class Gateway implements AutoCloseable {
if (commands != null) {
http.serveCommands(commands, token);
}
if (config.deyeCommands().enabled()) {
http.serveBattery(startBattery(snapshot, writer, poller), token);
}
http.start();
}
private BatteryCommands startBattery(Snapshot snapshot, InfluxWriter audit, Poller poller) {
var settings = config.deyeCommands();
return new BatterySettings(new ModbusBatteryRegisters(deyeModbus), snapshot,
new JsonReserveStore(settings.reserveFile()), settings.reserveFloorPct(), audit, clock,
System::nanoTime, settings.minInterval(),
() -> Thread.sleep(settings.readBackDelayMillis()),
() -> poller.requestPoll(BatterySettings.SETTINGS_DEVICE));
}
/**
* A restore file that cannot be read must not stop the heating side (polling, health), so the
* write path stays off instead, and never writes, until someone has looked at the moved file.
@@ -175,7 +194,7 @@ public final class Gateway implements AutoCloseable {
}
if (Switchable.isOn(devices.deye())) {
// One client for both, so the logger sees one request at a time.
var deyeModbus = deyeModbus(devices.deye());
deyeModbus = deyeModbus(devices.deye());
result.add(new DeyeDevice(deyeModbus));
result.add(new DeyeSettingsDevice(deyeModbus));
}
@@ -1,6 +1,7 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.BatterySettings;
import home.gateway.application.Device;
import home.gateway.domain.Reading;
@@ -19,7 +20,7 @@ import java.util.function.IntToDoubleFunction;
*/
public final class DeyeSettingsDevice implements Device {
public static final String NAME = "deye-settings";
public static final String NAME = BatterySettings.SETTINGS_DEVICE;
private static final int UNIT_ID = 1;
private static final int PROGRAMS = 6;
@@ -0,0 +1,32 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.application.BatteryRegisters;
import java.io.IOException;
/** The Deye's holding registers over the shared logger link: FC3 to read, FC16 to write. */
public final class ModbusBatteryRegisters implements BatteryRegisters {
private static final int UNIT_ID = 1;
private final ModbusClient modbus;
public ModbusBatteryRegisters(ModbusClient modbus) {
this.modbus = modbus;
}
@Override
public int[] read(int address, int count) throws IOException {
int[] words = modbus.readHoldingRegisters(UNIT_ID, address, count);
if (words.length != count) {
throw new IOException("Expected " + count + " registers from " + address + ", got " + words.length);
}
return words;
}
@Override
public void write(int address, int[] values) throws IOException {
modbus.writeMultipleRegisters(UNIT_ID, address, values);
}
}
@@ -0,0 +1,132 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import home.gateway.application.BatteryCommands;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
* The Deye settings the app may change (ADR-013), with the same token and throttle as {@code /commands}.
* {@code GET /battery/reserve} answers {pct, floorPct}; {@code POST /battery/reserve} (body: pct) and
* {@code POST /battery/schedule} (body: optional enabled, and slots with slot and optional start, soc,
* gridCharge) answer 200 applied, 422 rejected, 409 settings unknown or changed meanwhile, 429 too soon
* after the last write (S4), 502 failed (read back differs; the body says what was read, nothing is retried).
*/
class BatteryEndpoint implements HttpHandler {
private static final String RESERVE_PATH = "/battery/reserve";
private static final String SCHEDULE_PATH = "/battery/schedule";
private final BatteryCommands battery;
private final TokenGuard guard;
BatteryEndpoint(BatteryCommands battery, String token, Clock clock) {
this.battery = battery;
this.guard = new TokenGuard(token, clock);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
var method = exchange.getRequestMethod();
var path = exchange.getRequestURI().getPath();
if (!path.equals(RESERVE_PATH) && !path.equals(SCHEDULE_PATH)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (guard.admit(exchange)) {
dispatch(exchange, method, path);
}
}
}
private void dispatch(HttpExchange exchange, String method, String path) throws IOException {
if (method.equals("GET") && path.equals(RESERVE_PATH)) {
respond(exchange, 200, battery.reserve());
} else if (method.equals("POST")) {
post(exchange, path);
} else {
exchange.getResponseHeaders().set("Allow", path.equals(RESERVE_PATH) ? "GET, POST" : "POST");
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
}
}
private void post(HttpExchange exchange, String path) throws IOException {
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
var requester = JsonRequests.requester(exchange);
try {
var result = path.equals(RESERVE_PATH)
? battery.changeReserve(reservePct(body), requester)
: battery.changeSchedule(scheduleChange(body, requester));
respond(exchange, httpStatus(result.status()), result);
} catch (IllegalArgumentException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
}
}
private static int reservePct(JsonNode body) {
if (!body.path("pct").isInt()) {
throw new IllegalArgumentException("need an integer \"pct\"");
}
return body.get("pct").intValue();
}
private static ScheduleChange scheduleChange(JsonNode body, String requester) {
var enabled = body.path("enabled");
if (!enabled.isMissingNode() && !enabled.isBoolean()) {
throw new IllegalArgumentException("\"enabled\" must be true or false");
}
if (!body.path("slots").isMissingNode() && !body.path("slots").isArray()) {
throw new IllegalArgumentException("\"slots\" must be a list");
}
var slots = new ArrayList<SlotChange>();
for (JsonNode slot : body.path("slots")) {
slots.add(new SlotChange(integer(slot, "slot", true), integer(slot, "start", false),
integer(slot, "soc", false), bool(slot, "gridCharge")));
}
return new ScheduleChange(enabled.isBoolean() ? enabled.booleanValue() : null, List.copyOf(slots), requester);
}
private static Integer integer(JsonNode slot, String name, boolean required) {
var value = slot.path(name);
if (value.isInt()) {
return value.intValue();
}
if (required || !value.isMissingNode()) {
throw new IllegalArgumentException("\"" + name + "\" must be an integer");
}
return null;
}
private static Boolean bool(JsonNode slot, String name) {
var value = slot.path(name);
if (value.isBoolean()) {
return value.booleanValue();
}
if (!value.isMissingNode()) {
throw new IllegalArgumentException("\"" + name + "\" must be true or false");
}
return null;
}
private static int httpStatus(BatteryResult.Status status) {
return switch (status) {
case APPLIED -> 200;
case REJECTED -> 422;
case STALE -> 409;
case TOO_SOON -> 429;
case FAILED -> 502;
};
}
}
@@ -14,8 +14,6 @@ import home.gateway.domain.Setpoint;
import home.gateway.domain.VerifiedValue;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
@@ -23,9 +21,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/**
@@ -41,12 +37,6 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
*/
class CommandsEndpoint implements HttpHandler {
static final int MIN_TOKEN_LENGTH = 16;
private static final String DEFAULT_REQUESTER = "backend";
private static final int MAX_BODY_BYTES = 1024;
private static final Pattern JSON_TYPE = Pattern.compile("application/json\\s*(;.*)?", Pattern.CASE_INSENSITIVE);
private static final Pattern REQUESTER = Pattern.compile("[\\w.@+-]{1,64}");
private static final String BEARER = "Bearer ";
private static final String HEAT_PUMP = "heat_pump";
private static final String COMMANDS_PATH = "/commands";
private static final String RENEW_PATH = "/commands/renew";
@@ -63,18 +53,12 @@ class CommandsEndpoint implements HttpHandler {
private final Commands commands;
private final Snapshot snapshot;
private final byte[] token;
private final FailedLoginThrottle throttle;
private final TokenGuard guard;
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.guard = new TokenGuard(token, clock);
this.commands = commands;
this.snapshot = snapshot;
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
}
@Override
@@ -84,15 +68,7 @@ class CommandsEndpoint implements HttpHandler {
var path = exchange.getRequestURI().getPath();
if (!List.of(COMMANDS_PATH, RENEW_PATH, RESTORE_PATH).contains(path)) {
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
} else if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds()));
respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts"));
} else if (!authorized(exchange)) {
throttle.recordFailure();
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
} else {
} else if (guard.admit(exchange)) {
dispatch(exchange, method, path);
}
}
@@ -132,7 +108,7 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, 405, new GatewayHttpServer.Error("method not allowed"));
return;
}
var body = readJsonBody(exchange);
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
@@ -141,27 +117,18 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: need a text \"setpoint\""));
return;
}
var result = commands.restoreNow(setpoint.textValue(), requester(exchange));
var result = commands.restoreNow(setpoint.textValue(), JsonRequests.requester(exchange));
respond(exchange, httpStatus(result.outcome()), result);
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
return false;
}
var presented = header.substring(BEARER.length()).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(presented, token);
}
private void post(HttpExchange exchange) throws IOException {
var body = readJsonBody(exchange);
var body = JsonRequests.readJsonBody(exchange);
if (body == null) {
return;
}
Command command;
try {
command = parse(body, requester(exchange));
command = parse(body, JsonRequests.requester(exchange));
} catch (IllegalArgumentException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return;
@@ -170,47 +137,6 @@ class CommandsEndpoint implements HttpHandler {
respond(exchange, httpStatus(result.status()), result);
}
/** The parsed body, or null after answering 415, 413 or 400. */
private static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return null;
}
var body = readLimited(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return null;
}
try {
return JSON.readTree(body);
} catch (IOException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return null;
}
}
private static String requester(HttpExchange exchange) {
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
}
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
private static byte[] readLimited(HttpExchange exchange) throws IOException {
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
// The JDK server has already rejected a malformed length.
if (declared != null && Long.parseLong(declared.trim()) > MAX_BODY_BYTES) {
return null;
}
var bytes = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
return bytes.length > MAX_BODY_BYTES ? null : bytes;
}
/** The name ends up in an Influx tag, so anything unusual is not trusted. */
private static boolean isPlainName(String requestedBy) {
return requestedBy != null && REQUESTER.matcher(requestedBy).matches();
}
private static Command parse(JsonNode body, String requestedBy) {
var setpoint = body.path("setpoint");
var value = body.path("value");
@@ -5,6 +5,7 @@ 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.BatteryCommands;
import home.gateway.application.Commands;
import home.gateway.application.DeviceState;
import home.gateway.application.Snapshot;
@@ -20,7 +21,7 @@ import java.util.concurrent.Executors;
/**
* JSON API: read-only {@code /health} for monitoring and {@code /snapshot} for the app, plus
* {@code /commands} when enabled by {@link #serveCommands}. Only critical
* {@code /commands} and {@code /battery} when enabled by {@link #serveCommands} and {@link #serveBattery}. 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.
*/
@@ -43,7 +44,7 @@ public class GatewayHttpServer implements AutoCloseable {
/** The release tag, set as VERSION by the image; "dev" for a local build. */
private static final String VERSION = Objects.requireNonNullElse(System.getenv("VERSION"), "dev");
public static final int MIN_TOKEN_LENGTH = CommandsEndpoint.MIN_TOKEN_LENGTH;
public static final int MIN_TOKEN_LENGTH = TokenGuard.MIN_TOKEN_LENGTH;
private final Snapshot snapshot;
private final Clock clock;
@@ -68,6 +69,11 @@ public class GatewayHttpServer implements AutoCloseable {
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
}
/** Adds the battery settings endpoints (ADR-013) with their own token check; call before {@link #start()}. */
public void serveBattery(BatteryCommands battery, String token) {
server.createContext("/battery", new BatteryEndpoint(battery, token, clock));
}
public void start() {
server.start();
}
@@ -0,0 +1,63 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.regex.Pattern;
import static home.gateway.adapters.http.GatewayHttpServer.JSON;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** Reading the body and the requester of a write request. */
final class JsonRequests {
private static final String DEFAULT_REQUESTER = "backend";
private static final int MAX_BODY_BYTES = 1024;
private static final Pattern JSON_TYPE = Pattern.compile("application/json\\s*(;.*)?", Pattern.CASE_INSENSITIVE);
private static final Pattern REQUESTER = Pattern.compile("[\\w.@+-]{1,64}");
private JsonRequests() {
}
/** The parsed body, or null after answering 415, 413 or 400. */
static JsonNode readJsonBody(HttpExchange exchange) throws IOException {
var contentType = exchange.getRequestHeaders().getFirst("Content-Type");
if (contentType == null || !JSON_TYPE.matcher(contentType).matches()) {
respond(exchange, 415, new GatewayHttpServer.Error("content type must be application/json"));
return null;
}
var body = readLimited(exchange);
if (body == null) {
respond(exchange, 413, new GatewayHttpServer.Error("body larger than " + MAX_BODY_BYTES + " bytes"));
return null;
}
try {
return JSON.readTree(body);
} catch (IOException e) {
respond(exchange, 400, new GatewayHttpServer.Error("bad request: " + e.getMessage()));
return null;
}
}
static String requester(HttpExchange exchange) {
var requestedBy = exchange.getRequestHeaders().getFirst("X-Requested-By");
return isPlainName(requestedBy) ? requestedBy : DEFAULT_REQUESTER;
}
/** The body, or null when it is larger than the limit; never reads more than limit + 1 bytes. */
private static byte[] readLimited(HttpExchange exchange) throws IOException {
var declared = exchange.getRequestHeaders().getFirst("Content-Length");
// The JDK server has already rejected a malformed length.
if (declared != null && Long.parseLong(declared.trim()) > MAX_BODY_BYTES) {
return null;
}
var bytes = exchange.getRequestBody().readNBytes(MAX_BODY_BYTES + 1);
return bytes.length > MAX_BODY_BYTES ? null : bytes;
}
/** The name ends up in an Influx tag, so anything unusual is not trusted. */
private static boolean isPlainName(String requestedBy) {
return requestedBy != null && REQUESTER.matcher(requestedBy).matches();
}
}
@@ -0,0 +1,55 @@
package home.gateway.adapters.http;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import static home.gateway.adapters.http.GatewayHttpServer.respond;
/** The shared token of the write endpoints (S6) and the throttle on wrong ones. */
final class TokenGuard {
static final int MIN_TOKEN_LENGTH = 16;
private static final String BEARER = "Bearer ";
private final byte[] token;
private final FailedLoginThrottle throttle;
TokenGuard(String token, Clock clock) {
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
throw new IllegalArgumentException(
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
}
this.token = token.getBytes(StandardCharsets.UTF_8);
this.throttle = new FailedLoginThrottle(clock);
}
/** True if the request may go on; otherwise it has been answered with 429 or 401. */
boolean admit(HttpExchange exchange) throws IOException {
if (throttle.isBlocked()) {
exchange.getResponseHeaders().set("Retry-After",
String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds()));
respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts"));
return false;
}
if (!authorized(exchange)) {
throttle.recordFailure();
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
return false;
}
return true;
}
private boolean authorized(HttpExchange exchange) {
var header = exchange.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith(BEARER)) {
return false;
}
var presented = header.substring(BEARER.length()).getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(presented, token);
}
}
@@ -0,0 +1,37 @@
package home.gateway.adapters.store;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
/** Replaces a file in one step: a power cut leaves the old content or the new, never half. */
final class AtomicFile {
private AtomicFile() {
}
static void replace(Path file, byte[] content) throws IOException {
var temp = file.resolveSibling(file.getFileName() + ".tmp");
try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(content));
// Without this a power cut after the move can leave an empty file on the SD card.
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
syncDirectory(file);
}
/** Makes the rename durable. Not every platform can open a directory, so failure is ignored. */
private static void syncDirectory(Path file) {
try (var directory = FileChannel.open(file.toAbsolutePath().getParent(), StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// The file itself is already durable; only the rename may be lost.
}
}
}
@@ -0,0 +1,43 @@
package home.gateway.adapters.store;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.ReserveStore;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.OptionalInt;
/** The battery reserve as a small JSON file, {@code {"pct": 50}}, replaced atomically (ADR-013). */
public class JsonReserveStore implements ReserveStore {
private final Path file;
private final ObjectMapper json = new ObjectMapper()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS);
public JsonReserveStore(Path file) {
this.file = file;
}
/** @throws IOException when the file exists but is not a valid reserve */
@Override
public OptionalInt load() throws IOException {
if (!Files.exists(file)) {
return OptionalInt.empty();
}
return OptionalInt.of(json.readValue(file.toFile(), Stored.class).pct());
}
@Override
public void save(int pct) throws IOException {
AtomicFile.replace(file, json.writeValueAsBytes(new Stored(pct)));
}
private record Stored(@JsonProperty(required = true) int pct) {
}
}
@@ -12,12 +12,9 @@ import home.gateway.application.RestoreStore;
import home.gateway.domain.Restore;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@@ -68,24 +65,7 @@ public class JsonRestoreStore implements RestoreStore {
@Override
public void save(List<Restore> restores) throws IOException {
var temp = file.resolveSibling(file.getFileName() + ".tmp");
try (var channel = FileChannel.open(temp, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
channel.write(ByteBuffer.wrap(json.writeValueAsBytes(restores.stream().map(Stored::of).toList())));
// Without this a power cut after the move can leave an empty file on the SD card.
channel.force(true);
}
Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE);
syncDirectory();
}
/** Makes the rename durable. Not every platform can open a directory, so failure is ignored. */
private void syncDirectory() {
try (var directory = FileChannel.open(file.toAbsolutePath().getParent(), StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException | UnsupportedOperationException ignored) {
// The file itself is already durable; only the rename may be lost.
}
AtomicFile.replace(file, json.writeValueAsBytes(restores.stream().map(Stored::of).toList()));
}
/**
@@ -0,0 +1,18 @@
package home.gateway.application;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.ScheduleChange;
/** What the HTTP adapter may ask of the battery settings service (ADR-013); the service does every check. */
public interface BatteryCommands {
BatteryResult changeSchedule(ScheduleChange change);
BatteryResult changeReserve(int pct, String requestedBy);
/** The reserve now and the lowest one allowed now. */
Reserve reserve();
record Reserve(int pct, int floorPct) {
}
}
@@ -0,0 +1,11 @@
package home.gateway.application;
import java.io.IOException;
/** The inverter's holding registers, read and written as raw words; one request at a time. */
public interface BatteryRegisters {
int[] read(int address, int count) throws IOException;
void write(int address, int[] values) throws IOException;
}
@@ -0,0 +1,279 @@
package home.gateway.application;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.ProgramRegisters;
import home.gateway.domain.Reading;
import home.gateway.domain.Schedule;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.Slot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.LongSupplier;
/**
* The only way the gateway writes to the Deye (ADR-013): the Time of Use table and the backup
* reserve. A table change is checked as a whole against the last reading of the settings, then
* against the registers read right before writing, written in ranges and read back. Nothing is
* retried and nothing is restored: these are persistent settings, as in the Solarman app.
* Calls are serialized, as the logger takes one request at a time.
*/
public class BatterySettings implements BatteryCommands {
private static final Logger LOGGER = LoggerFactory.getLogger(BatterySettings.class);
private static final String AUDIT_DATABASE = "electricity";
private static final String AUDIT_MEASUREMENT = "deye_settings_audit";
/** The device whose last reading is the state we write on top of. */
public static final String SETTINGS_DEVICE = "deye-settings";
private static final String RESERVE_TARGET = "reserve";
static final Duration MAX_READING_AGE = Duration.ofMinutes(30);
static final int DEFAULT_RESERVE_PCT = 50;
// Above the shutdown SOC, where the inverter stops discharging, so the house keeps some energy.
private static final int SHUTDOWN_MARGIN_PCT = 10;
private final BatteryRegisters registers;
private final Snapshot snapshot;
private final ReserveStore store;
private final int configuredFloorPct;
private final ReadingSink audit;
private final Clock clock;
private final LongSupplier nanoTime;
private final Duration minInterval;
private final Pause pause;
private final Runnable pollSoon;
// Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier.
private final ReentrantLock lock = new ReentrantLock();
private final Map<Integer, Long> lastWrite = new HashMap<>();
private int storedReservePct = DEFAULT_RESERVE_PCT;
private record Settings(Schedule schedule, int shutdownSocPct) {
}
public BatterySettings(BatteryRegisters registers, Snapshot snapshot, ReserveStore store,
int floorPct, ReadingSink audit, Clock clock, LongSupplier nanoTime, Duration minInterval,
Pause pause, Runnable pollSoon) {
this.registers = registers;
this.snapshot = snapshot;
this.store = store;
this.configuredFloorPct = floorPct;
this.audit = audit;
this.clock = clock;
this.nanoTime = nanoTime;
this.minInterval = minInterval;
this.pause = pause;
this.pollSoon = pollSoon;
try {
storedReservePct = store.load().orElse(DEFAULT_RESERVE_PCT);
} catch (IOException e) {
// A higher reserve is the safe side, so an unreadable file means the default, not a stop.
LOGGER.error("Cannot read the battery reserve, using {} %: {}", DEFAULT_RESERVE_PCT, e.toString());
}
}
@Override
public Reserve reserve() {
int floor = floorPct();
return new Reserve(Math.max(storedReservePct, floor), floor);
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
lock.lock();
try {
return changeReserveLocked(pct, requestedBy);
} finally {
lock.unlock();
}
}
@Override
public BatteryResult changeSchedule(ScheduleChange change) {
lock.lock();
try {
return changeScheduleLocked(change);
} finally {
lock.unlock();
}
}
private BatteryResult changeReserveLocked(int pct, String requestedBy) {
var current = reserve();
if (pct < current.floorPct() || pct > Schedule.MAX_SOC) {
return BatteryResult.of(Status.REJECTED,
"the reserve must be " + current.floorPct() + " to " + Schedule.MAX_SOC + " %");
}
if (pct > current.pct()) {
// Raising it must not break an enabled schedule; lowering never can.
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
}
if (settings.schedule().enabled() && lowestSoc(settings.schedule()) < pct) {
return BatteryResult.of(Status.REJECTED, "the schedule is enabled and a slot SOC is below "
+ pct + " %: raise the slot SOCs first or disable the schedule");
}
}
try {
store.save(pct);
} catch (IOException e) {
auditChange(RESERVE_TARGET, requestedBy, "FAILED", current.pct(), pct);
return BatteryResult.of(Status.FAILED, "cannot save the reserve: " + e.getMessage());
}
storedReservePct = pct;
auditChange(RESERVE_TARGET, requestedBy, "APPLIED", current.pct(), pct);
return BatteryResult.of(Status.APPLIED, "applied");
}
private BatteryResult changeScheduleLocked(ScheduleChange change) {
var settings = freshSettings();
if (settings == null) {
return BatteryResult.of(Status.STALE, staleMessage());
}
var current = settings.schedule();
Schedule next;
try {
next = current.with(change);
} catch (IllegalArgumentException e) {
return rejected(change, e.getMessage());
}
var problem = next.problemReplacing(current, reserve().pct());
if (problem.isPresent()) {
return rejected(change, problem.get());
}
if (next.equals(current)) {
return rejected(change, "nothing to change");
}
int[] live;
try {
live = registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT);
} catch (IOException e) {
return BatteryResult.of(Status.FAILED, "cannot read the settings before writing: " + e.getMessage());
}
if (!ProgramRegisters.decode(live).equals(current)) {
return BatteryResult.of(Status.STALE,
"the inverter's settings changed since they were read (the Solarman app?): reload and retry");
}
var changes = ProgramRegisters.changes(live, next);
var tooSoon = changes.keySet().stream().filter(this::tooSoon).findFirst();
if (tooSoon.isPresent()) {
return BatteryResult.of(Status.TOO_SOON, "too soon after the last write to register " + tooSoon.get());
}
return write(change, live, changes);
}
private BatteryResult write(ScheduleChange change, int[] live, Map<Integer, Integer> changes) {
String problem = null;
changes.keySet().forEach(address -> lastWrite.put(address, nanoTime.getAsLong()));
try {
for (var range : ProgramRegisters.ranges(changes)) {
registers.write(range.address(), range.values());
}
pause.await();
} catch (IOException e) {
problem = "write failed: " + e.getMessage();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
problem = "interrupted";
}
var read = readBack(changes);
var failed = new ArrayList<Integer>();
for (var entry : changes.entrySet()) {
boolean took = entry.getValue().equals(read.get(entry.getKey()));
if (!took) {
failed.add(entry.getKey());
}
auditChange(String.valueOf(entry.getKey()), change.requestedBy(), took ? "APPLIED" : "FAILED",
live[entry.getKey() - ProgramRegisters.FIRST], entry.getValue());
}
pollSoon.run();
if (problem == null && failed.isEmpty()) {
return new BatteryResult(Status.APPLIED, "applied", read);
}
return new BatteryResult(Status.FAILED, (problem != null ? problem + "; " : "")
+ "registers not as written: " + failed + "; not retried", read);
}
/** What the inverter holds now at the written registers; empty if it cannot be read. */
private Map<Integer, Integer> readBack(Map<Integer, Integer> changes) {
try {
int[] words = registers.read(ProgramRegisters.FIRST, ProgramRegisters.COUNT);
var read = new LinkedHashMap<Integer, Integer>();
changes.keySet().forEach(address -> read.put(address, words[address - ProgramRegisters.FIRST]));
return read;
} catch (IOException e) {
LOGGER.error("Cannot read back the Deye settings: {}", e.toString());
return Map.of();
}
}
private BatteryResult rejected(ScheduleChange change, String message) {
LOGGER.warn("rejected battery change from {}: {}", change.requestedBy(), message);
return BatteryResult.of(Status.REJECTED, message);
}
private boolean tooSoon(int register) {
var last = lastWrite.get(register);
return last != null && nanoTime.getAsLong() - last < minInterval.toNanos();
}
private int floorPct() {
var settings = latestSettings();
return settings == null ? configuredFloorPct
: Math.max(configuredFloorPct, settings.shutdownSocPct() + SHUTDOWN_MARGIN_PCT);
}
private static int lowestSoc(Schedule schedule) {
return schedule.slots().stream().mapToInt(Slot::soc).min().orElseThrow();
}
private static String staleMessage() {
return "the inverter's settings were not read in the last " + MAX_READING_AGE.toMinutes() + " minutes";
}
/** The last reading of the settings if it is recent enough to write on top of, else null. */
private Settings freshSettings() {
return snapshot.state(SETTINGS_DEVICE)
.filter(state -> state.lastSuccess() != null
&& Duration.between(state.lastSuccess(), clock.instant()).compareTo(MAX_READING_AGE) <= 0)
.map(this::settingsOf).orElse(null);
}
/** The last reading of any age: the shutdown SOC hardly ever changes. */
private Settings latestSettings() {
return snapshot.state(SETTINGS_DEVICE).map(this::settingsOf).orElse(null);
}
private Settings settingsOf(DeviceState state) {
return state.readings().stream()
.filter(reading -> reading.measurement().equals("deye_settings"))
.findFirst().map(BatterySettings::fromReading).orElse(null);
}
private static Settings fromReading(Reading reading) {
var fields = reading.fields();
var slots = new ArrayList<Slot>();
for (int slot = 1; slot <= Schedule.SLOTS; slot++) {
String prefix = "prog" + slot + "_";
slots.add(new Slot(fields.get(prefix + "start_hhmm").intValue(), fields.get(prefix + "soc_pct").intValue(),
fields.get(prefix + "grid_charge") == 1.0));
}
return new Settings(new Schedule(fields.get("tou_enabled") == 1.0, fields.get("tou_days").intValue(), slots),
fields.get("shutdown_soc_pct").intValue());
}
private void auditChange(String target, String requestedBy, String status, int old, int value) {
audit.write(List.of(new Reading(AUDIT_DATABASE, AUDIT_MEASUREMENT,
Map.of("register", target, "requested_by", requestedBy, "status", status),
Map.of("old_value", (double) old, "new_value", (double) value), clock.instant())));
}
}
@@ -0,0 +1,14 @@
package home.gateway.application;
import java.io.IOException;
import java.util.OptionalInt;
/** The backup reserve in percent, kept on the Pi's disk so it survives a restart. */
public interface ReserveStore {
/** Empty when nothing was saved yet. */
OptionalInt load() throws IOException;
/** Replaces the stored value atomically. */
void save(int pct) throws IOException;
}
@@ -7,6 +7,7 @@ import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** The latest state of every device, safe to read while the pollers write. */
@@ -26,6 +27,10 @@ public class Snapshot {
states.computeIfPresent(name, (key, state) -> state.failed(now, error));
}
public Optional<DeviceState> state(String name) {
return Optional.ofNullable(states.get(name));
}
public List<DeviceState> states() {
return states.values().stream().sorted(Comparator.comparing(DeviceState::name)).toList();
}
@@ -0,0 +1,40 @@
package home.gateway.config;
import java.nio.file.Path;
import java.time.Duration;
import static home.gateway.config.Checks.positive;
import static home.gateway.config.Checks.required;
/**
* The write path to the Deye (ADR-013). Off unless {@code enabled}; it needs the Deye enabled and the
* token file of {@code commands}. A disabled section is not validated.
*
* @param reserveFile where the backup reserve is kept, next to the restore file
* @param reserveFloorPct default 30: the reserve never goes below it, nor below the shutdown SOC + 10
* @param minIntervalSeconds default 60: per register, S4
* @param readBackDelayMillis default 1000: the inverter needs about that long to take a value
*/
public record DeyeCommandsConfig(boolean enabled, Path reserveFile, Integer reserveFloorPct,
Integer minIntervalSeconds, Integer readBackDelayMillis) {
public DeyeCommandsConfig {
if (enabled) {
required(reserveFile, "reserveFile");
reserveFloorPct = positive(reserveFloorPct, 30, "reserveFloorPct");
if (reserveFloorPct > 100) {
throw new IllegalArgumentException("reserveFloorPct must be at most 100, got " + reserveFloorPct);
}
minIntervalSeconds = positive(minIntervalSeconds, 60, "minIntervalSeconds");
readBackDelayMillis = positive(readBackDelayMillis, 1000, "readBackDelayMillis");
}
}
static DeyeCommandsConfig disabled() {
return new DeyeCommandsConfig(false, null, null, null, null);
}
public Duration minInterval() {
return Duration.ofSeconds(minIntervalSeconds);
}
}
@@ -18,7 +18,7 @@ import static home.gateway.config.Checks.required;
*/
@JsonIgnoreProperties("_comment")
public record GatewayConfig(Influx influx, Http http, Watchdog watchdog, DevicesConfig devices,
CommandsConfig commands) {
CommandsConfig commands, DeyeCommandsConfig deyeCommands) {
public GatewayConfig {
required(influx, "influx");
@@ -26,6 +26,14 @@ public record GatewayConfig(Influx influx, Http http, Watchdog watchdog, Devices
http = http == null ? new Http(null, null) : http;
watchdog = watchdog == null ? new Watchdog(null, null) : watchdog;
commands = commands == null ? CommandsConfig.disabled() : commands;
deyeCommands = deyeCommands == null ? DeyeCommandsConfig.disabled() : deyeCommands;
if (deyeCommands.enabled()) {
if (!DevicesConfig.Switchable.isOn(devices.deye())) {
throw new IllegalArgumentException("deyeCommands.enabled needs devices.deye.enabled: "
+ "settings are written through the Deye's logger link");
}
required(commands.tokenFile(), "commands.tokenFile (deyeCommands uses the same token)");
}
if (commands.enabled() && !DevicesConfig.Switchable.isOn(devices.heatPump())) {
throw new IllegalArgumentException("commands.enabled needs devices.heatPump.enabled: "
+ "setpoints are written through the heat pump's connection");
@@ -0,0 +1,21 @@
package home.gateway.domain;
import java.util.Map;
/**
* The outcome of one battery change. REJECTED: refused by the rules, nothing written. STALE: the
* settings are unknown or changed since the app showed them, nothing written. TOO_SOON: S4.
* FAILED: a write or its read-back went wrong; {@code read} holds the registers as read afterwards.
*/
public record BatteryResult(Status status, String message, Map<Integer, Integer> read) {
public enum Status { APPLIED, REJECTED, STALE, TOO_SOON, FAILED }
public BatteryResult {
read = read == null ? Map.of() : Map.copyOf(read);
}
public static BatteryResult of(Status status, String message) {
return new BatteryResult(status, message, null);
}
}
@@ -0,0 +1,99 @@
package home.gateway.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* Where the {@link Schedule} lives in the Deye's holding registers (research/energy/README.md):
* 146 holds the Time of Use switch (bit 0) and the days (bits 1 to 7), then starts at 148,
* SOCs at 166 and charge sources at 172. The block FIRST..LAST is read in one request.
*/
public final class ProgramRegisters {
public static final int TIME_OF_USE = 146;
public static final int FIRST = TIME_OF_USE;
public static final int COUNT = 32;
private static final int START = 148;
private static final int SOC = 166;
private static final int SOURCE = 172;
private static final int GRID = 1;
private ProgramRegisters() {
}
/** Reads the block as the poller shows it: a source of 1 or 3 (grid, or grid and generator) is grid charge. */
public static Schedule decode(int[] words) {
var slots = new ArrayList<Slot>();
for (int i = 0; i < Schedule.SLOTS; i++) {
int source = words[SOURCE + i - FIRST];
slots.add(new Slot(words[START + i - FIRST], words[SOC + i - FIRST], (source & GRID) != 0));
}
return new Schedule((words[0] & 1) != 0, (words[0] >> 1) & 0x7F, slots);
}
/**
* The registers that must change so that the block read as {@code current} reads as {@code next},
* by address. The days stay as they are and a slot whose grid charge does not change keeps its
* source, so a generator value we never write is never overwritten; a changed one becomes 0 or 1.
*/
public static Map<Integer, Integer> changes(int[] current, Schedule next) {
var changes = new TreeMap<Integer, Integer>();
var before = decode(current);
if (before.enabled() != next.enabled()) {
changes.put(TIME_OF_USE, (current[0] & ~1) | (next.enabled() ? 1 : 0));
}
for (int i = 0; i < Schedule.SLOTS; i++) {
var slot = next.slots().get(i);
var old = before.slots().get(i);
putIfChanged(changes, START + i, old.start(), slot.start());
putIfChanged(changes, SOC + i, old.soc(), slot.soc());
if (old.gridCharge() != slot.gridCharge()) {
changes.put(SOURCE + i, slot.gridCharge() ? GRID : 0);
}
}
return changes;
}
/**
* Consecutive addresses as one range each, so one FC16 per range; the switch comes last, so a
* schedule is never enabled over a half written table.
*/
public static List<Range> ranges(Map<Integer, Integer> changes) {
var ranges = new ArrayList<Range>();
List<Integer> values = null;
int start = 0;
int previous = -2;
for (var change : new TreeMap<>(changes).entrySet()) {
if (change.getKey() != previous + 1) {
if (values != null) {
ranges.add(Range.of(start, values));
}
values = new ArrayList<>();
start = change.getKey();
}
values.add(change.getValue());
previous = change.getKey();
}
if (values != null) {
ranges.add(Range.of(start, values));
}
ranges.sort((a, b) -> Boolean.compare(a.address() == TIME_OF_USE, b.address() == TIME_OF_USE));
return ranges;
}
private static void putIfChanged(Map<Integer, Integer> changes, int address, int old, int value) {
if (old != value) {
changes.put(address, value);
}
}
/** Registers from {@code address} on, written with one FC16. */
public record Range(int address, int[] values) {
static Range of(int address, List<Integer> values) {
return new Range(address, values.stream().mapToInt(Integer::intValue).toArray());
}
}
}
@@ -0,0 +1,89 @@
package home.gateway.domain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
/**
* The Deye's Time of Use: the switch, the days it runs (bits Monday to Sunday, kept as the inverter
* holds them) and six slots. The rules below are the allowlist of ADR-013.
*/
public record Schedule(boolean enabled, int days, List<Slot> slots) {
public static final int SLOTS = 6;
public static final int MAX_SOC = 100;
public Schedule {
if (slots.size() != SLOTS) {
throw new IllegalArgumentException("a schedule has " + SLOTS + " slots");
}
slots = List.copyOf(slots);
}
/** @throws IllegalArgumentException for a slot outside 1 to 6 or named twice */
public Schedule with(ScheduleChange change) {
var next = new ArrayList<>(slots);
var seen = new HashSet<Integer>();
for (SlotChange slotChange : change.slots()) {
if (slotChange.slot() < 1 || slotChange.slot() > SLOTS || !seen.add(slotChange.slot())) {
throw new IllegalArgumentException("slots are numbered 1 to " + SLOTS + ", each once");
}
var slot = next.get(slotChange.slot() - 1);
if (slotChange.start() != null) {
slot = slot.withStart(slotChange.start());
}
if (slotChange.soc() != null) {
slot = slot.withSoc(slotChange.soc());
}
if (slotChange.gridCharge() != null) {
slot = slot.withGridCharge(slotChange.gridCharge());
}
next.set(slotChange.slot() - 1, slot);
}
return new Schedule(change.enabled() != null ? change.enabled() : enabled, days, next);
}
/**
* Why this schedule may not replace {@code previous}, or empty. Only what changed is checked,
* except that an enabled schedule must hold the reserve in every slot and a changed start
* needs strictly increasing starts, so the table is valid as a whole.
*/
public Optional<String> problemReplacing(Schedule previous, int reserve) {
boolean startChanged = false;
for (int i = 0; i < SLOTS; i++) {
var slot = slots.get(i);
var before = previous.slots.get(i);
if (slot.start() != before.start()) {
startChanged = true;
if (!isTime(slot.start())) {
return Optional.of("start of slot " + (i + 1) + " must be HHMM, 0000 to 2359");
}
}
if (slot.soc() != before.soc() && (slot.soc() < reserve || slot.soc() > MAX_SOC)) {
return Optional.of("SOC of slot " + (i + 1) + " must be " + reserve + " to " + MAX_SOC);
}
if (enabled && slot.soc() < reserve) {
return Optional.of("SOC of slot " + (i + 1) + " is below the reserve " + reserve
+ ", which an enabled schedule must hold");
}
}
if (startChanged && !startsIncrease()) {
return Optional.of("slot starts must be strictly increasing from slot 1 to 6");
}
return Optional.empty();
}
private boolean startsIncrease() {
for (int i = 1; i < SLOTS; i++) {
if (slots.get(i).start() <= slots.get(i - 1).start()) {
return false;
}
}
return true;
}
private static boolean isTime(int hhmm) {
return hhmm >= 0 && hhmm / 100 < 24 && hhmm % 100 < 60;
}
}
@@ -0,0 +1,11 @@
package home.gateway.domain;
import java.util.List;
/** A requested change of the Time of Use switch and of single slots; a null switch stays as it is. */
public record ScheduleChange(Boolean enabled, List<SlotChange> slots, String requestedBy) {
public ScheduleChange {
slots = List.copyOf(slots);
}
}
@@ -0,0 +1,17 @@
package home.gateway.domain;
/** One program slot of the Deye: it starts at {@code start} (HHMM) and runs to the next slot's start. */
public record Slot(int start, int soc, boolean gridCharge) {
public Slot withStart(int newStart) {
return new Slot(newStart, soc, gridCharge);
}
public Slot withSoc(int newSoc) {
return new Slot(start, newSoc, gridCharge);
}
public Slot withGridCharge(boolean newGridCharge) {
return new Slot(start, soc, newGridCharge);
}
}
@@ -0,0 +1,5 @@
package home.gateway.domain;
/** What to change in one slot (1 to 6); a null field stays as it is. */
public record SlotChange(int slot, Integer start, Integer soc, Boolean gridCharge) {
}
@@ -0,0 +1,53 @@
package home.gateway.adapters.deye;
import home.gateway.adapters.modbus.MockModbusServer;
import home.gateway.adapters.modbus.PduModbusClient;
import home.gateway.adapters.modbus.TcpTransport;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class ModbusBatteryRegistersTest {
private MockModbusServer server;
private TcpTransport transport;
private ModbusBatteryRegisters registers;
@BeforeEach
void start() throws IOException {
server = new MockModbusServer();
for (int address = 146; address < 178; address++) {
server.holding(address, address);
}
transport = new TcpTransport(new TcpTransport.Settings("localhost", server.port(), Duration.ofSeconds(2),
Duration.ofSeconds(2), Duration.ZERO));
registers = new ModbusBatteryRegisters(new PduModbusClient(transport));
}
@AfterEach
void stop() throws IOException {
transport.close();
server.close();
}
@Test
void readsABlockFromUnitOneAndWritesARangeWithOneRequest() throws IOException {
registers.write(166, new int[] {90, 95});
assertEquals(1, server.multipleWrites().size());
assertArrayEquals(new int[] {1, 166, 90, 95}, server.multipleWrites().get(0));
assertArrayEquals(new int[] {90, 95, 168}, registers.read(166, 3));
}
@Test
void failsWhenTheBlockCannotBeRead() {
assertThrows(IOException.class, () -> registers.read(300, 2));
}
}
@@ -0,0 +1,178 @@
package home.gateway.adapters.http;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import home.gateway.application.BatteryCommands;
import home.gateway.application.Snapshot;
import home.gateway.domain.BatteryResult;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BatteryEndpointTest {
private static final String TOKEN = "0123456789abcdef-secret";
private final ObjectMapper json = new ObjectMapper();
private final HttpClient client = HttpClient.newHttpClient();
private final List<ScheduleChange> schedules = new ArrayList<>();
private final List<String> reserves = new ArrayList<>();
private BatteryResult next = BatteryResult.of(Status.APPLIED, "applied");
private GatewayHttpServer server;
private final BatteryCommands battery = new BatteryCommands() {
@Override
public BatteryResult changeSchedule(ScheduleChange change) {
schedules.add(change);
return next;
}
@Override
public BatteryResult changeReserve(int pct, String requestedBy) {
reserves.add(pct + " by " + requestedBy);
return next;
}
@Override
public Reserve reserve() {
return new Reserve(50, 30);
}
};
@BeforeEach
void startServer() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-10-24T10:00:00Z"), ZoneId.of("UTC"));
server = new GatewayHttpServer(new Snapshot(), clock, new InetSocketAddress(0), Set.of());
server.serveBattery(battery, TOKEN);
server.start();
}
@AfterEach
void stopServer() {
server.close();
}
private HttpResponse<String> send(String path, String method, String body, String... headers) throws Exception {
var builder = HttpRequest.newBuilder(URI.create("http://localhost:" + server.port() + path))
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (body != null) {
builder.header("Content-Type", "application/json");
}
for (int i = 0; i < headers.length; i += 2) {
builder.setHeader(headers[i], headers[i + 1]);
}
return client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
}
private HttpResponse<String> authorized(String path, String method, String body) throws Exception {
return send(path, method, body, "Authorization", "Bearer " + TOKEN, "X-Requested-By", "ala");
}
@Test
void getsTheReserveAndItsFloor() throws Exception {
var response = authorized("/battery/reserve", "GET", null);
assertEquals(200, response.statusCode());
assertEquals(json.readTree("{\"pct\":50,\"floorPct\":30}"), json.readTree(response.body()));
}
@Test
void postsAReserve() throws Exception {
var response = authorized("/battery/reserve", "POST", "{\"pct\":60}");
assertEquals(200, response.statusCode());
assertEquals(List.of("60 by ala"), reserves);
}
@Test
void postsAScheduleChange() throws Exception {
var body = """
{"enabled": true, "slots": [{"slot": 2, "soc": 90, "gridCharge": true},
{"slot": 3, "start": 930}]}""";
var response = authorized("/battery/schedule", "POST", body);
assertEquals(200, response.statusCode());
assertEquals(List.of(new ScheduleChange(true, List.of(new SlotChange(2, null, 90, true),
new SlotChange(3, 930, null, null)), "ala")), schedules);
}
@Test
void statusFollowsTheOutcomeAndTheBodyCarriesWhatWasRead() throws Exception {
var expected = Map.of(Status.APPLIED, 200, Status.REJECTED, 422, Status.STALE, 409,
Status.TOO_SOON, 429, Status.FAILED, 502);
for (var entry : expected.entrySet()) {
next = new BatteryResult(entry.getKey(), "m", Map.of(167, 80));
var response = authorized("/battery/schedule", "POST", "{\"enabled\":false}");
assertEquals(entry.getValue(), response.statusCode(), entry.getKey().name());
JsonNode body = json.readTree(response.body());
assertEquals(entry.getKey().name(), body.get("status").asText());
assertEquals(80, body.get("read").get("167").asInt());
}
}
@Test
void badBodiesAre400AndReachNoService() throws Exception {
for (var body : List.of("{\"pct\":\"60\"}", "{}", "{\"pct\":60.5}")) {
assertEquals(400, authorized("/battery/reserve", "POST", body).statusCode(), body);
}
for (var body : List.of("{\"enabled\":\"yes\"}", "{\"slots\":{}}", "{\"slots\":[{\"soc\":90}]}",
"{\"slots\":[{\"slot\":1,\"soc\":\"9\"}]}", "{\"slots\":[{\"slot\":1,\"gridCharge\":1}]}",
"{\"slots\":[{\"slot\":1,\"start\":\"0930\"}]}")) {
assertEquals(400, authorized("/battery/schedule", "POST", body).statusCode(), body);
}
assertEquals(415, send("/battery/reserve", "POST", null, "Authorization", "Bearer " + TOKEN).statusCode());
assertEquals(400, authorized("/battery/reserve", "POST", "not json").statusCode());
assertEquals(List.of(), schedules);
assertEquals(List.of(), reserves);
}
@Test
void needsTheTokenAndThrottlesWrongOnes() throws Exception {
assertEquals(401, send("/battery/reserve", "GET", null).statusCode());
assertEquals(401, send("/battery/schedule", "POST", "{}", "Authorization", "Bearer wrong-token-value-xx")
.statusCode());
for (int i = 0; i < FailedLoginThrottle.MAX_FAILURES; i++) {
send("/battery/reserve", "GET", null);
}
assertEquals(429, authorized("/battery/reserve", "GET", null).statusCode());
assertEquals(List.of(), schedules);
}
@Test
void knowsOnlyItsPathsAndMethods() throws Exception {
assertEquals(404, authorized("/battery/other", "GET", null).statusCode());
assertEquals(405, authorized("/battery/schedule", "GET", null).statusCode());
assertEquals(405, authorized("/battery/reserve", "DELETE", null).statusCode());
}
@Test
void refusesAShortToken() {
var other = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
assertThrows(IllegalArgumentException.class, () -> new BatteryEndpoint(battery, "short", other));
}
}
@@ -0,0 +1,50 @@
package home.gateway.adapters.store;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class JsonReserveStoreTest {
@TempDir
Path dir;
@Test
void missingFileIsEmpty() throws IOException {
assertEquals(OptionalInt.empty(), new JsonReserveStore(dir.resolve("reserve.json")).load());
}
@Test
void aSavedReserveComesBackAsASmallJsonFile() throws IOException {
var file = dir.resolve("reserve.json");
var store = new JsonReserveStore(file);
store.save(60);
store.save(55);
assertEquals(OptionalInt.of(55), new JsonReserveStore(file).load());
assertEquals("{\"pct\":55}", Files.readString(file));
try (var files = Files.list(dir)) {
assertEquals(List.of(file), files.toList());
}
}
@Test
void anUnusableFileFailsTheLoad() throws IOException {
var file = dir.resolve("reserve.json");
var store = new JsonReserveStore(file);
for (var content : List.of("", "not json", "{}", "{\"pct\":null}", "{\"pct\":\"50\"}", "[50]",
"{\"pct\":50,\"extra\":1}")) {
Files.writeString(file, content);
assertThrows(IOException.class, store::load, content);
}
}
}
@@ -0,0 +1,566 @@
package home.gateway.application;
import home.gateway.adapters.deye.DeyeSettingsDevice;
import home.gateway.adapters.deye.ModbusBatteryRegisters;
import home.gateway.adapters.modbus.ModbusClient;
import home.gateway.domain.BatteryResult.Status;
import home.gateway.domain.Reading;
import home.gateway.domain.ScheduleChange;
import home.gateway.domain.SlotChange;
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.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalInt;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The service runs against the real settings device and a fake inverter, so the snapshot it checks is
* exactly what the poller would have stored.
*/
class BatterySettingsTest {
private static final Instant T0 = Instant.parse("2026-10-24T12:00:00Z");
/** The settings read on 2026-10-24 (research/energy/README.md), writable and breakable. */
private static final class FakeInverter implements ModbusClient {
final Map<Integer, Integer> registers = new HashMap<>();
final List<int[]> writes = new ArrayList<>();
int reads;
int failReadNumber;
boolean failWrites;
boolean ignoreWrites;
Runnable beforeRead = () -> { };
FakeInverter() {
registers.putAll(Map.of(98, 1, 102, 280, 104, 100, 108, 80, 109, 120, 111, 1, 115, 20, 116, 50, 117, 35));
registers.putAll(Map.of(127, 30, 128, 40, 130, 1, 141, 1, 142, 2, 143, 9000, 145, 1, 146, 254,
231, 1000, 233, 3100));
int[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int slot = 0; slot < 6; slot++) {
registers.put(148 + slot, starts[slot]);
registers.put(154 + slot, 10000);
registers.put(160 + slot, 4900);
registers.put(166 + slot, 80);
registers.put(172 + slot, 0);
}
}
@Override
public int[] readHoldingRegisters(int unitId, int address, int count) throws IOException {
beforeRead.run();
if (++reads == failReadNumber) {
throw new IOException("read timeout");
}
int[] words = new int[count];
for (int i = 0; i < count; i++) {
words[i] = registers.getOrDefault(address + i, 0);
}
return words;
}
@Override
public int[] readInputRegisters(int unitId, int address, int count) {
throw new AssertionError("FC3 only");
}
@Override
public void writeSingleRegister(int unitId, int address, int value) {
throw new AssertionError("battery settings use FC16");
}
@Override
public void writeMultipleRegisters(int unitId, int address, int[] values) throws IOException {
int[] record = new int[1 + values.length];
record[0] = address;
System.arraycopy(values, 0, record, 1, values.length);
writes.add(record);
if (failWrites) {
throw new IOException("write timeout");
}
for (int i = 0; i < values.length && !ignoreWrites; i++) {
registers.put(address + i, values[i]);
}
}
}
private final FakeInverter inverter = new FakeInverter();
private final DeyeSettingsDevice settingsDevice = new DeyeSettingsDevice(inverter);
private final Snapshot snapshot = new Snapshot();
private final List<Reading> audit = new ArrayList<>();
private final MutableClock clock = new MutableClock();
private int storedReserve = -1;
private boolean loadFails;
private boolean saveFails;
private int repolls;
private int floor = 30;
private final ReserveStore store = new ReserveStore() {
@Override
public OptionalInt load() throws IOException {
if (loadFails) {
throw new IOException("corrupt");
}
return storedReserve < 0 ? OptionalInt.empty() : OptionalInt.of(storedReserve);
}
@Override
public void save(int pct) throws IOException {
if (saveFails) {
throw new IOException("disk full");
}
storedReserve = pct;
}
};
private BatterySettings service() {
snapshot.register(DeyeSettingsDevice.NAME, Duration.ofMinutes(10), T0);
poll();
return new BatterySettings(new ModbusBatteryRegisters(inverter), snapshot, store, floor, audit::addAll,
clock, clock::nanos, Duration.ofSeconds(60), () -> { }, () -> repolls++);
}
/** What the poller does every ten minutes; it does not count as one of the service's reads. */
private void poll() {
int before = inverter.reads;
try {
snapshot.recordSuccess(DeyeSettingsDevice.NAME, clock.instant(), settingsDevice.read(clock.instant()));
} catch (IOException e) {
throw new AssertionError(e);
}
inverter.reads = before;
}
private static ScheduleChange change(Boolean enabled, SlotChange... slots) {
return new ScheduleChange(enabled, List.of(slots), "ala");
}
private static SlotChange soc(int slot, int soc) {
return new SlotChange(slot, null, soc, null);
}
private static SlotChange start(int slot, int start) {
return new SlotChange(slot, start, null, null);
}
private static SlotChange grid(int slot, boolean grid) {
return new SlotChange(slot, null, null, grid);
}
private void assertNothingWritten() {
assertTrue(inverter.writes.isEmpty(), "no register may be written");
assertTrue(audit.isEmpty());
}
@Test
void writesTheChangedSocAndReadsItBack() {
var result = service().changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.APPLIED, result.status());
assertEquals(1, inverter.writes.size());
assertArrayEquals(new int[] {167, 90}, inverter.writes.get(0));
assertEquals(Map.of(167, 90), result.read());
assertEquals(1, repolls);
}
@Test
void writesConsecutiveRegistersWithOneRequestAndTheSwitchLast() {
var result = service().changeSchedule(change(true, soc(2, 90), soc(3, 95), start(5, 1800), grid(1, true)));
assertEquals(Status.APPLIED, result.status());
assertEquals(4, inverter.writes.size());
assertArrayEquals(new int[] {152, 1800}, inverter.writes.get(0));
assertArrayEquals(new int[] {167, 90, 95}, inverter.writes.get(1));
assertArrayEquals(new int[] {172, 1}, inverter.writes.get(2));
assertArrayEquals(new int[] {146, 255}, inverter.writes.get(3));
}
@Test
void keepsTheDaysWhenSwitchingTimeOfUse() {
inverter.registers.put(146, 0b1010_1010);
var service = service();
assertEquals(Status.APPLIED, service.changeSchedule(change(false, soc(1, 90))).status());
assertEquals(0b1010_1010, inverter.registers.get(146), "already off: not written");
assertEquals(1, inverter.writes.size());
clock.advance(Duration.ofMinutes(5));
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(true)).status());
assertEquals(0b1010_1011, inverter.registers.get(146));
}
@Test
void auditsEveryRegisterIntoTheElectricityDatabase() {
service().changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(2, audit.size());
var first = audit.get(0);
assertEquals("electricity", first.database());
assertEquals("deye_settings_audit", first.measurement());
assertEquals(Map.of("register", "167", "requested_by", "ala", "status", "APPLIED"), first.tags());
assertEquals(Map.of("old_value", 80.0, "new_value", 90.0), first.fields());
assertEquals("168", audit.get(1).tags().get("register"));
}
@Test
void refusesToEnableWhileASlotSocIsBelowTheReserve() {
inverter.registers.put(168, 45);
var service = service();
var result = service.changeSchedule(change(true));
assertEquals(Status.REJECTED, result.status());
assertTrue(result.message().contains("below the reserve 50"));
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(true, soc(3, 50))).status());
}
@Test
void keepsAnEnabledScheduleAboveTheReserve() {
inverter.registers.put(146, 255);
inverter.registers.put(168, 45);
var result = service().changeSchedule(change(null, start(1, 130)));
assertEquals(Status.REJECTED, result.status());
assertNothingWritten();
}
@Test
void socMustBeFromTheCurrentReserveTo100() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(1, 49))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(1, 101))).status());
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 100))).status());
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 50))).status());
}
@Test
void theSocMinimumFollowsTheReserve() {
var service = service();
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 40))).status());
}
@Test
void startsAreValidTimesAndStrictlyIncreasing() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, 160))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, 2400))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(1, -100))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(2, 100))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(6, 1700))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, start(3, 400))).status());
assertNothingWritten();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, start(6, 2359))).status());
}
@Test
void validatesTheWholeTableBeforeWritingAnything() {
var result = service().changeSchedule(change(null, soc(1, 90), soc(2, 95), start(4, 400)));
assertEquals(Status.REJECTED, result.status());
assertNothingWritten();
}
@Test
void rejectsUnknownOrRepeatedSlotsAndEmptyChanges() {
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(0, 90))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(7, 90))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(2, 90), grid(2, true))).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null)).status());
assertEquals(Status.REJECTED, service.changeSchedule(change(null, soc(2, 80))).status());
assertNothingWritten();
}
@Test
void gridChargeIsWrittenAsZeroOrOneAndAGeneratorValueIsLeftAlone() {
inverter.registers.put(172, 3);
inverter.registers.put(173, 2);
var service = service();
assertEquals(Status.REJECTED, service.changeSchedule(change(null, grid(1, true))).status(), "no change");
assertEquals(Status.APPLIED, service.changeSchedule(change(null, grid(1, false), grid(2, true),
soc(6, 90))).status());
assertEquals(0, inverter.registers.get(172));
assertEquals(1, inverter.registers.get(173));
assertEquals(0, inverter.registers.get(174));
}
@Test
void refusesWithoutAReadingOfTheSettings() {
snapshot.register(DeyeSettingsDevice.NAME, Duration.ofMinutes(10), T0);
var service = new BatterySettings(new ModbusBatteryRegisters(inverter), snapshot, store, floor,
audit::addAll, clock, clock::nanos, Duration.ofSeconds(60), () -> { }, () -> repolls++);
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.STALE, result.status());
assertNothingWritten();
assertEquals(0, inverter.reads);
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
}
@Test
void refusesOnAReadingOlderThanThirtyMinutes() {
var service = service();
clock.advance(Duration.ofMinutes(31));
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(1, 90))).status());
}
@Test
void refusesWhenTheInverterChangedSinceTheReading() {
var service = service();
inverter.registers.put(167, 70);
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.STALE, result.status());
assertTrue(result.message().contains("changed"));
assertNothingWritten();
assertEquals(0, repolls);
}
@Test
void refusesWhenTheDaysChangedSinceTheReading() {
var service = service();
inverter.registers.put(146, 0b0011_1110);
assertEquals(Status.STALE, service.changeSchedule(change(null, soc(1, 90))).status());
assertNothingWritten();
}
@Test
void failsWithoutWritingWhenTheRegistersCannotBeReadFirst() {
var service = service();
inverter.failReadNumber = 1;
var result = service.changeSchedule(change(null, soc(1, 90)));
assertEquals(Status.FAILED, result.status());
assertNothingWritten();
}
@Test
void reportsAFailedWriteAuditsItAndDoesNotRetry() {
var service = service();
inverter.failWrites = true;
var result = service.changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(Status.FAILED, result.status());
assertTrue(result.message().contains("write timeout"));
assertEquals(1, inverter.writes.size());
assertEquals(Map.of(167, 80, 168, 80), result.read());
assertEquals(List.of("FAILED", "FAILED"), audit.stream().map(r -> r.tags().get("status")).toList());
}
@Test
void reportsWhatWasReadWhenTheReadBackDiffers() {
var service = service();
inverter.ignoreWrites = true;
var result = service.changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.FAILED, result.status());
assertEquals(Map.of(167, 80), result.read());
assertEquals(1, inverter.writes.size());
assertEquals("FAILED", audit.get(0).tags().get("status"));
}
@Test
void reportsPartialSuccessPerRegister() {
var service = service();
inverter.beforeRead = () -> {
if (inverter.reads == 1) {
inverter.registers.put(167, 1);
}
};
var result = service.changeSchedule(change(null, soc(2, 90), soc(3, 95)));
assertEquals(Status.FAILED, result.status());
assertEquals(List.of("FAILED", "APPLIED"), audit.stream().map(r -> r.tags().get("status")).toList());
}
@Test
void failsWhenTheReadBackCannotBeRead() {
var service = service();
inverter.failReadNumber = 2;
var result = service.changeSchedule(change(null, soc(2, 90)));
assertEquals(Status.FAILED, result.status());
assertEquals(Map.of(), result.read());
assertEquals("FAILED", audit.get(0).tags().get("status"));
assertEquals(1, inverter.writes.size());
}
@Test
void limitsTheRateOfWritesPerRegister() {
var service = service();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 90))).status());
clock.advance(Duration.ofSeconds(10));
poll();
assertEquals(Status.TOO_SOON, service.changeSchedule(change(null, soc(2, 95))).status());
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(3, 95))).status());
clock.advance(Duration.ofSeconds(60));
poll();
assertEquals(Status.APPLIED, service.changeSchedule(change(null, soc(2, 95))).status());
assertEquals(3, audit.size());
}
@Test
void theReserveDefaultsTo50AndTheFloorToTheConfiguredOne() {
var reserve = service().reserve();
assertEquals(50, reserve.pct());
assertEquals(30, reserve.floorPct());
}
@Test
void theFloorIsTheShutdownSocPlusTenWhenThatIsHigher() {
inverter.registers.put(115, 45);
storedReserve = 40;
var reserve = service().reserve();
assertEquals(55, reserve.floorPct());
assertEquals(55, reserve.pct());
}
@Test
void usesTheStoredReserve() {
storedReserve = 65;
assertEquals(65, service().reserve().pct());
}
@Test
void anUnreadableReserveFileMeansTheDefault() {
loadFails = true;
assertEquals(50, service().reserve().pct());
}
@Test
void changesTheReserveStoresItAndAuditsIt() {
var service = service();
var result = service.changeReserve(40, "ala");
assertEquals(Status.APPLIED, result.status());
assertEquals(40, storedReserve);
assertEquals(40, service.reserve().pct());
assertEquals(Map.of("register", "reserve", "requested_by", "ala", "status", "APPLIED"),
audit.get(0).tags());
assertEquals(Map.of("old_value", 50.0, "new_value", 40.0), audit.get(0).fields());
assertTrue(inverter.writes.isEmpty(), "the reserve is the app's, nothing goes to the inverter");
}
@Test
void theReserveStaysWithinTheFloorAnd100() {
var service = service();
assertEquals(Status.REJECTED, service.changeReserve(29, "ala").status());
assertEquals(Status.REJECTED, service.changeReserve(101, "ala").status());
assertEquals(-1, storedReserve);
assertTrue(audit.isEmpty());
}
@Test
void raisingTheReserveAboveASlotSocIsRefusedWhileTheScheduleIsEnabled() {
inverter.registers.put(146, 255);
var service = service();
var result = service.changeReserve(85, "ala");
assertEquals(Status.REJECTED, result.status());
assertTrue(result.message().contains("raise the slot SOCs first"));
assertEquals(-1, storedReserve);
assertEquals(Status.APPLIED, service.changeReserve(80, "ala").status());
}
@Test
void raisingTheReserveIsFineWhileTheScheduleIsOff() {
assertEquals(Status.APPLIED, service().changeReserve(90, "ala").status());
assertEquals(90, storedReserve);
}
@Test
void raisingTheReserveNeedsAFreshReadingButLoweringDoesNot() {
var service = service();
clock.advance(Duration.ofMinutes(45));
assertEquals(Status.STALE, service.changeReserve(60, "ala").status());
assertEquals(Status.APPLIED, service.changeReserve(40, "ala").status());
}
@Test
void aReserveThatCannotBeSavedFailsAndStaysAsItWas() {
var service = service();
saveFails = true;
var result = service.changeReserve(40, "ala");
assertEquals(Status.FAILED, result.status());
assertEquals(50, service.reserve().pct());
assertEquals("FAILED", audit.get(0).tags().get("status"));
}
private static final class MutableClock extends Clock {
private Instant now = T0;
private long nanos;
void advance(Duration by) {
now = now.plus(by);
nanos += by.toNanos();
}
long nanos() {
return nanos;
}
@Override
public ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
}
@@ -22,6 +22,14 @@ class SnapshotTest {
private final Snapshot snapshot = new Snapshot();
@Test
void findsOneDeviceByName() {
snapshot.register("pump", INTERVAL, T0);
assertEquals("pump", snapshot.state("pump").orElseThrow().name());
assertTrue(snapshot.state("other").isEmpty());
}
@Test
void registeredDeviceHasNoAttemptYet() {
snapshot.register("pump", INTERVAL, T0);
@@ -197,6 +197,44 @@ class GatewayConfigLoaderTest {
assertTrue(error.getMessage().contains("Cannot read"), error.getMessage());
}
private static final String DEYE = "\"deye\": {\"enabled\": true, \"host\": \"localhost\", \"loggerSerial\": 1}";
private static final String TOKEN = "\"commands\": {\"tokenFile\": \"/t/token\"}";
private static String withDeyeCommands(String devices, String commands, String deyeCommands) {
return "{" + INFLUX + ", \"devices\": {" + devices + "}, " + commands + ", \"deyeCommands\": "
+ deyeCommands + "}";
}
@Test
void deyeCommandsAreOffWhenTheSectionIsMissingOrNotEnabled() throws IOException {
assertFalse(load("{" + INFLUX + ", \"devices\": {}}").deyeCommands().enabled());
assertFalse(load(withDeyeCommands("", TOKEN, "{\"reserveFloorPct\": 0}")).deyeCommands().enabled());
}
@Test
void readsEnabledDeyeCommandsWithDefaults() throws IOException {
var deyeCommands = load(withDeyeCommands(DEYE, TOKEN, "{\"enabled\": true, \"reserveFile\": \"/t/reserve.json\"}"))
.deyeCommands();
assertTrue(deyeCommands.enabled());
assertEquals(Path.of("/t/reserve.json"), deyeCommands.reserveFile());
assertEquals(30, deyeCommands.reserveFloorPct());
assertEquals(Duration.ofSeconds(60), deyeCommands.minInterval());
assertEquals(1000, deyeCommands.readBackDelayMillis());
}
@Test
void rejectsDeyeCommandsWithoutTheDeyeTheTokenOrAReserveFile() {
var enabled = "{\"enabled\": true, \"reserveFile\": \"/t/reserve.json\"}";
assertRejected(withDeyeCommands("", TOKEN, enabled), "", "deyeCommands.enabled needs devices.deye.enabled");
assertRejected(withDeyeCommands(DEYE, "\"commands\": {}", enabled), "", "commands.tokenFile");
assertRejected(withDeyeCommands(DEYE, TOKEN, "{\"enabled\": true}"), "deyeCommands", "reserveFile is required");
assertRejected(withDeyeCommands(DEYE, TOKEN, enabled.replace("}", ", \"reserveFloorPct\": 101}")),
"deyeCommands", "reserveFloorPct must be at most 100");
assertRejected(withDeyeCommands(DEYE, TOKEN, enabled.replace("}", ", \"minIntervalSeconds\": 0}")),
"deyeCommands", "minIntervalSeconds must be greater than 0");
}
private static String withCommands(String commands) {
return "{" + INFLUX + ", \"devices\": {\"heatPump\": {\"enabled\": true, " + CONNECTION
+ "}}, \"commands\": " + commands + "}";
@@ -0,0 +1,63 @@
package home.gateway.domain;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ProgramRegistersTest {
/** Registers 146 to 177 as read on 2026-10-24. */
private static int[] block() {
int[] words = new int[ProgramRegisters.COUNT];
words[0] = 254;
int[] starts = {100, 500, 900, 1300, 1700, 2100};
for (int i = 0; i < 6; i++) {
words[2 + i] = starts[i];
words[20 + i] = 80;
}
return words;
}
@Test
void decodesTheSwitchTheDaysAndTheSlots() {
var words = block();
words[0] = 0b1010_1011;
words[26] = 3;
words[27] = 2;
var schedule = ProgramRegisters.decode(words);
assertTrue(schedule.enabled());
assertEquals(0b101_0101, schedule.days());
assertEquals(new Slot(100, 80, true), schedule.slots().get(0));
assertEquals(new Slot(500, 80, false), schedule.slots().get(1));
}
@Test
void changesAreTheDifferingRegistersOnly() {
var current = ProgramRegisters.decode(block());
var next = current.with(new ScheduleChange(true, List.of(new SlotChange(1, 130, null, null),
new SlotChange(6, null, 95, true)), "x"));
assertEquals(Map.of(146, 255, 148, 130, 171, 95, 177, 1), ProgramRegisters.changes(block(), next));
}
@Test
void rangesJoinNeighboursAndPutTheSwitchLast() {
var ranges = ProgramRegisters.ranges(Map.of(146, 255, 148, 130, 149, 600, 166, 90, 168, 91, 169, 92));
assertEquals(List.of(148, 166, 168, 146), ranges.stream().map(ProgramRegisters.Range::address).toList());
assertArrayEquals(new int[] {130, 600}, ranges.get(0).values());
assertArrayEquals(new int[] {91, 92}, ranges.get(2).values());
}
@Test
void noChangesNoRanges() {
assertEquals(List.of(), ProgramRegisters.ranges(Map.of()));
}
}