feat: keep OSD always visible with pre-map fullscreen states (#3)

This commit is contained in:
2026-11-03 13:11:15 +01:00
parent 79ea5d62b7
commit 3d6d146007
9 changed files with 328 additions and 157 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Current capabilities:
- starts an independent kiosk display before video, tolerates temporary X11 downtime without restarting the supervisor, and stops deterministically on shutdown;
- keeps the fullscreen fallback/menu canvas below `fpv-video`, showing `Brak połączenia WFB` only while video is absent;
- maps a transparent, input-transparent OSD overlay above `fpv-video` as the flight OSD rendering surface: it never intercepts video input, and it punches its output shape only around the text it draws;
- renders a deliberately minimal Viture OSD on that overlay while decoded video is present — the arm state and one compact adaptive-link line showing the target table, the CH6 maxPower ceiling, the requested runtime level and the applied VTX state, with explicit pending marks until real producers arrive — refreshing on a slower cadence than the video so the pilot stream keeps priority; a stale snapshot (dead UART) stops rendering instead of freezing telemetry over live video.
- renders the flight OSD from startup on the transparent overlay — on top of the no-video fallback presentation and above fullscreen video alike: the arm state, the compact adaptive-link line, and an explicit VIDEO ON/OFF line sourced from the display-observed video lifecycle; before any snapshot has arrived it shows explicit NO DATA / unknown marks, and a snapshot beyond the freshness window stays visible marked DATA STALE instead of being presented as current — refreshing on a slower cadence than the video so the pilot stream keeps priority.
Future MIPI/touch targets use their own templates rather than sharing one global layout. The flight OSD renders on the existing transparent layer above video, preserving the pilot stream's priority.
+13 -6
View File
@@ -66,19 +66,26 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
}
// The canvas layer keeps the target-specific fallback template with the
// exact fallback text.
// exact fallback text; the OSD layer shows explicit state without a
// snapshot. Both layers are fullscreen at startup with no video — the
// canvas below (RoleBelow) and the OSD above (RoleAbove), per the
// display package's fullscreen-args contract.
canvasSurface := &recordingCanvas{width: 800, height: 600}
canvas.Template.Draw(canvasSurface)
if len(canvasSurface.lines) != 1 || canvasSurface.lines[0] != "Brak połączenia WFB" {
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
}
// The OSD layer is the OSD surface, never the fallback: without a
// published snapshot it presents nothing.
osdSurface := &recordingCanvas{width: 800, height: 600}
osdLayer.Template.Draw(osdSurface)
if len(osdSurface.lines) != 0 {
t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines)
wantStartup := []string{"NO DATA", "VTX tbl:-- ceil:-- req:-- ack:--", "VIDEO OFF"}
if len(osdSurface.lines) != len(wantStartup) {
t.Fatalf("OSD lines=%q want the explicit startup state %q", osdSurface.lines, wantStartup)
}
for i := range wantStartup {
if osdSurface.lines[i] != wantStartup[i] {
t.Fatalf("line %d=%q want %q", i, osdSurface.lines[i], wantStartup[i])
}
}
// Video presence hides the fallback on the canvas layer only.
@@ -124,7 +131,7 @@ func TestBuildTargetOSDLayerRendersPublishedSnapshots(t *testing.T) {
surface := &recordingCanvas{width: 800, height: 600}
osdLayer.Template.Draw(surface)
want := []string{"ARM ARMED", "VTX tbl:-- ceil:95 req:-- ack:--"}
want := []string{"ARM ARMED", "VTX tbl:-- ceil:95 req:-- ack:--", "VIDEO ON"}
if len(surface.lines) != len(want) {
t.Fatalf("OSD lines=%q want %q from the supervisor-published snapshot", surface.lines, want)
}
+3 -1
View File
@@ -5,7 +5,9 @@
// fullscreen fallback/menu canvas below video, visible while decoded pilot
// video is absent, and a transparent, input-transparent OSD overlay above
// video that renders deliberately minimal flight telemetry from
// supervisor-published OSD snapshots. When video
// supervisor-published OSD snapshots from startup — on top of the fallback
// presentation and above fullscreen video alike, with the observed video
// lifecycle shown as an explicit VIDEO line. When video
// output is presented the video window takes rendering priority and the
// fallback disappears, returning when video ends. Presence is observed from
// the video output itself on the display server, never from WFB or launcher
+57 -35
View File
@@ -6,60 +6,82 @@ import (
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// SnapshotSource serves the latest supervisor-published OSD snapshot while
// it is fresh. The file-backed implementation lives in the osd package; the
// template only consumes the contract.
// SnapshotSource serves the latest supervisor-published OSD snapshot and its
// state (none / fresh / stale). The file-backed implementation lives in the
// osd package; the template only consumes the contract.
type SnapshotSource interface {
Latest() (osd.Snapshot, bool)
Latest() (osd.Snapshot, osd.SnapshotState)
}
// OSDTemplate is the flight OSD layout of a display target: a transparent,
// input-transparent surface above pilot video. While decoded video is
// absent the fallback canvas owns the screen and the OSD presents nothing;
// while video is present the OSD draws deliberately minimal telemetry from
// the latest published snapshot — arm state and requested CH6 WFB power.
// The rendering gate is the video presence the display observes on the
// display server, never the snapshot's Video field: the supervisor's field
// is its launcher knowledge, while the display trusts only the presented
// video output itself. The OSD never draws the fallback: it is a telemetry
// surface, not a video substitute, and WFB or video decode never move into
// the UI process to feed it. The OSD refreshes on its own slower cadence
// than the video and the video window keeps rendering priority.
// input-transparent surface above everything else on the screen. It renders
// from startup — on top of the no-video fallback presentation and above
// fullscreen video alike — and never gates on video presence: the video
// lifecycle the display observes on the display server is itself displayed
// as the VIDEO line, never the snapshot's Video field. ARM and the compact
// adaptive-link line come from the latest snapshot; a stale last-known
// snapshot stays visible and marked DATA STALE, and before any snapshot has
// existed the OSD shows explicit NO DATA / unknown marks. The OSD never
// draws the fallback: it is a telemetry surface, not a video substitute,
// and WFB or video decode never move into the UI process to feed it. The
// OSD refreshes on its own slower cadence than the video and the video
// window keeps rendering priority.
type OSDTemplate struct {
video *VideoPresence
source SnapshotSource
}
// NewOSDTemplate builds the OSD surface for a target from the shared video
// presence and the supervisor-published snapshot source.
// NewOSDTemplate builds the OSD surface for a target from the shared
// display-observed video presence and the supervisor-published snapshot
// source.
func NewOSDTemplate(video *VideoPresence, source SnapshotSource) OSDTemplate {
return OSDTemplate{video: video, source: source}
}
func (t OSDTemplate) Draw(canvas Canvas) {
canvas.Clear()
if t.video.Active() {
if snapshot, ok := t.source.Latest(); ok {
drawOSD(canvas, snapshot)
}
}
snapshot, state := t.source.Latest()
t.draw(canvas, snapshot, state)
canvas.Flush()
}
// drawOSD renders the deliberately minimal flight telemetry: the arm state
// and one compact adaptive-link line — the operator's target table, the
// temporary CH6 maxPower ceiling, the requested runtime level and the
// applied VTX state. Values whose producers do not exist yet render at
// their explicit pending marks. The full final RC state stays in the
// snapshot; the pilot-facing overlay stays terse, and static configuration
// is never rendered as applied VTX state.
func drawOSD(canvas Canvas, snapshot osd.Snapshot) {
arm := "OFF"
if snapshot.Armed {
arm = "ARMED"
// draw renders the deliberately minimal telemetry lines in the top-left
// corner: arm state, the adaptive-link contract, an optional staleness or
// no-data marker, and the observed video state. The full final RC state
// stays in the snapshot; the pilot-facing overlay stays terse.
func (t OSDTemplate) draw(canvas Canvas, snapshot osd.Snapshot, state osd.SnapshotState) {
y := 32
switch state {
case osd.SnapshotNone:
canvas.Text(32, y, "NO DATA")
y += 32
canvas.Text(32, y, "VTX tbl:-- ceil:-- req:-- ack:--")
y += 32
default:
canvas.Text(32, y, armLine(snapshot))
y += 32
canvas.Text(32, y, vtxLine(snapshot))
y += 32
if state == osd.SnapshotStale {
canvas.Text(32, y, "DATA STALE")
y += 32
}
}
canvas.Text(32, 32, "ARM "+arm)
canvas.Text(32, 64, vtxLine(snapshot))
canvas.Text(32, y, "VIDEO "+videoState(t.video.Active()))
}
func armLine(snapshot osd.Snapshot) string {
if snapshot.Armed {
return "ARM ARMED"
}
return "ARM OFF"
}
func videoState(active bool) string {
if active {
return "ON"
}
return "OFF"
}
// vtxLine is the compact adaptive-link contract line.
+127 -96
View File
@@ -1,61 +1,42 @@
package display
import (
"strings"
"testing"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// stubSource serves one fixed snapshot, or none.
// stubSource serves one fixed snapshot state, or none.
type stubSource struct {
snapshot osd.Snapshot
ok bool
state osd.SnapshotState
}
func (s stubSource) Latest() (osd.Snapshot, bool) { return s.snapshot, s.ok }
func (s stubSource) Latest() (osd.Snapshot, osd.SnapshotState) { return s.snapshot, s.state }
// armedPendingSource is the contract state until the adaptive-link
// producers exist: armed with a CH6 ceiling, everything else pending.
func armedPendingSource(ceiling uint8) stubSource {
// pendingSource is the contract state while the adaptive-link producers do
// not exist: arm state and CH6 ceiling from the snapshot, everything else
// explicitly pending.
func pendingSource(armed bool, ceiling uint8) stubSource {
return stubSource{snapshot: osd.Snapshot{
Armed: true,
Armed: armed,
OpConfig: osd.OperatorConfig{MaxPower: ceiling},
Policy: osd.GSPolicy{Pending: true},
Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending},
Link: osd.GSObservedLink{Known: false},
}, ok: true}
}, state: osd.SnapshotFresh}
}
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewOSDTemplate(NewVideoPresence(), armedPendingSource(63)).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none: without video the fallback canvas owns the screen", lines)
}
}
func TestOSDTemplateDrawsArmStateAndPendingContractWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, armedPendingSource(63)).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
func assertLines(t *testing.T, canvas *fakeCanvas, want []string) {
t.Helper()
lines := canvas.lines()
want := []string{"ARM ARMED", "VTX tbl:-- ceil:63 req:-- ack:--"}
if len(lines) != len(want) {
t.Fatalf("lines=%q want %q", lines, want)
if len(lines) < len(want) {
t.Fatalf("lines=%q want at least %q", lines, want)
}
// Compare only the most recent draw pass: the canvas accumulates lines
// across passes.
lines = lines[len(lines)-len(want):]
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
@@ -63,17 +44,89 @@ func TestOSDTemplateDrawsArmStateAndPendingContractWhileVideoPresent(t *testing.
}
}
func TestOSDTemplateDrawsDisarmedState(t *testing.T) {
func TestOSDTemplateRendersExplicitNoDataState(t *testing.T) {
// Before any snapshot has ever existed the overlay must show explicit
// unknown state plus the authoritative video state instead of a blank
// overlay — including over the no-video fallback presentation.
canvas := &fakeCanvas{width: 800, height: 600}
NewOSDTemplate(NewVideoPresence(), stubSource{}).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
assertLines(t, canvas, []string{
"NO DATA",
"VTX tbl:-- ceil:-- req:-- ack:--",
"VIDEO OFF",
})
}
func TestOSDTemplateDrawsFreshSnapshotWithVideoOff(t *testing.T) {
// Fresh snapshot data renders on top of the no-video fallback
// presentation; the observed video lifecycle shows as VIDEO OFF.
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
NewOSDTemplate(presence, pendingSource(true, 63)).Draw(canvas)
assertLines(t, canvas, []string{
"ARM ARMED",
"VTX tbl:-- ceil:63 req:-- ack:--",
"VIDEO OFF",
})
}
func TestOSDTemplateMarksStaleLastKnownData(t *testing.T) {
// A snapshot beyond the freshness window stays visible as last-known
// data, explicitly marked stale — never presented as current.
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
Armed: true,
OpConfig: osd.OperatorConfig{
Table: osd.Table{Gen: 3, Hash: 0xcafe},
MaxPower: 63,
},
Policy: osd.GSPolicy{Level: 42},
Ack: osd.VTXAck{
Table: osd.Table{Gen: 3},
Level: 40,
Lifecycle: osd.VTXLifecycleApplied,
},
}, state: osd.SnapshotStale}).Draw(canvas)
assertLines(t, canvas, []string{
"ARM ARMED",
"VTX tbl:3 ceil:63 req:42 ack:40",
"DATA STALE",
"VIDEO OFF",
})
}
func TestOSDTemplateStaysVisibleAcrossVideoTransition(t *testing.T) {
// Video appearing fullscreen must not hide the OSD: the data lines stay
// and only the authoritative VIDEO line flips.
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
template := NewOSDTemplate(presence, pendingSource(true, 63))
template.Draw(canvas)
assertLines(t, canvas, []string{
"ARM ARMED",
"VTX tbl:-- ceil:63 req:-- ack:--",
"VIDEO OFF",
})
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{ok: true}).Draw(canvas)
lines := canvas.lines()
if len(lines) != 2 || lines[0] != "ARM OFF" {
t.Fatalf("lines=%q want the disarmed arm state and the pending contract line", lines)
}
template.Draw(canvas)
assertLines(t, canvas, []string{
"ARM ARMED",
"VTX tbl:-- ceil:63 req:-- ack:--",
"VIDEO ON",
})
}
func TestOSDTemplateRendersSelectedTableRequestedAndAppliedStates(t *testing.T) {
@@ -93,35 +146,30 @@ func TestOSDTemplateRendersSelectedTableRequestedAndAppliedStates(t *testing.T)
Level: 40,
Lifecycle: osd.VTXLifecycleApplied,
},
}, ok: true}).Draw(canvas)
}, state: osd.SnapshotFresh}).Draw(canvas)
lines := canvas.lines()
want := []string{"ARM ARMED", "VTX tbl:3 ceil:127 req:42 ack:40"}
if len(lines) != len(want) {
t.Fatalf("lines=%q want %q", lines, want)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
}
}
assertLines(t, canvas, []string{
"ARM ARMED",
"VTX tbl:3 ceil:127 req:42 ack:40",
"VIDEO ON",
})
}
func TestOSDTemplateRendersAckErrorState(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
OpConfig: osd.OperatorConfig{MaxPower: 10},
Policy: osd.GSPolicy{Pending: true},
Ack: osd.VTXAck{Lifecycle: osd.VTXLifecycleError},
}, ok: true}).Draw(canvas)
}, state: osd.SnapshotFresh}).Draw(canvas)
lines := canvas.lines()
if len(lines) != 2 || lines[1] != "VTX tbl:-- ceil:10 req:-- ack:ERR" {
t.Fatalf("lines=%q want the acknowledged error state", lines)
}
assertLines(t, canvas, []string{
"ARM OFF",
"VTX tbl:-- ceil:10 req:-- ack:ERR",
"VIDEO OFF",
})
}
func TestOSDTemplateRendersLevelZeroAsExplicitOff(t *testing.T) {
@@ -130,59 +178,42 @@ func TestOSDTemplateRendersLevelZeroAsExplicitOff(t *testing.T) {
// value — no producer yet — renders as the pending mark.
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
OpConfig: osd.OperatorConfig{MaxPower: 0},
Policy: osd.GSPolicy{Level: 0},
Ack: osd.VTXAck{Level: 0, Lifecycle: osd.VTXLifecycleApplied},
}, ok: true}).Draw(canvas)
}, state: osd.SnapshotFresh}).Draw(canvas)
lines := canvas.lines()
want := "VTX tbl:-- ceil:0 req:0 ack:0"
if len(lines) != 2 || lines[1] != want {
t.Fatalf("lines=%q want %q", lines, want)
}
assertLines(t, canvas, []string{
"ARM OFF",
"VTX tbl:-- ceil:0 req:0 ack:0",
"VIDEO OFF",
})
}
func TestOSDTemplateStaysTransparentWithoutASnapshotWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{}).Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none without a published snapshot", lines)
}
}
func TestOSDTemplateGatesOnObservedVideoPresenceNotSnapshotVideo(t *testing.T) {
// The rendering gate is the video lifecycle the display observes on the
// display server, never the snapshot's Video field: the supervisor's
// field is its launcher knowledge, while the display trusts only the
// presented video output itself.
func TestOSDTemplateVideoLineUsesOnlyObservedLifecycle(t *testing.T) {
// The VIDEO line is sourced from the video lifecycle the display
// observes on the display server, never from the snapshot's Video
// field (the supervisor's launcher knowledge).
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
claimingVideo := stubSource{snapshot: osd.Snapshot{Armed: true, Video: true}, ok: true}
NewOSDTemplate(presence, claimingVideo).Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("lines=%q want none: snapshot presence must never replace the observed lifecycle", lines)
}
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
Video: true,
}, state: osd.SnapshotFresh}).Draw(canvas)
denyingVideo := stubSource{snapshot: osd.Snapshot{Armed: true}, ok: true}
presence.SetActive(true)
NewOSDTemplate(presence, denyingVideo).Draw(canvas)
if lines := canvas.lines(); len(lines) == 0 {
t.Fatal("the observed video lifecycle must gate rendering even when the snapshot denies video")
for _, line := range canvas.lines() {
if strings.HasPrefix(line, "VIDEO") && line != "VIDEO OFF" {
t.Fatalf("line=%q: snapshot presence must never replace the observed lifecycle", line)
}
}
}
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
template := NewOSDTemplate(presence, armedPendingSource(63))
template := NewOSDTemplate(presence, pendingSource(true, 63))
for _, active := range []bool{false, true, false} {
presence.SetActive(active)
+38
View File
@@ -27,6 +27,10 @@ const (
maxImageText16Units = 127
// windowTypePropertyName is the EWMH property naming a window's role.
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
// windowStatePropertyName is the EWMH property carrying a window's
// initial states; a compliant window manager must honor it when the
// window is mapped.
windowStatePropertyName = "_NET_WM_STATE"
// layerMarkerPropertyName tags Ground Station layer windows so sibling
// layers — and their window-manager frames — never count as video
// output for each other.
@@ -90,6 +94,20 @@ func fullscreenState(role LayerRole) string {
return "below"
}
// fullscreenStateAtoms are the initial EWMH states announced as a property
// before mapping: every layer is genuinely fullscreen and undecorated from
// its first frame, with the canvas additionally parking below and the OSD
// overlay above. A compliant window manager must honor pre-map states; the
// post-map wmctrl request remains as reinforcement, never as the only
// fullscreen mechanism.
func fullscreenStateAtoms(role LayerRole) []string {
atoms := []string{"_NET_WM_STATE_FULLSCREEN"}
if role == RoleAbove {
return append(atoms, "_NET_WM_STATE_ABOVE")
}
return append(atoms, "_NET_WM_STATE_BELOW")
}
// stackModeForVideoAppear is where a layer goes relative to the first
// presented video window: the canvas parks below it, the OSD rises above it.
func stackModeForVideoAppear(role LayerRole) uint32 {
@@ -308,6 +326,26 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
xproto.AtomAtom, 32, 1, value)
}
}
// Announce the initial fullscreen state before mapping: a compliant
// window manager then maps the layer fullscreen and undecorated from
// its first frame, instead of a decorated, repositioned window that
// only becomes fullscreen after a later wmctrl round trip (or never,
// when wmctrl is missing).
stateProp := internAtom(conn, windowStatePropertyName)
if stateProp != 0 {
var stateData []byte
for _, name := range fullscreenStateAtoms(o.Role) {
if atom := internAtom(conn, name); atom != 0 {
value := make([]byte, 4)
binary.LittleEndian.PutUint32(value, uint32(atom))
stateData = append(stateData, value...)
}
}
if len(stateData) > 0 {
xproto.ChangeProperty(conn, xproto.PropModeReplace, window, stateProp,
xproto.AtomAtom, 32, uint32(len(stateData)/4), stateData)
}
}
// The OSD overlay must be transparent and input-transparent from its
// first mapped frame: the shapes are emptied before mapping, and a
// server without the shape extension never gets the overlay at all, so
+15
View File
@@ -310,6 +310,21 @@ func TestStackReassertTargetOnlyAboveLiveTrackedVideo(t *testing.T) {
}
}
func TestFullscreenStateAtomsOrderByRole(t *testing.T) {
// Fullscreen is requested as a pre-map _NET_WM_STATE property so a
// compliant window manager maps each layer genuinely fullscreen and
// undecorated from its first frame; a post-map client message alone
// leaves a decorated, repositioned window on the screen at startup.
below := fullscreenStateAtoms(RoleBelow)
if len(below) != 2 || below[0] != "_NET_WM_STATE_FULLSCREEN" || below[1] != "_NET_WM_STATE_BELOW" {
t.Fatalf("canvas state atoms=%v want fullscreen below", below)
}
above := fullscreenStateAtoms(RoleAbove)
if len(above) != 2 || above[0] != "_NET_WM_STATE_FULLSCREEN" || above[1] != "_NET_WM_STATE_ABOVE" {
t.Fatalf("OSD state atoms=%v want fullscreen above", above)
}
}
func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
var input, output bool
for _, kind := range transparentShapeKinds() {
+25 -8
View File
@@ -460,10 +460,27 @@ func writeStateFile(path string, data []byte) error {
return os.Rename(tmpPath, path)
}
// SnapshotState tells the display how to treat the latest snapshot.
type SnapshotState int
const (
// SnapshotNone means no snapshot has ever been decoded: the display
// renders explicit unknown state instead of last-known values.
SnapshotNone SnapshotState = iota
// SnapshotFresh means the snapshot is within the freshness window and
// its values are current.
SnapshotFresh
// SnapshotStale means the last successfully decoded snapshot is older
// than the freshness window: the display keeps showing its values
// marked as stale, never as current.
SnapshotStale
)
// FileSource reads the supervisor-published snapshot from its state file.
// It is the display-side end of the snapshot contract: reads that fail or
// return stale snapshots report no snapshot, so the OSD stays transparent
// instead of rendering telemetry from a dead UART.
// It is the display-side end of the snapshot contract: missing or corrupt
// records report none, and a record beyond the freshness window is reported
// as stale last-known data rather than hidden, so the display can mark it
// visibly instead of freezing it as current.
type FileSource struct {
path string
// now is the clock used for freshness checks; tests replace it.
@@ -475,14 +492,14 @@ func NewFileSource(path string) *FileSource {
return &FileSource{path: path, now: time.Now}
}
// Latest reports the current snapshot while it is fresh.
func (s *FileSource) Latest() (Snapshot, bool) {
// Latest reports the current snapshot and its state.
func (s *FileSource) Latest() (Snapshot, SnapshotState) {
snapshot, err := DecodeFile(s.path)
if err != nil {
return Snapshot{}, false
return Snapshot{}, SnapshotNone
}
if age := s.now().Sub(time.UnixMilli(snapshot.PublishedMS)); age > Freshness {
return Snapshot{}, false
return snapshot, SnapshotStale
}
return snapshot, true
return snapshot, SnapshotFresh
}
+49 -10
View File
@@ -77,20 +77,52 @@ func TestDecodeRejectsDamagedSnapshots(t *testing.T) {
}
}
func TestFileSourceReportsFreshSnapshotAndHidesStaleOrBrokenOnes(t *testing.T) {
func TestFileSourceAgeIsNotResetByRewritingIdenticalBytes(t *testing.T) {
// Publisher heartbeats rewrite the last snapshot's identical bytes; the
// display's staleness must key on the snapshot's own publish time, so a
// rewrite can never present stale data as current.
path := filepath.Join(t.TempDir(), "osd-state.bin")
now := time.UnixMilli(10_000_000)
source := NewFileSource(path)
source.now = func() time.Time { return now }
want := testSnapshot()
want.PublishedMS = now.UnixMilli()
if err := os.WriteFile(path, Encode(want), 0644); err != nil {
t.Fatal(err)
}
if _, state := source.Latest(); state != SnapshotFresh {
t.Fatalf("state=%v want fresh at publish time", state)
}
// Heartbeat rewrite of identical bytes at a later wall-clock time.
if err := os.WriteFile(path, Encode(want), 0644); err != nil {
t.Fatal(err)
}
source.now = func() time.Time { return now.Add(2 * Freshness) }
got, state := source.Latest()
if state != SnapshotStale {
t.Fatalf("state=%v want stale despite the heartbeat rewrite", state)
}
if got != want {
t.Fatalf("last-known snapshot=%+v want %+v", got, want)
}
}
func TestFileSourceReportsFreshStaleAndMissingStates(t *testing.T) {
path := filepath.Join(t.TempDir(), "osd-state.bin")
source := NewFileSource(path)
fixed := time.UnixMilli(10_000_000)
source.now = func() time.Time { return fixed }
if _, ok := source.Latest(); ok {
t.Fatal("a missing state file must report no snapshot")
if _, state := source.Latest(); state != SnapshotNone {
t.Fatalf("a missing state file must report state %v, got %v", SnapshotNone, state)
}
if err := os.WriteFile(path, []byte("garbage that is not a snapshot"), 0644); err != nil {
t.Fatal(err)
}
if _, ok := source.Latest(); ok {
t.Fatal("a corrupt state file must report no snapshot")
if _, state := source.Latest(); state != SnapshotNone {
t.Fatalf("a corrupt state file must report state %v, got %v", SnapshotNone, state)
}
want := testSnapshot()
@@ -98,20 +130,27 @@ func TestFileSourceReportsFreshSnapshotAndHidesStaleOrBrokenOnes(t *testing.T) {
if err := os.WriteFile(path, Encode(want), 0644); err != nil {
t.Fatal(err)
}
got, ok := source.Latest()
if !ok {
t.Fatal("a fresh state file must report its snapshot")
got, state := source.Latest()
if state != SnapshotFresh {
t.Fatalf("a fresh snapshot must report state %v, got %v", SnapshotFresh, state)
}
if got != want {
t.Fatalf("got=%+v want %+v", got, want)
}
// The last successfully decoded snapshot must survive past the
// freshness window, marked stale instead of being hidden: the display
// shows last-known values as explicitly stale.
stale := want
stale.PublishedMS = fixed.Add(-Freshness - time.Second).UnixMilli()
if err := os.WriteFile(path, Encode(stale), 0644); err != nil {
t.Fatal(err)
}
if _, ok := source.Latest(); ok {
t.Fatal("a snapshot older than the freshness window must not render")
got, state = source.Latest()
if state != SnapshotStale {
t.Fatalf("a snapshot beyond the freshness window must report state %v, got %v", SnapshotStale, state)
}
if got != stale {
t.Fatalf("last-known snapshot=%+v want %+v", got, stale)
}
}