Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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()
Expand All @@ -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();
}
}
77 changes: 77 additions & 0 deletions src/main/java/com/ibrasoft/lensbridge/model/board/BoardReport.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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<Update> 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) {}
}
Original file line number Diff line number Diff line change
@@ -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<BoardReport, String> {

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;
}
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/ibrasoft/lensbridge/model/board/Device.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions src/main/resources/db/migration/sqlite/V3__device_board_report.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading