diff --git a/README.md b/README.md index c6891ab..3654a53 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,16 @@ Current capabilities: - logs channel changes instead of every frame; - starts/stops `fpv-video` from the configured CH6 power/lifecycle control; - publishes immutable flight OSD snapshots — final RC channels, arm state - derived from the configured arm channel, requested CH6 WFB power and - decoded-video presence — to a tmpfs state file without ever blocking the - UART, WFB or video paths; unchanged state is republished as a heartbeat - and the file is removed on shutdown; + derived from the configured arm channel, the adaptive-link contract values + (operator target table, temporary CH6 maxPower ceiling, requested runtime + level, VTX acknowledgment, observed link — pending/unknown until their + producers exist) and decoded-video presence — to a tmpfs state file + without ever blocking the UART, WFB or video paths; unchanged state is + republished as a heartbeat and the file is removed on shutdown; - 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 — arm state and requested CH6 WFB power — on that overlay while decoded video is present, 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 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. 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. @@ -68,9 +70,13 @@ osd: state_path: /run/groundstation/osd-state.bin arm_index: 4 # CH5/AUX1, zero-based armed_threshold: 0 # armed while the arm channel stays above this + vtx_table_gen: 0 # operator-selected VTX table (0 = none selected) + vtx_table_hash: 0 ``` -`groundstation-display` reads the same state file; both sides must agree on +CH6 acts only as the temporary operator maxPower ceiling and static +configuration is never applied VTX state. `groundstation-display` reads the +same state file; both sides must agree on the path (override the renderer's with `groundstation-display -osd-state ` if you change `osd.state_path`). The state file carries only flight telemetry and never any credentials or keys. diff --git a/cmd/groundstation-display/main_test.go b/cmd/groundstation-display/main_test.go index 2dadaa6..3deb965 100644 --- a/cmd/groundstation-display/main_test.go +++ b/cmd/groundstation-display/main_test.go @@ -96,7 +96,14 @@ func TestBuildTargetOSDLayerRendersPublishedSnapshots(t *testing.T) { publisher := osd.NewFilePublisher(state, nil) defer publisher.Close() - snapshot := osd.Snapshot{Sequence: 11, Armed: true, Power: true} + snapshot := osd.Snapshot{ + Sequence: 11, + Armed: true, + OpConfig: osd.OperatorConfig{MaxPower: 95}, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending}, + Link: osd.GSObservedLink{Known: false}, + } publisher.Publish(snapshot) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { @@ -117,7 +124,7 @@ func TestBuildTargetOSDLayerRendersPublishedSnapshots(t *testing.T) { surface := &recordingCanvas{width: 800, height: 600} osdLayer.Template.Draw(surface) - want := []string{"ARM ARMED", "PWR ON"} + want := []string{"ARM ARMED", "VTX tbl:-- ceil:95 req:-- ack:--"} if len(surface.lines) != len(want) { t.Fatalf("OSD lines=%q want %q from the supervisor-published snapshot", surface.lines, want) } diff --git a/cmd/groundstation/osd.go b/cmd/groundstation/osd.go index 093f71b..4c55624 100644 --- a/cmd/groundstation/osd.go +++ b/cmd/groundstation/osd.go @@ -8,17 +8,28 @@ import ( // newSnapshot builds the immutable flight OSD snapshot from the latest // valid RC frame: the final RC channels, the arm state derived from the -// configured arm channel, the requested CH6 WFB power (the same channel -// comparison that drives the video launcher) and the supervisor's -// decoded-video presence (its video launcher ownership). Building it is -// pure computation: publication never blocks the UART loop. +// configured arm channel, and the adaptive-link contract values. CH6 feeds +// only the temporary operator maxPower ceiling — it constrains what may be +// requested and is never proof of applied VTX power. The policy, +// acknowledgment and link producers (adaptive control, the VTX ack +// transport, WFB RX metrics) do not exist yet, so the snapshot carries +// their explicit pending/unknown states instead of fabricated data. The +// supervisor's decoded-video presence stays its launcher ownership. +// Building the snapshot is pure computation: publication never blocks the +// UART loop. func newSnapshot(frame protocol.RCFrame, cfg config.Config, video *videoController) osd.Snapshot { snapshot := osd.Snapshot{ Sequence: frame.Sequence, TimestampMS: frame.Timestamp, Channels: frame.Channels, Armed: frame.Channels[cfg.OSD.ArmIndex] > cfg.OSD.ArmedThreshold, - Power: frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold, + OpConfig: osd.OperatorConfig{ + Table: osd.Table{Gen: cfg.OSD.VtxTableGen, Hash: cfg.OSD.VtxTableHash}, + MaxPower: osd.CeilingFromChannel(frame.Channels[cfg.Video.AuxIndex]), + }, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending}, + Link: osd.GSObservedLink{Known: false}, } if video != nil { snapshot.Video = video.Running() diff --git a/cmd/groundstation/osd_test.go b/cmd/groundstation/osd_test.go index 6c87246..fa8ad73 100644 --- a/cmd/groundstation/osd_test.go +++ b/cmd/groundstation/osd_test.go @@ -15,11 +15,14 @@ import ( "git.piksar.eu/piotr/groundstation/internal/protocol" ) -func TestNewSnapshotDerivesArmPowerAndVideoPresence(t *testing.T) { +func TestNewSnapshotDerivesArmCeilingAndContractStates(t *testing.T) { cfg := config.Default() + cfg.OSD.VtxTableGen = 3 + cfg.OSD.VtxTableHash = 0xcafe frame := protocol.RCFrame{Sequence: 3, Timestamp: 42} frame.Channels[4] = 512 // arm channel above the armed threshold - frame.Channels[5] = -1000 // CH6 knob below the video threshold + frame.Channels[5] = 100 // CH6 knob mid-range + frame.Channels[15] = -999 // untouched channel must stay unchanged snapshot := newSnapshot(frame, cfg, nil) if snapshot.Sequence != 3 || snapshot.TimestampMS != 42 { @@ -31,21 +34,37 @@ func TestNewSnapshotDerivesArmPowerAndVideoPresence(t *testing.T) { if !snapshot.Armed { t.Fatal("arm channel above the threshold must derive armed") } - if snapshot.Power { - t.Fatal("CH6 below the video threshold must derive no requested WFB power") + // CH6 is only the temporary operator maxPower ceiling, never applied + // VTX power. + if snapshot.OpConfig.MaxPower != osd.CeilingFromChannel(100) { + t.Fatalf("maxPower=%d want the CH6-derived ceiling", snapshot.OpConfig.MaxPower) + } + if snapshot.OpConfig.Table != (osd.Table{Gen: 3, Hash: 0xcafe}) { + t.Fatalf("table=%+v want the operator-selected table", snapshot.OpConfig.Table) + } + // No policy, acknowledgment or link producer exists yet: the snapshot + // must carry explicit pending/unknown states, never fabricated data. + if !snapshot.Policy.Pending || snapshot.Policy.Level != 0 { + t.Fatalf("policy=%+v must stay explicitly pending", snapshot.Policy) + } + if !snapshot.Ack.Pending || snapshot.Ack.Lifecycle != osd.VTXLifecyclePending { + t.Fatalf("ack=%+v must stay explicitly pending", snapshot.Ack) + } + if snapshot.Link.Known { + t.Fatalf("link=%+v must stay explicitly unknown", snapshot.Link) } if snapshot.Video { t.Fatal("without a video launcher there is no decoded-video presence") } frame.Channels[4] = -256 - frame.Channels[5] = 100 - snapshot = newSnapshot(frame, cfg, &videoController{cmd: &exec.Cmd{}}) + frame.Channels[5] = -1000 + snapshot = newSnapshot(frame, cfg, &videoController{cmd: &exec.Cmd{}, done: make(chan error, 1)}) if snapshot.Armed { t.Fatal("arm channel below the threshold must derive disarmed") } - if !snapshot.Power { - t.Fatal("CH6 above the video threshold must derive requested WFB power") + if snapshot.OpConfig.MaxPower != osd.CeilingFromChannel(-1000) { + t.Fatalf("maxPower=%d must follow the CH6 knob", snapshot.OpConfig.MaxPower) } if !snapshot.Video { t.Fatal("a running video launcher is the supervisor's decoded-video presence") @@ -119,8 +138,11 @@ func TestRunPublishesSnapshotsForValidFramesOnly(t *testing.T) { if got.Armed { t.Fatal("the latest frame's arm state must be disarmed") } - if !got.Power { - t.Fatal("the latest frame's CH6 must request WFB power") + if got.OpConfig.MaxPower != osd.CeilingFromChannel(100) { + t.Fatalf("maxPower=%d must follow the latest frame's CH6 knob", got.OpConfig.MaxPower) + } + if !got.Policy.Pending || !got.Ack.Pending || got.Link.Known { + t.Fatalf("contract values %+v/%+v/%+v must stay pending/unknown", got.Policy, got.Ack, got.Link) } if got.PublishedMS == 0 { t.Fatal("the publisher must stamp the publication time") @@ -143,8 +165,8 @@ func TestRunPublishesWithoutAConfiguredVideoLauncher(t *testing.T) { if !ok { t.Fatal("frames must publish even without a video launcher") } - if !got.Power || got.Video { - t.Fatalf("snapshot=%+v must derive the requested power without video presence", got) + if got.OpConfig.MaxPower != osd.CeilingFromChannel(0) || got.Video { + t.Fatalf("snapshot=%+v must derive the ceiling without video presence", got) } } diff --git a/deploy/config.yml b/deploy/config.yml index ffe3f52..c2c1638 100644 --- a/deploy/config.yml +++ b/deploy/config.yml @@ -18,12 +18,19 @@ video: osd: # Flight OSD snapshots for the kiosk overlay: final RC channels, arm - # state, requested CH6 WFB power and decoded-video presence, published - # to tmpfs without blocking the UART path. The display process must read - # the same path (its -osd-state flag defaults to the value below). + # state, the adaptive-link contract values and decoded-video presence, + # published to tmpfs without blocking the UART path. The display process + # must read the same path (its -osd-state flag defaults to the value + # below). state_path: /run/groundstation/osd-state.bin arm_index: 4 # CH5/AUX1, zero-based armed_threshold: 0 + # Operator-selected VTX power table identifiers (0 = none selected yet). + # Static configuration is operator intent only: it is never rendered or + # serialized as applied VTX state, and CH6 acts only as the temporary + # maxPower ceiling. + vtx_table_gen: 0 + vtx_table_hash: 0 logging: enabled: true diff --git a/internal/config/config.go b/internal/config/config.go index 0bccb4b..9d617a7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,11 @@ type OSDConfig struct { // channels: armed while that channel stays above the threshold. ArmIndex int `yaml:"arm_index"` ArmedThreshold int16 `yaml:"armed_threshold"` + // VtxTableGen and VtxTableHash are the operator-selected VTX power + // table identifiers. Zero means no table is selected yet; a static + // selection is operator intent and never applied VTX state. + VtxTableGen uint32 `yaml:"vtx_table_gen"` + VtxTableHash uint64 `yaml:"vtx_table_hash"` } type Config struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0d978b8..1774058 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -67,7 +67,7 @@ func TestDefaultConfigPublishesOSDSnapshotsFromAUX1(t *testing.T) { func TestLoadOverridesOSDSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yml") - body := "osd:\n state_path: /tmp/osd-state.bin\n arm_index: 6\n armed_threshold: 256\n" + body := "osd:\n state_path: /tmp/osd-state.bin\n arm_index: 6\n armed_threshold: 256\n vtx_table_gen: 3\n vtx_table_hash: 9\n" if err := os.WriteFile(path, []byte(body), 0644); err != nil { t.Fatal(err) } @@ -83,6 +83,19 @@ func TestLoadOverridesOSDSettings(t *testing.T) { if cfg.OSD.ArmIndex != 6 || cfg.OSD.ArmedThreshold != 256 { t.Fatalf("arm config=%+v want the overrides", cfg.OSD) } + if cfg.OSD.VtxTableGen != 3 || cfg.OSD.VtxTableHash != 9 { + t.Fatalf("table config=%+v want the selected table", cfg.OSD) + } +} + +func TestDefaultConfigSelectsNoVtxTable(t *testing.T) { + // No table exists yet: the operator selection defaults to none, so the + // OSD shows an explicit pending table instead of static configuration + // dressed up as applied VTX state. + cfg := Default() + if cfg.OSD.VtxTableGen != 0 || cfg.OSD.VtxTableHash != 0 { + t.Fatalf("table config=%+v must default to no selection", cfg.OSD) + } } func TestLoadRejectsArmAndVideoIndexesOutsideTheChannelRange(t *testing.T) { diff --git a/internal/display/osd.go b/internal/display/osd.go index cad9ec2..2366122 100644 --- a/internal/display/osd.go +++ b/internal/display/osd.go @@ -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) } diff --git a/internal/display/osd_test.go b/internal/display/osd_test.go index 6c59dea..58512c7 100644 --- a/internal/display/osd_test.go +++ b/internal/display/osd_test.go @@ -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) diff --git a/internal/display/x11.go b/internal/display/x11.go index a068cda..c2e3682 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -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. diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index ad804f7..fa1d88f 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -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() { diff --git a/internal/osd/contract_test.go b/internal/osd/contract_test.go new file mode 100644 index 0000000..c24e36d --- /dev/null +++ b/internal/osd/contract_test.go @@ -0,0 +1,131 @@ +package osd + +import ( + "testing" +) + +func TestTableSelectionRequiresAnIdentifier(t *testing.T) { + if (Table{}).Selected() { + t.Fatal("zero generation and hash mean no table is selected yet") + } + if !(Table{Gen: 3}).Selected() || !(Table{Hash: 9}).Selected() { + t.Fatal("either identifier alone selects a table") + } +} + +func TestCeilingFromChannelScalesTheKnobLinearly(t *testing.T) { + // The temporary operator maxPower ceiling maps the full CH6 channel + // range onto the 0..127 level domain. It is operator intent only. + cases := []struct { + channel int16 + want uint8 + }{ + {-1024, 0}, + {-1023, 0}, + {0, 63}, + {512, 95}, + {1023, 126}, + {1024, 127}, + } + for _, c := range cases { + if got := CeilingFromChannel(c.channel); got != c.want { + t.Fatalf("CeilingFromChannel(%d)=%d want %d", c.channel, got, c.want) + } + } + if CeilingFromChannel(-2000) != 0 || CeilingFromChannel(2000) != 127 { + t.Fatal("out-of-range channels must clamp to the level domain") + } +} + +func TestSnapshotCarriesFourDistinctContractValues(t *testing.T) { + // The four contract values must stay distinct: every field survives a + // roundtrip at its own offset, so no value can silently alias another + // (for example a mode/power collapse of CH6 ceiling and applied VTX + // state). + snapshot := Snapshot{ + Sequence: 7, + TimestampMS: 99, + Armed: true, + Video: true, + OpConfig: OperatorConfig{ + Table: Table{Gen: 3, Hash: 0xdeadbeefcafe}, + MaxPower: 95, + }, + Policy: GSPolicy{ + Pending: false, + Level: 42, + Reason: [8]byte{'a', 'd', 'a', 'p', 't'}, + TimestampMS: 1234, + }, + Ack: VTXAck{ + Pending: false, + Table: Table{Gen: 2, Hash: 0xfeed}, + Level: 40, + Lifecycle: VTXLifecycleApplied, + }, + Link: GSObservedLink{ + Known: true, + AgeMS: 250, + }, + PublishedMS: 555, + } + snapshot.Channels[5] = 100 + + got, err := Decode(Encode(snapshot)) + if err != nil { + t.Fatal(err) + } + if got != snapshot { + t.Fatalf("roundtrip lost contract state: %+v != %+v", got, snapshot) + } +} + +func TestSnapshotCarriesExplicitPendingStates(t *testing.T) { + // Until the policy, acknowledgment and link producers exist the + // snapshot must carry their explicit pending/unknown states, never + // fabricated levels or metrics. + snapshot := Snapshot{ + Armed: true, + OpConfig: OperatorConfig{ + MaxPower: 63, + }, + Policy: GSPolicy{Pending: true}, + Ack: VTXAck{Pending: true, Lifecycle: VTXLifecyclePending}, + Link: GSObservedLink{Known: false}, + } + + got, err := Decode(Encode(snapshot)) + if err != nil { + t.Fatal(err) + } + if !got.Policy.Pending || got.Policy.Level != 0 { + t.Fatalf("policy=%+v must stay explicitly pending", got.Policy) + } + if !got.Ack.Pending || got.Ack.Level != 0 || got.Ack.Lifecycle != VTXLifecyclePending { + t.Fatalf("ack=%+v must stay explicitly pending", got.Ack) + } + if got.Link.Known || got.Link.AgeMS != 0 { + t.Fatalf("link=%+v must stay explicitly unknown", got.Link) + } + if got.OpConfig != snapshot.OpConfig { + t.Fatalf("operator config=%+v must survive", got.OpConfig) + } +} + +func TestDecodeRejectsV1Snapshots(t *testing.T) { + // A v1-sized record cannot carry the contract; a v2-sized record that + // claims v1 must be rejected by version, not parsed loosely. + v1Shaped := make([]byte, Size) + v1Shaped[0], v1Shaped[1], v1Shaped[2] = Magic0, Magic1, Version-1 + if _, err := Decode(v1Shaped); err == nil { + t.Fatal("a v1-marked record must be rejected") + } +} + +func TestVTXLifecycleStatesAreDistinct(t *testing.T) { + if VTXLifecyclePending == VTXLifecycleApplied || + VTXLifecycleApplied == VTXLifecycleError || + VTXLifecyclePending == VTXLifecycleError { + t.Fatal("pending, applied and error must be distinct lifecycle states") + } +} diff --git a/internal/osd/osd.go b/internal/osd/osd.go index b2fec6f..a534ec1 100644 --- a/internal/osd/osd.go +++ b/internal/osd/osd.go @@ -1,9 +1,21 @@ // Package osd is the shared flight OSD snapshot contract between the Ground // Station supervisor and the kiosk display process. The supervisor owns the -// RC, arming, power and video state and publishes immutable snapshots; -// the display renders the latest fresh snapshot on the transparent OSD -// overlay. The snapshot is a plain value: everything published is copied, so -// a reader can never observe or mutate a half-written state. +// RC, arming, video and operator-configuration state and publishes immutable +// snapshots; the display renders the latest fresh snapshot on the transparent +// OSD overlay. The snapshot is a plain value: everything published is copied, +// so a reader can never observe or mutate a half-written state. +// +// The adaptive-link contract is modeled as four distinct values that must +// never be collapsed into one another: the operator configuration (selected +// power table plus the temporary CH6 maxPower ceiling), the ground-station +// policy (requested runtime level), the aircraft VTX acknowledgment (applied +// level and lifecycle) and the observed receiver link. Their producers — +// adaptive control, the VTX ack transport and WFB RX metric ingestion — do +// not exist yet and stay out of scope; until they arrive the snapshot carries +// explicit pending/unknown states instead of fabricated data. CH6 is operator +// intent only: it constrains what may be requested and is never proof of +// applied VTX power, and static configuration is never rendered or serialized +// as applied VTX state. package osd import ( @@ -23,12 +35,18 @@ import ( const ( Magic0 byte = 'G' Magic1 byte = 'O' - Version byte = 1 + Version byte = 2 // Size is the fixed encoded snapshot length: magic and version, frame // sequence and timestamp, all 16 final RC channels, one flags byte, - // the publish time and the CRC. - Size = 3 + 2 + 4 + protocol.Channels*2 + 1 + 8 + 2 + // the publish time, the four adaptive-link contract values and the CRC. + Size = 3 + 2 + 4 + protocol.Channels*2 + 1 + 8 + // 50: v2 header block + 1 + // operator maxPower ceiling + 4 + 8 + // operator table generation + hash + 1 + 8 + 8 + // policy level + reason + timestamp + 4 + 8 + 1 + 1 + // ack table generation + hash + level + lifecycle + 8 + // link freshness age + 2 // CRC // DefaultStatePath is where the supervisor publishes snapshots and // where the display reads them unless configured otherwise. It lives @@ -48,13 +66,48 @@ const ( // dead UART must end OSD rendering instead of freezing stale telemetry // over live video. Freshness = 2 * time.Second + + // MaxVTXLevel is the upper bound of the VTX power level domain shared + // by the operator ceiling, the ground-station policy and the applied + // acknowledgment. + MaxVTXLevel uint8 = 127 +) + +// VTX acknowledgment lifecycle states. +const ( + // VTXLifecyclePending means no acknowledgment has arrived yet. + VTXLifecyclePending uint8 = 0 + // VTXLifecycleApplied means the VTX acknowledged applying the level. + VTXLifecycleApplied uint8 = 1 + // VTXLifecycleError means the acknowledgment reported a lifecycle error. + VTXLifecycleError uint8 = 2 ) // Snapshot flag bits in the encoded record. const ( flagArmed byte = 1 << iota - flagPower flagVideo + flagTableSelected + flagPolicyPending + flagAckPending + flagLinkKnown +) + +// Fixed field offsets of the encoded record. +const ( + publishedOffset = 42 + opMaxPowerOffset = 50 + opTableGenOffset = 51 + opTableHashOffset = 55 + policyLevelOffset = 63 + policyReasonOffset = 64 + policyTimeOffset = 72 + ackTableGenOffset = 80 + ackTableHashOffset = 84 + ackLevelOffset = 92 + ackLifecycleOffset = 93 + linkAgeOffset = 94 + crcOffset = Size - 2 ) var ( @@ -64,24 +117,95 @@ var ( ErrCRC = errors.New("groundstation/osd: invalid snapshot crc") ) +// Table identifies a selectable VTX power table by generation and content +// hash. Zero generation and hash mean no table is selected yet. The +// identifiers are the operator's opaque selection of a table target — the +// table generator (future work) owns real identity — and a statically +// configured selection is operator intent: it is never applied VTX state. +type Table struct { + Gen uint32 + Hash uint64 +} + +// Selected reports whether a table has been selected at all. +func (t Table) Selected() bool { return t.Gen != 0 || t.Hash != 0 } + +// OperatorConfig is the operator's configuration intent: the selected power +// table plus the temporary CH6 maxPower ceiling. It is never proof of +// applied VTX power: the ceiling constrains what the system may request, +// while the applied state comes only from a VTX acknowledgment. +type OperatorConfig struct { + Table Table + MaxPower uint8 // maxPower ceiling in the 0..127 level domain +} + +// GSPolicy is the ground station's requested runtime VTX power level with +// the reason and time of the request. No policy producer exists yet (the +// adaptive controller is future work), so published policies stay Pending +// rather than fabricating a level. Within the 0..127 domain, level 0 is an +// explicit off / no-runtime-command request; only Pending marks the absent +// producer. +type GSPolicy struct { + Pending bool + Level uint8 // requested level, 0..127; 0 is explicit off + Reason [8]byte // producer-defined short reason tag + TimestampMS int64 +} + +// VTXAck is the aircraft VTX's acknowledgment: the table it accepted, the +// level it applied and its lifecycle/error state. No acknowledgment +// transport exists yet, so published acks stay Pending. Applied level 0 is +// an explicit applied-off state; only Pending marks the absent transport. +type VTXAck struct { + Pending bool + Table Table + Level uint8 // applied level, 0..127; 0 is explicit off + Lifecycle uint8 +} + +// GSObservedLink is the ground-station receiver's link observation with its +// freshness. WFB RX metrics are out of scope until that producer exists, so +// the observation stays explicitly UNKNOWN instead of carrying fabricated +// numbers. +type GSObservedLink struct { + Known bool // false = explicitly unknown + AgeMS int64 // freshness of the metrics at publication; 0 while unknown +} + // Snapshot is one immutable flight state: the final RC channels, the arm -// state derived from the configured arm channel, the requested CH6 WFB power -// and the supervisor's decoded-video presence knowledge — its video -// launcher ownership, the only decoded-video signal the supervisor has — -// plus the provenance of the RC frame it was built from. The display never -// renders from this field: it gates on the video lifecycle it observes on -// the display server itself. PublishedMS is stamped at publication and is -// what the display uses to reject stale snapshots. +// state derived from the configured arm channel, the supervisor's +// decoded-video presence knowledge and the four adaptive-link contract +// values, plus the provenance of the RC frame it was built from. The display +// never renders from the Video field: it gates on the video lifecycle it +// observes on the display server itself. PublishedMS is stamped at +// publication and is what the display uses to reject stale snapshots. type Snapshot struct { Sequence uint16 TimestampMS uint32 Channels [protocol.Channels]int16 Armed bool - Power bool Video bool + OpConfig OperatorConfig + Policy GSPolicy + Ack VTXAck + Link GSObservedLink PublishedMS int64 } +// CeilingFromChannel maps the final CH6 channel onto the temporary operator +// maxPower ceiling: the full channel range -1024..1024 scales linearly onto +// the 0..127 level domain with clamping. The ceiling is operator intent +// only and never proof of applied VTX power. +func CeilingFromChannel(channel int16) uint8 { + if channel <= -1024 { + return 0 + } + if channel >= 1024 { + return MaxVTXLevel + } + return uint8((int32(channel) + 1024) * int32(MaxVTXLevel) / 2048) +} + func Encode(snapshot Snapshot) []byte { buf := make([]byte, Size) buf[0], buf[1], buf[2] = Magic0, Magic1, Version @@ -94,15 +218,35 @@ func Encode(snapshot Snapshot) []byte { if snapshot.Armed { flags |= flagArmed } - if snapshot.Power { - flags |= flagPower - } if snapshot.Video { flags |= flagVideo } + if snapshot.OpConfig.Table.Selected() { + flags |= flagTableSelected + } + if snapshot.Policy.Pending { + flags |= flagPolicyPending + } + if snapshot.Ack.Pending { + flags |= flagAckPending + } + if snapshot.Link.Known { + flags |= flagLinkKnown + } buf[41] = flags - binary.LittleEndian.PutUint64(buf[42:50], uint64(snapshot.PublishedMS)) - binary.LittleEndian.PutUint16(buf[Size-2:], protocol.CRC16CCITT(buf[:Size-2])) + binary.LittleEndian.PutUint64(buf[publishedOffset:opMaxPowerOffset], uint64(snapshot.PublishedMS)) + buf[opMaxPowerOffset] = snapshot.OpConfig.MaxPower + binary.LittleEndian.PutUint32(buf[opTableGenOffset:opTableHashOffset], snapshot.OpConfig.Table.Gen) + binary.LittleEndian.PutUint64(buf[opTableHashOffset:policyLevelOffset], snapshot.OpConfig.Table.Hash) + buf[policyLevelOffset] = snapshot.Policy.Level + copy(buf[policyReasonOffset:policyTimeOffset], snapshot.Policy.Reason[:]) + binary.LittleEndian.PutUint64(buf[policyTimeOffset:ackTableGenOffset], uint64(snapshot.Policy.TimestampMS)) + binary.LittleEndian.PutUint32(buf[ackTableGenOffset:ackTableHashOffset], snapshot.Ack.Table.Gen) + binary.LittleEndian.PutUint64(buf[ackTableHashOffset:ackLevelOffset], snapshot.Ack.Table.Hash) + buf[ackLevelOffset] = snapshot.Ack.Level + buf[ackLifecycleOffset] = snapshot.Ack.Lifecycle + binary.LittleEndian.PutUint64(buf[linkAgeOffset:crcOffset], uint64(snapshot.Link.AgeMS)) + binary.LittleEndian.PutUint16(buf[crcOffset:], protocol.CRC16CCITT(buf[:crcOffset])) return buf } @@ -117,7 +261,7 @@ func Decode(buf []byte) (Snapshot, error) { if buf[2] != Version { return snapshot, ErrVersion } - if got, want := binary.LittleEndian.Uint16(buf[Size-2:]), protocol.CRC16CCITT(buf[:Size-2]); got != want { + if got, want := binary.LittleEndian.Uint16(buf[crcOffset:]), protocol.CRC16CCITT(buf[:crcOffset]); got != want { return snapshot, fmt.Errorf("%w: got %04x want %04x", ErrCRC, got, want) } snapshot.Sequence = binary.LittleEndian.Uint16(buf[3:5]) @@ -127,9 +271,22 @@ func Decode(buf []byte) (Snapshot, error) { } flags := buf[41] snapshot.Armed = flags&flagArmed != 0 - snapshot.Power = flags&flagPower != 0 snapshot.Video = flags&flagVideo != 0 - snapshot.PublishedMS = int64(binary.LittleEndian.Uint64(buf[42:50])) + snapshot.OpConfig.MaxPower = buf[opMaxPowerOffset] + snapshot.OpConfig.Table.Gen = binary.LittleEndian.Uint32(buf[opTableGenOffset:opTableHashOffset]) + snapshot.OpConfig.Table.Hash = binary.LittleEndian.Uint64(buf[opTableHashOffset:policyLevelOffset]) + snapshot.Policy.Level = buf[policyLevelOffset] + copy(snapshot.Policy.Reason[:], buf[policyReasonOffset:policyTimeOffset]) + snapshot.Policy.TimestampMS = int64(binary.LittleEndian.Uint64(buf[policyTimeOffset:ackTableGenOffset])) + snapshot.Ack.Table.Gen = binary.LittleEndian.Uint32(buf[ackTableGenOffset:ackTableHashOffset]) + snapshot.Ack.Table.Hash = binary.LittleEndian.Uint64(buf[ackTableHashOffset:ackLevelOffset]) + snapshot.Ack.Level = buf[ackLevelOffset] + snapshot.Ack.Lifecycle = buf[ackLifecycleOffset] + snapshot.Link.AgeMS = int64(binary.LittleEndian.Uint64(buf[linkAgeOffset:crcOffset])) + snapshot.Policy.Pending = flags&flagPolicyPending != 0 + snapshot.Ack.Pending = flags&flagAckPending != 0 + snapshot.Link.Known = flags&flagLinkKnown != 0 + snapshot.PublishedMS = int64(binary.LittleEndian.Uint64(buf[publishedOffset:opMaxPowerOffset])) return snapshot, nil } diff --git a/internal/osd/snapshot_test.go b/internal/osd/snapshot_test.go index c3f6622..edbdaa2 100644 --- a/internal/osd/snapshot_test.go +++ b/internal/osd/snapshot_test.go @@ -6,8 +6,6 @@ import ( "path/filepath" "testing" "time" - - "git.piksar.eu/piotr/groundstation/internal/protocol" ) func testSnapshot() Snapshot { @@ -18,16 +16,20 @@ func testSnapshot() Snapshot { snapshot.Channels[i] = int16(-1024 + i*137) } snapshot.Armed = true - snapshot.Power = true snapshot.Video = true + snapshot.OpConfig = OperatorConfig{Table: Table{Gen: 1}, MaxPower: 95} + snapshot.Policy = GSPolicy{Pending: true} + snapshot.Ack = VTXAck{Pending: true} + snapshot.Link = GSObservedLink{Known: false} snapshot.PublishedMS = time.Now().UnixMilli() return snapshot } func TestSnapshotSizeMatchesLayout(t *testing.T) { // Header (magic, version, sequence, timestamp) + all 16 channels + - // flags + publish time + CRC must stay a small fixed record. - if Size != 3+2+4+protocol.Channels*2+1+8+2 { + // flags + publish time + the four contract values + CRC must stay a + // small fixed record. + if Size != 50+1+12+17+14+8+2 { t.Fatalf("Size=%d must match the documented layout", Size) } if encoded := Encode(testSnapshot()); len(encoded) != Size {