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..dd6b1e2
--- /dev/null
+++ b/src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java
@@ -0,0 +1,77 @@
+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,
+ /* 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
+) {
+
+ /** 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,
+ /* 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,
+ 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,
+ /* 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)
+ 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..bf1f4df 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,55 @@ 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,
+ "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}}}}
+ """;
+ 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(board.updates().lastCheckError()).isEqualTo("no route");
+ assertThat(board.servicePort()).isTrue();
+ 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, false, false, 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);
+ }
}