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:
1 parent
8111405326
commit
619c3ee990
33 files changed
+2006
-108
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user