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
+9 -2
View File
@@ -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)
}
+16 -5
View File
@@ -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()
+34 -12
View File
@@ -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)
}
}