feat: keep OSD always visible with pre-map fullscreen states (#3)
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user