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
+131
View File
@@ -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
View File
@@ -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
}
+7 -5
View File
@@ -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 {