From 0457db7c331bfc325cdf203cf54b1bdc30fd69d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:37:30 +0000 Subject: [PATCH 1/2] Store each board's report of itself from its heartbeat Boards (agent 0.3.0 on) send `telemetry.board` with every heartbeat: the board app they run, the content they show and how many days are left, software waiting for the nightly install window, the outcome of the last agent self-update, and whether their clock can be believed. The backend keeps the latest one per device (devices.board_report, JSON text, plus board_report_at; V3 for Postgres and SQLite) and returns it in the device summaries the admin portal lists, as `board` and `boardReportAt`. A heartbeat without a report keeps the last one; unknown fields in a report are ignored, so a newer agent never breaks heartbeat parsing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .../dto/board/agent/HeartbeatFrame.java | 4 ++ .../dto/board/response/DeviceSummary.java | 6 ++ .../lensbridge/model/board/BoardReport.java | 66 +++++++++++++++++++ .../model/board/BoardReportConverter.java | 34 ++++++++++ .../lensbridge/model/board/Device.java | 6 ++ .../service/agent/HeartbeatService.java | 4 ++ .../postgresql/V3__device_board_report.sql | 12 ++++ .../sqlite/V3__device_board_report.sql | 12 ++++ .../service/agent/HeartbeatServiceTest.java | 47 +++++++++++++ 9 files changed, 191 insertions(+) create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java create mode 100644 src/main/java/com/ibrasoft/lensbridge/model/board/BoardReportConverter.java create mode 100644 src/main/resources/db/migration/postgresql/V3__device_board_report.sql create mode 100644 src/main/resources/db/migration/sqlite/V3__device_board_report.sql diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java index 7417538..ac705e7 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/agent/HeartbeatFrame.java @@ -1,5 +1,6 @@ package com.ibrasoft.lensbridge.dto.board.agent; +import com.ibrasoft.lensbridge.model.board.BoardReport; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; @@ -34,5 +35,8 @@ public static class Telemetry { private String agentVersion; private String displayedFrameKey; + + /** What the board runs and shows; stored as the device's latest state. */ + private BoardReport board; } } diff --git a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java index bee555e..a0471cd 100644 --- a/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java +++ b/src/main/java/com/ibrasoft/lensbridge/dto/board/response/DeviceSummary.java @@ -1,6 +1,7 @@ package com.ibrasoft.lensbridge.dto.board.response; import com.ibrasoft.lensbridge.model.board.Audience; +import com.ibrasoft.lensbridge.model.board.BoardReport; import com.ibrasoft.lensbridge.model.board.Device; import lombok.Builder; import lombok.Data; @@ -24,6 +25,9 @@ public class DeviceSummary { private String agentVersion; private String hardwareModel; private String lastSeenIp; + /** The board's latest report of itself; null until an agent that sends one heartbeats. */ + private BoardReport board; + private Instant boardReportAt; public static DeviceSummary of(Device d) { return DeviceSummary.builder() @@ -36,6 +40,8 @@ public static DeviceSummary of(Device d) { .agentVersion(d.getAgentVersion()) .hardwareModel(d.getHardwareModel()) .lastSeenIp(d.getLastSeenIp()) + .board(d.getBoardReport()) + .boardReportAt(d.getBoardReportAt()) .build(); } } diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java new file mode 100644 index 0000000..f13d094 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java @@ -0,0 +1,66 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * What a board reports about itself with every heartbeat: the board app it runs, the content + * it shows, the software waiting for its nightly install window, how its last agent update + * went, and whether its clock can be believed. Stored as the device's latest state, it is the + * admin portal's view of a board nobody is standing in front of. + *

+ * The agent owns this shape ({@code localserver.BoardReport} in the agent repo). Unknown + * fields are ignored so a newer agent never breaks an older backend's heartbeat parsing. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record BoardReport( + String appVersion, + Content content, + Updates updates, + AgentUpdate lastAgentUpdate, + Clock clock, + /* Why the last content sync failed, if it did. */ + String syncError, + /* Set when the installed content cannot be read. */ + String error +) { + + /** The installed content. Dates are the board's own ({@code yyyy-MM-dd}). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Content( + String firstDay, + String lastDay, + String createdAt, + /* How it arrived: sync, usb, upload or cli. */ + String source, + String installedAt, + int daysRemaining, + int staleDays + ) {} + + /** Board software waiting for the install window. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Updates( + List available, + /* "HH:mm" in the board's zone. */ + String installTime, + /* When the waiting updates install, RFC 3339; null when nothing waits. */ + String installAt, + boolean installing + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Update(String type, String version, String description) {} + + /** The root updater's last outcome: status is ok, rolled-back or refused. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record AgentUpdate(String from, String to, String at, String status, String message) {} + + /** + * Where the clock's time comes from: ntp, rtc, uploader, starting or unverified. + * {@code trusted} is false when the board is warning that its clock may be wrong. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record Clock(String source, boolean trusted) {} +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReportConverter.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReportConverter.java new file mode 100644 index 0000000..127aab3 --- /dev/null +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReportConverter.java @@ -0,0 +1,34 @@ +package com.ibrasoft.lensbridge.model.board; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** Stores a {@link BoardReport} as JSON text, so the column works on Postgres and SQLite alike. */ +@Converter +public class BoardReportConverter implements AttributeConverter { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(BoardReport report) { + if (report == null) return null; + try { + return JSON.writeValueAsString(report); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Board report cannot be stored", e); + } + } + + @Override + public BoardReport convertToEntityAttribute(String json) { + if (json == null || json.isBlank()) return null; + try { + return JSON.readValue(json, BoardReport.class); + } catch (JsonProcessingException e) { + // A report this backend cannot read is as good as none; the next heartbeat replaces it. + return null; + } + } +} diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java index 0185e86..a478a1f 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/Device.java @@ -48,6 +48,12 @@ public class Device { private String hardwareModel; private String lastSeenIp; + /** The board's latest report of what it runs and shows (heartbeat), and when it came. */ + @Convert(converter = BoardReportConverter.class) + @Column(name = "board_report", columnDefinition = "TEXT") + private BoardReport boardReport; + private Instant boardReportAt; + /** When set, all auth attempts are rejected and any live session is closed. */ private Instant revokedAt; diff --git a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java index 8bc6938..4662f5a 100644 --- a/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java +++ b/src/main/java/com/ibrasoft/lensbridge/service/agent/HeartbeatService.java @@ -44,6 +44,10 @@ public void record(UUID deviceId, HeartbeatFrame frame, String remoteIp) { log.info("Device {} agent version {} → {}", deviceId, device.getAgentVersion(), reported); device.setAgentVersion(reported); } + if (t != null && t.getBoard() != null) { + device.setBoardReport(t.getBoard()); + device.setBoardReportAt(now); + } deviceRepository.save(device); }); diff --git a/src/main/resources/db/migration/postgresql/V3__device_board_report.sql b/src/main/resources/db/migration/postgresql/V3__device_board_report.sql new file mode 100644 index 0000000..13c9918 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V3__device_board_report.sql @@ -0,0 +1,12 @@ +-- +-- V3: the board's latest report of itself (heartbeat `telemetry.board`): the app it runs, the +-- content it shows, updates waiting for the night, the last agent update, whether its clock +-- can be believed. One JSON document per device, replaced on every heartbeat, so a column +-- rather than a table: it is current state, not history (device_telemetry keeps history). +-- +-- Nullable: boards report it from agent 0.3.0 on. +-- +-- The SQLite twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table devices add column board_report TEXT; +alter table devices add column board_report_at timestamp(6) with time zone; diff --git a/src/main/resources/db/migration/sqlite/V3__device_board_report.sql b/src/main/resources/db/migration/sqlite/V3__device_board_report.sql new file mode 100644 index 0000000..edfdad6 --- /dev/null +++ b/src/main/resources/db/migration/sqlite/V3__device_board_report.sql @@ -0,0 +1,12 @@ +-- +-- V3: the board's latest report of itself (heartbeat `telemetry.board`): the app it runs, the +-- content it shows, updates waiting for the night, the last agent update, whether its clock +-- can be believed. One JSON document per device, replaced on every heartbeat, so a column +-- rather than a table: it is current state, not history (device_telemetry keeps history). +-- +-- Nullable: boards report it from agent 0.3.0 on. +-- +-- The Postgres twin of this file must stay at the same version -- see docs/MIGRATIONS.md. + +alter table devices add column board_report TEXT; +alter table devices add column board_report_at timestamp; diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java index 3c847ee..add21e2 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java @@ -134,4 +134,51 @@ void recordPublishesHeartbeatEventAfterPersist() { verify(events).heartbeat(deviceId, frame); } + + /** The board's report, as the agent sends it, parsed and stored as the device's state. */ + @Test + void recordStoresTheBoardReport() throws Exception { + String json = """ + {"type":"heartbeat","telemetry":{"agentVersion":"0.3.0","board":{ + "appVersion":"2.2.0", + "content":{"firstDay":"2026-09-25","lastDay":"2026-10-01","createdAt":"2026-09-25T12:00:00Z", + "source":"usb","daysRemaining":6,"staleDays":0}, + "updates":{"available":[{"type":"app","version":"2.3.0","description":"board app 2.3.0"}], + "installTime":"23:00","installAt":"2026-09-25T23:00:00-04:00","installing":false}, + "lastAgentUpdate":{"from":"0.2.1","to":"0.3.0","at":"2026-09-24T23:01:00Z","status":"ok","message":"m"}, + "clock":{"source":"unverified","trusted":false}, + "someFutureField":{"x":1}}}} + """; + HeartbeatFrame frame = new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, HeartbeatFrame.class); + UUID deviceId = UUID.randomUUID(); + Device device = Device.builder().id(deviceId).displayName("d").build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + + service.record(deviceId, frame, "ip"); + + var board = device.getBoardReport(); + assertThat(board).isNotNull(); + assertThat(board.appVersion()).isEqualTo("2.2.0"); + assertThat(board.content().daysRemaining()).isEqualTo(6); + assertThat(board.updates().available()).extracting(u -> u.version()).containsExactly("2.3.0"); + assertThat(board.lastAgentUpdate().status()).isEqualTo("ok"); + assertThat(board.clock().trusted()).isFalse(); + assertThat(device.getBoardReportAt()).isNotNull(); + + // What is stored reads back the same. + var converter = new com.ibrasoft.lensbridge.model.board.BoardReportConverter(); + assertThat(converter.convertToEntityAttribute(converter.convertToDatabaseColumn(board))).isEqualTo(board); + } + + @Test + void recordKeepsTheLastReportWhenAHeartbeatHasNone() { + UUID deviceId = UUID.randomUUID(); + var report = new com.ibrasoft.lensbridge.model.board.BoardReport("2.2.0", null, null, null, null, null, null); + Device device = Device.builder().id(deviceId).displayName("d").boardReport(report).build(); + when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device)); + + service.record(deviceId, frameWithTelemetry(), "ip"); + + assertThat(device.getBoardReport()).isEqualTo(report); + } } From b0be5d3eef339ff38b53103d527336b325f58ffd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 20:07:51 +0000 Subject: [PATCH 2/2] Board report: update-check status, offline routes, content time zone The agent now reports whether it follows the release channels, when it last checked and why that failed; which offline routes the board accepts (service port, USB); and its content's time zone, so the portal can work out days left again from lastDay when a report is old. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy --- .../lensbridge/model/board/BoardReport.java | 13 ++++++++++++- .../service/agent/HeartbeatServiceTest.java | 8 ++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java index f13d094..dd6b1e2 100644 --- a/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java +++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java @@ -22,6 +22,9 @@ public record BoardReport( Clock clock, /* Why the last content sync failed, if it did. */ String syncError, + /* The offline routes the board accepts: its ethernet upload page, USB sticks. */ + boolean servicePort, + boolean usbImport, /* Set when the installed content cannot be read. */ String error ) { @@ -32,6 +35,8 @@ public record Content( String firstDay, String lastDay, String createdAt, + /* The board's zone, so days left can be worked out again from lastDay. */ + String timezone, /* How it arrived: sync, usb, upload or cli. */ String source, String installedAt, @@ -47,7 +52,13 @@ public record Updates( String installTime, /* When the waiting updates install, RFC 3339; null when nothing waits. */ String installAt, - boolean installing + boolean installing, + /* Whether the board follows the release channels at all (auto_update). */ + boolean autoUpdate, + /* When the channels were last asked, RFC 3339; null before the first check. */ + String lastCheckAt, + /* Why that check failed, if it did. */ + String lastCheckError ) {} @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java index add21e2..bf1f4df 100644 --- a/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java +++ b/src/test/java/com/ibrasoft/lensbridge/service/agent/HeartbeatServiceTest.java @@ -144,7 +144,9 @@ void recordStoresTheBoardReport() throws Exception { "content":{"firstDay":"2026-09-25","lastDay":"2026-10-01","createdAt":"2026-09-25T12:00:00Z", "source":"usb","daysRemaining":6,"staleDays":0}, "updates":{"available":[{"type":"app","version":"2.3.0","description":"board app 2.3.0"}], - "installTime":"23:00","installAt":"2026-09-25T23:00:00-04:00","installing":false}, + "installTime":"23:00","installAt":"2026-09-25T23:00:00-04:00","installing":false, + "autoUpdate":true,"lastCheckAt":"2026-09-25T18:00:00Z","lastCheckError":"no route"}, + "servicePort":true,"usbImport":true, "lastAgentUpdate":{"from":"0.2.1","to":"0.3.0","at":"2026-09-24T23:01:00Z","status":"ok","message":"m"}, "clock":{"source":"unverified","trusted":false}, "someFutureField":{"x":1}}}} @@ -163,6 +165,8 @@ void recordStoresTheBoardReport() throws Exception { assertThat(board.updates().available()).extracting(u -> u.version()).containsExactly("2.3.0"); assertThat(board.lastAgentUpdate().status()).isEqualTo("ok"); assertThat(board.clock().trusted()).isFalse(); + assertThat(board.updates().lastCheckError()).isEqualTo("no route"); + assertThat(board.servicePort()).isTrue(); assertThat(device.getBoardReportAt()).isNotNull(); // What is stored reads back the same. @@ -173,7 +177,7 @@ void recordStoresTheBoardReport() throws Exception { @Test void recordKeepsTheLastReportWhenAHeartbeatHasNone() { UUID deviceId = UUID.randomUUID(); - var report = new com.ibrasoft.lensbridge.model.board.BoardReport("2.2.0", null, null, null, null, null, null); + var report = new com.ibrasoft.lensbridge.model.board.BoardReport("2.2.0", null, null, null, null, null, false, false, null); Device device = Device.builder().id(deviceId).displayName("d").boardReport(report).build(); when(deviceRepository.findById(deviceId)).thenReturn(Optional.of(device));