feat: publish adaptive-link OSD contract and keep overlay above video (#3)

This commit is contained in:
2026-10-18 08:02:18 +02:00
parent f0d5052ccb
commit 79ea5d62b7
14 changed files with 616 additions and 81 deletions
+42 -8
View File
@@ -1,6 +1,8 @@
package display
import (
"strconv"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
@@ -44,18 +46,50 @@ func (t OSDTemplate) Draw(canvas Canvas) {
canvas.Flush()
}
// drawOSD renders the deliberately minimal flight telemetry: two short
// lines in the top-left corner. The full final RC state stays in the
// snapshot; the pilot-facing overlay stays terse.
// 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"
}
power := "OFF"
if snapshot.Power {
power = "ON"
}
canvas.Text(32, 32, "ARM "+arm)
canvas.Text(32, 64, "PWR "+power)
canvas.Text(32, 64, vtxLine(snapshot))
}
// vtxLine is the compact adaptive-link contract line.
func vtxLine(snapshot osd.Snapshot) string {
return "VTX tbl:" + vtxTable(snapshot.OpConfig.Table) +
" ceil:" + strconv.FormatUint(uint64(snapshot.OpConfig.MaxPower), 10) +
" req:" + vtxLevel(snapshot.Policy.Pending, snapshot.Policy.Level) +
" ack:" + vtxAck(snapshot.Ack)
}
func vtxTable(table osd.Table) string {
if !table.Selected() {
return "--"
}
return strconv.FormatUint(uint64(table.Gen), 10)
}
func vtxLevel(pending bool, level uint8) string {
if pending {
return "--"
}
return strconv.FormatUint(uint64(level), 10)
}
func vtxAck(ack osd.VTXAck) string {
if ack.Pending {
return "--"
}
if ack.Lifecycle == osd.VTXLifecycleError {
return "ERR"
}
return vtxLevel(false, ack.Level)
}
+80 -9
View File
@@ -14,14 +14,22 @@ type stubSource struct {
func (s stubSource) Latest() (osd.Snapshot, bool) { return s.snapshot, s.ok }
func armedSource(sequence uint16) stubSource {
return stubSource{snapshot: osd.Snapshot{Sequence: sequence, Armed: true, Power: true}, ok: true}
// armedPendingSource is the contract state until the adaptive-link
// producers exist: armed with a CH6 ceiling, everything else pending.
func armedPendingSource(ceiling uint8) stubSource {
return stubSource{snapshot: osd.Snapshot{
Armed: true,
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}
}
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewOSDTemplate(NewVideoPresence(), armedSource(1)).Draw(canvas)
NewOSDTemplate(NewVideoPresence(), armedPendingSource(63)).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
@@ -32,19 +40,19 @@ func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
}
}
func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
func TestOSDTemplateDrawsArmStateAndPendingContractWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, armedSource(7)).Draw(canvas)
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)
}
lines := canvas.lines()
want := []string{"ARM ARMED", "PWR ON"}
want := []string{"ARM ARMED", "VTX tbl:-- ceil:63 req:-- ack:--"}
if len(lines) != len(want) {
t.Fatalf("lines=%q want %q", lines, want)
}
@@ -55,7 +63,7 @@ func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
}
}
func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
func TestOSDTemplateDrawsDisarmedState(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
@@ -63,7 +71,32 @@ func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
NewOSDTemplate(presence, stubSource{ok: true}).Draw(canvas)
lines := canvas.lines()
want := []string{"ARM OFF", "PWR OFF"}
if len(lines) != 2 || lines[0] != "ARM OFF" {
t.Fatalf("lines=%q want the disarmed arm state and the pending contract line", lines)
}
}
func TestOSDTemplateRendersSelectedTableRequestedAndAppliedStates(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{
Armed: true,
OpConfig: osd.OperatorConfig{
Table: osd.Table{Gen: 3, Hash: 0xcafe},
MaxPower: 127,
},
Policy: osd.GSPolicy{Level: 42},
Ack: osd.VTXAck{
Table: osd.Table{Gen: 3},
Level: 40,
Lifecycle: osd.VTXLifecycleApplied,
},
}, ok: true}).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)
}
@@ -74,6 +107,44 @@ func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
}
}
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)
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)
}
}
func TestOSDTemplateRendersLevelZeroAsExplicitOff(t *testing.T) {
// Level 0 in an available policy or acknowledgment is an explicit
// off / no-runtime-command state and must render as 0; only a pending
// 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)
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)
}
}
func TestOSDTemplateStaysTransparentWithoutASnapshotWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
@@ -111,7 +182,7 @@ func TestOSDTemplateGatesOnObservedVideoPresenceNotSnapshotVideo(t *testing.T) {
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
template := NewOSDTemplate(presence, armedSource(3))
template := NewOSDTemplate(presence, armedPendingSource(63))
for _, active := range []bool{false, true, false} {
presence.SetActive(active)
+51 -6
View File
@@ -99,6 +99,23 @@ func stackModeForVideoAppear(role LayerRole) uint32 {
return xproto.StackModeBelow
}
// stackReassertTarget reports which video window the OSD overlay must sit
// above after an external restack, and whether one exists. Only the OSD
// overlay re-asserts its place: window managers promote fullscreened video
// to the top of their stacking order after the video-appear restack, which
// would bury the overlay — and its text — for the rest of the flight. The
// fallback canvas keeps its event-driven lifecycle, and the video window is
// never moved.
func stackReassertTarget(role LayerRole, videoActive bool, topVideo xproto.Window, videoWindows map[xproto.Window]struct{}) (xproto.Window, bool) {
if role != RoleAbove || !videoActive || topVideo == 0 {
return 0, false
}
if _, tracked := videoWindows[topVideo]; !tracked {
return 0, false
}
return topVideo, true
}
// raisesOnVideoEnd reports whether a layer raises itself when video ends:
// only the below canvas needs that to make the fallback visible again; the
// OSD overlay is already above everything.
@@ -321,9 +338,11 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
presence := o.presence()
typeAtom := internWindowTypeAtom(conn)
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom)
var topVideo xproto.Window
if len(videoWindows) > 0 {
presence.SetActive(true)
stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1],
topVideo = videoWindows[len(videoWindows)-1]
stackWindowRelative(conn, window, topVideo,
stackModeForVideoAppear(o.Role))
} else {
presence.SetActive(false)
@@ -380,6 +399,7 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
refresh: o.RefreshInterval,
video: presence,
videoWindows: videoWindowSet(videoWindows),
topVideo: topVideo,
typeAtom: typeAtom,
markerAtom: markerAtom,
role: o.Role,
@@ -645,10 +665,14 @@ type x11Session struct {
refresh time.Duration
video *VideoPresence
videoWindows map[xproto.Window]struct{}
typeAtom xproto.Atom
markerAtom xproto.Atom
role LayerRole
unicodeFont bool
// topVideo is the video window the OSD overlay must sit above; it is
// re-asserted on the overlay's refresh because window managers may
// promote fullscreened video back on top after the appear restack.
topVideo xproto.Window
typeAtom xproto.Atom
markerAtom xproto.Atom
role LayerRole
unicodeFont bool
// fontAscent, fontDescent and fontCell describe the drawn font so the
// OSD overlay can punch its output shape around each text's glyph cell.
fontAscent int16
@@ -767,6 +791,10 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
// event-driven.
if s.refresh > 0 && time.Since(lastRefresh) >= s.refresh {
lastRefresh = time.Now()
// The redraw is also where the overlay re-claims its place
// above fullscreen video: window managers may have promoted
// the video window back on top since the appear restack.
s.reassertStacking()
return true, nil
}
// Round trip keeps the connection liveness visible even without
@@ -783,6 +811,19 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
}
}
// reassertStacking re-claims the overlay's place above the presented video
// window; a no-op for the canvas and whenever video is absent.
func (s *x11Session) reassertStacking() {
if s.video == nil || !s.video.Active() {
return
}
target, ok := stackReassertTarget(s.role, s.video.Active(), s.topVideo, s.videoWindows)
if !ok {
return
}
stackWindowRelative(s.conn, s.window, target, xproto.StackModeAbove)
}
// applyVideoChange folds one root structure event into the shared video
// presence and restacks this layer so video keeps rendering priority: the
// canvas parks below video and the OSD overlay rises above it. It reports
@@ -803,13 +844,17 @@ func (s *x11Session) applyVideoChange(event xgb.Event) bool {
return false
}
// The first presented video output takes priority: park on our side
// of it.
// of it, and remember it as the restack target for later re-claims.
s.topVideo = w
stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role))
return s.video.SetActive(true)
case videoDisappeared:
if !removeVideoWindow(s.videoWindows, w) {
return false
}
if s.topVideo == w {
s.topVideo = 0
}
// Only the below canvas raises itself so the fallback returns
// visibly even if some other window was stacked above it; the OSD
// overlay is already above video.
+24
View File
@@ -286,6 +286,30 @@ func TestOSDEventMaskNeverSelectsDeviceInput(t *testing.T) {
}
}
func TestStackReassertTargetOnlyAboveLiveTrackedVideo(t *testing.T) {
// Window managers promote fullscreened video to the top of their
// stacking order after the video-appear restack, burying the OSD
// overlay; the overlay's refresh must re-assert its place above the
// tracked video window — and only the OSD overlay may do so.
windows := map[xproto.Window]struct{}{0x20: {}}
if target, ok := stackReassertTarget(RoleAbove, true, 0x20, windows); !ok || target != 0x20 {
t.Fatalf("target=%v ok=%v want the tracked video window", target, ok)
}
if _, ok := stackReassertTarget(RoleBelow, true, 0x20, windows); ok {
t.Fatal("the fallback canvas must never re-assert stacking")
}
if _, ok := stackReassertTarget(RoleAbove, false, 0x20, windows); ok {
t.Fatal("no re-assert while video is absent")
}
if _, ok := stackReassertTarget(RoleAbove, true, 0x30, windows); ok {
t.Fatal("a stale top video window must not be restacked against")
}
if _, ok := stackReassertTarget(RoleAbove, true, 0, windows); ok {
t.Fatal("no tracked video window means nothing to re-assert")
}
}
func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
var input, output bool
for _, kind := range transparentShapeKinds() {