feat: publish adaptive-link OSD contract and keep overlay above video (#3)
This commit is contained in:
@@ -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
|
||||
<path>` if you change `osd.state_path`). The state file carries only flight
|
||||
telemetry and never any credentials or keys.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+42
-8
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+181
-24
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user