From 3d6d1460075115b524cdb85c276cb1890e6e1347 Mon Sep 17 00:00:00 2001 From: Hermes Date: Tue, 3 Nov 2026 13:11:15 +0100 Subject: [PATCH] feat: keep OSD always visible with pre-map fullscreen states (#3) --- README.md | 2 +- cmd/groundstation-display/main_test.go | 19 ++- internal/display/display.go | 4 +- internal/display/osd.go | 92 ++++++---- internal/display/osd_test.go | 223 ++++++++++++++----------- internal/display/x11.go | 38 +++++ internal/display/x11_test.go | 15 ++ internal/osd/osd.go | 33 +++- internal/osd/snapshot_test.go | 59 +++++-- 9 files changed, 328 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 3654a53..14e9435 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/groundstation-display/main_test.go b/cmd/groundstation-display/main_test.go index 3deb965..804ccf5 100644 --- a/cmd/groundstation-display/main_test.go +++ b/cmd/groundstation-display/main_test.go @@ -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) } diff --git a/internal/display/display.go b/internal/display/display.go index 3149b72..259ea30 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -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 diff --git a/internal/display/osd.go b/internal/display/osd.go index 2366122..9acb632 100644 --- a/internal/display/osd.go +++ b/internal/display/osd.go @@ -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. diff --git a/internal/display/osd_test.go b/internal/display/osd_test.go index 58512c7..025f4c7 100644 --- a/internal/display/osd_test.go +++ b/internal/display/osd_test.go @@ -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) diff --git a/internal/display/x11.go b/internal/display/x11.go index c2e3682..cec2a61 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -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 diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index fa1d88f..dbcc0e6 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -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() { diff --git a/internal/osd/osd.go b/internal/osd/osd.go index a534ec1..3ee79f4 100644 --- a/internal/osd/osd.go +++ b/internal/osd/osd.go @@ -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 } diff --git a/internal/osd/snapshot_test.go b/internal/osd/snapshot_test.go index edbdaa2..56c0f55 100644 --- a/internal/osd/snapshot_test.go +++ b/internal/osd/snapshot_test.go @@ -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) } }