diff --git a/src/main/java/home/gateway/application/CommandService.java b/src/main/java/home/gateway/application/CommandService.java index 47bc5c6..9c342c1 100644 --- a/src/main/java/home/gateway/application/CommandService.java +++ b/src/main/java/home/gateway/application/CommandService.java @@ -45,6 +45,7 @@ public class CommandService implements Commands { private final ReadingSink audit; private final Clock clock; private final LongSupplier nanoTime; + private final Instant earliestPlausible; private final Duration minInterval; private final Pause pause; // Not synchronized: blocking I/O inside a monitor would pin a virtual thread's carrier. @@ -58,8 +59,8 @@ public class CommandService implements Commands { /** Fails when the store cannot be read: pending restores must never be dropped silently. */ public CommandService(List setpoints, SetpointRegisters registers, RestoreStore store, - ReadingSink audit, Clock clock, LongSupplier nanoTime, Duration minInterval, Pause pause) - throws IOException { + ReadingSink audit, Clock clock, LongSupplier nanoTime, + Instant earliestPlausible, Duration minInterval, Pause pause) throws IOException { setpoints.forEach(SafetyLimits::require); setpoints.forEach(setpoint -> allowlist.put(setpoint.name(), setpoint)); this.registers = registers; @@ -67,6 +68,7 @@ public class CommandService implements Commands { this.audit = audit; this.clock = clock; this.nanoTime = nanoTime; + this.earliestPlausible = earliestPlausible; this.minInterval = minInterval; this.pause = pause; store.load().forEach(restore -> restores.put(restore.register(), restore)); @@ -97,6 +99,10 @@ public class CommandService implements Commands { return rejected(command, setpoint, "a boost may last at most " + Command.MAX_BOOST.toHours() + " h"); } var now = clock.instant(); + // The Pi has no RTC: before NTP has set the clock, restore times would be nonsense. + if (now.isBefore(earliestPlausible)) { + return rejected(command, setpoint, "clock not set"); + } if (tooSoon(setpoint.register())) { return rejected(command, setpoint, "too soon after the last write to " + setpoint.name()); } diff --git a/src/test/java/home/gateway/application/CommandServiceTest.java b/src/test/java/home/gateway/application/CommandServiceTest.java index 7774d20..2fe20d0 100644 --- a/src/test/java/home/gateway/application/CommandServiceTest.java +++ b/src/test/java/home/gateway/application/CommandServiceTest.java @@ -59,7 +59,7 @@ class CommandServiceTest { private CommandService service() throws IOException { registers.values.putIfAbsent(4144, 45); registers.values.putIfAbsent(4132, 20); - return new CommandService(List.of(CWU, CO), registers, store, audit::addAll, clock, clock::nanos, + return new CommandService(List.of(CWU, CO), registers, store, audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ofSeconds(60), () -> { pauses++; inPause.countDown(); @@ -261,7 +261,7 @@ class CommandServiceTest { }; assertThrows(IOException.class, () -> new CommandService(List.of(CWU), registers, broken, - audit::addAll, clock, clock::nanos, Duration.ZERO, () -> { })); + audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { })); } @Test @@ -539,7 +539,7 @@ class CommandServiceTest { var typo = new Setpoint("cwu_comfort", 4144, 30, 500, 1); assertThrows(IllegalArgumentException.class, () -> new CommandService(List.of(typo), registers, store, - audit::addAll, clock, clock::nanos, Duration.ZERO, () -> { })); + audit::addAll, clock, clock::nanos, T0.minus(Duration.ofDays(1)), Duration.ZERO, () -> { })); } @Test @@ -599,6 +599,41 @@ class CommandServiceTest { assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 40, null)).status()); } + @Test + void refusesCommandsWhileTheClockIsBeforeTheFloor() throws IOException { + var service = service(); + clock.stepBack(Duration.ofDays(2)); + + var result = service.apply(command("cwu_comfort", 50, HOUR)); + + assertEquals(Status.REJECTED, result.status()); + assertEquals("clock not set", result.message()); + assertEquals(0, registers.reads); + assertEquals(0, registers.writes); + assertEquals("REJECTED", lastStatus()); + clock.advance(Duration.ofDays(2)); + assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 50, HOUR)).status()); + } + + @Test + void aClockStepBackwardDoesNotTripTheRateLimit() throws IOException { + var service = service(); + service.apply(command("cwu_comfort", 50, null)); + clock.advance(Duration.ofSeconds(61)); + clock.stepBack(Duration.ofHours(1)); + + assertEquals(Status.APPLIED, service.apply(command("cwu_comfort", 40, null)).status()); + } + + @Test + void aClockStepForwardDoesNotLiftTheRateLimit() throws IOException { + var service = service(); + service.apply(command("cwu_comfort", 50, null)); + clock.stepBack(Duration.ofHours(-1)); + + assertEquals(Status.REJECTED, service.apply(command("cwu_comfort", 40, null)).status()); + } + private static final class MutableClock extends Clock { private Instant now = T0; private long nanos; @@ -608,6 +643,10 @@ class CommandServiceTest { nanos += by.toNanos(); } + void stepBack(Duration by) { + now = now.minus(by); + } + long nanos() { return nanos; }