524 lines
19 KiB
Go
524 lines
19 KiB
Go
// 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, 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.
|
|
//
|
|
// Extension contract for future domains: the snapshot is an explicit,
|
|
// extensible foundation of independent domains — operator table identity,
|
|
// requested level, applied level, observed link, and flight telemetry
|
|
// (versioned WFB-derived battery/GPS/arm/flight-mode/attitude with source
|
|
// and freshness, future work). Telemetry domains land as new typed nested
|
|
// structs with their own explicit pending/unknown states, appended at fixed
|
|
// offsets before the CRC behind a version bump; the typed fields plus the
|
|
// versioned all-or-nothing codec are the extension seam, and no runtime
|
|
// placeholder fields exist before a domain is actually modeled. Domains are
|
|
// independent by construction: each occupies a disjoint byte range (pinned
|
|
// by contract tests), decoding is atomic — malformed or damaged records are
|
|
// rejected whole and can never clear one domain while keeping others — and
|
|
// the publisher retains its last valid snapshot, republishing it until a
|
|
// new valid snapshot replaces it. Flight telemetry stays independent of
|
|
// video and policy; battery, GPS and similar aircraft state must never be
|
|
// inferred from RC channels, and the only RC-derived flight state remains
|
|
// the already-defined CH5/AUX1 arm indication.
|
|
package osd
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.piksar.eu/piotr/groundstation/internal/protocol"
|
|
)
|
|
|
|
const (
|
|
Magic0 byte = 'G'
|
|
Magic1 byte = 'O'
|
|
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, 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
|
|
// in tmpfs: snapshots are runtime telemetry, not persistent state.
|
|
DefaultStatePath = "/run/groundstation/osd-state.bin"
|
|
|
|
// Heartbeat is how often an unchanged snapshot is republished, so the
|
|
// display keeps seeing it as fresh while the UART stays healthy.
|
|
Heartbeat = 500 * time.Millisecond
|
|
|
|
// writeInterval is the minimum gap between two state-file writes. It
|
|
// bounds the write rate when RC channels change on every frame without
|
|
// delaying an arm or power transition noticeably.
|
|
writeInterval = 50 * time.Millisecond
|
|
|
|
// Freshness is the display-side maximum age of a rendered snapshot: a
|
|
// 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
|
|
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 (
|
|
ErrLength = errors.New("groundstation/osd: invalid snapshot length")
|
|
ErrMagic = errors.New("groundstation/osd: invalid snapshot magic")
|
|
ErrVersion = errors.New("groundstation/osd: unsupported snapshot version")
|
|
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 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
|
|
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
|
|
binary.LittleEndian.PutUint16(buf[3:5], snapshot.Sequence)
|
|
binary.LittleEndian.PutUint32(buf[5:9], snapshot.TimestampMS)
|
|
for i, value := range snapshot.Channels {
|
|
binary.LittleEndian.PutUint16(buf[9+i*2:9+i*2+2], uint16(value))
|
|
}
|
|
var flags byte
|
|
if snapshot.Armed {
|
|
flags |= flagArmed
|
|
}
|
|
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[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
|
|
}
|
|
|
|
func Decode(buf []byte) (Snapshot, error) {
|
|
var snapshot Snapshot
|
|
if len(buf) != Size {
|
|
return snapshot, ErrLength
|
|
}
|
|
if buf[0] != Magic0 || buf[1] != Magic1 {
|
|
return snapshot, ErrMagic
|
|
}
|
|
if buf[2] != Version {
|
|
return snapshot, ErrVersion
|
|
}
|
|
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])
|
|
snapshot.TimestampMS = binary.LittleEndian.Uint32(buf[5:9])
|
|
for i := range snapshot.Channels {
|
|
snapshot.Channels[i] = int16(binary.LittleEndian.Uint16(buf[9+i*2 : 9+i*2+2]))
|
|
}
|
|
flags := buf[41]
|
|
snapshot.Armed = flags&flagArmed != 0
|
|
snapshot.Video = flags&flagVideo != 0
|
|
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
|
|
}
|
|
|
|
// DecodeFile reads and decodes one snapshot from its state file.
|
|
func DecodeFile(path string) (Snapshot, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
return Decode(data)
|
|
}
|
|
|
|
// Publisher publishes immutable snapshots latest-wins. Publish never blocks:
|
|
// it stores the snapshot atomically and only wakes the writer, so the UART,
|
|
// WFB and video paths never wait on state-file I/O. The writer persists the
|
|
// latest snapshot whenever it changes and republishes it as a heartbeat so
|
|
// the display keeps seeing it as fresh.
|
|
type Publisher struct {
|
|
latest atomic.Pointer[Snapshot]
|
|
wake chan struct{}
|
|
done chan struct{}
|
|
stopped chan struct{}
|
|
closeOnce sync.Once
|
|
path string
|
|
heartbeat time.Duration
|
|
writeInterval time.Duration
|
|
write func(path string, data []byte) error
|
|
logger *log.Logger
|
|
}
|
|
|
|
// NewPublisher builds an in-memory publisher without a writer goroutine.
|
|
func NewPublisher() *Publisher {
|
|
return newFilePublisher("", nil, Heartbeat, writeInterval, nil)
|
|
}
|
|
|
|
// NewFilePublisher builds a publisher that persists snapshots to path for
|
|
// the display process. Failures are logged and retried; they never block
|
|
// Publish.
|
|
func NewFilePublisher(path string, logger *log.Logger) *Publisher {
|
|
return newFilePublisher(path, writeStateFile, Heartbeat, writeInterval, logger)
|
|
}
|
|
|
|
func newFilePublisher(path string, write func(string, []byte) error, heartbeat, interval time.Duration, logger *log.Logger) *Publisher {
|
|
p := &Publisher{
|
|
wake: make(chan struct{}, 1),
|
|
done: make(chan struct{}),
|
|
stopped: make(chan struct{}),
|
|
path: path,
|
|
heartbeat: heartbeat,
|
|
writeInterval: interval,
|
|
write: write,
|
|
logger: logger,
|
|
}
|
|
if path != "" {
|
|
go p.loop()
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Publish records the snapshot and wakes the writer. The published value is
|
|
// stamped with the publication time and stored as an immutable copy.
|
|
func (p *Publisher) Publish(snapshot Snapshot) {
|
|
snapshot.PublishedMS = time.Now().UnixMilli()
|
|
p.latest.Store(&snapshot)
|
|
if p.path == "" {
|
|
return
|
|
}
|
|
select {
|
|
case p.wake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Latest reports the most recently published snapshot, if any.
|
|
func (p *Publisher) Latest() (Snapshot, bool) {
|
|
snapshot := p.latest.Load()
|
|
if snapshot == nil {
|
|
return Snapshot{}, false
|
|
}
|
|
return *snapshot, true
|
|
}
|
|
|
|
// Close stops the writer and removes the state file, so a stopped
|
|
// supervisor never leaves stale telemetry for the display to render.
|
|
// It is safe to call more than once and on an in-memory publisher.
|
|
func (p *Publisher) Close() {
|
|
p.closeOnce.Do(func() {
|
|
if p.path == "" {
|
|
return
|
|
}
|
|
close(p.done)
|
|
<-p.stopped
|
|
_ = os.Remove(p.path)
|
|
})
|
|
}
|
|
|
|
func (p *Publisher) loop() {
|
|
defer close(p.stopped)
|
|
ticker := time.NewTicker(p.writeInterval)
|
|
defer ticker.Stop()
|
|
|
|
var last Snapshot
|
|
have := false
|
|
var lastWrite time.Time
|
|
var lastErrLog time.Time
|
|
for {
|
|
select {
|
|
case <-p.wake:
|
|
case <-ticker.C:
|
|
case <-p.done:
|
|
return
|
|
}
|
|
|
|
snapshot := p.latest.Load()
|
|
if snapshot == nil {
|
|
continue
|
|
}
|
|
// Changed states go out as soon as the write floor allows; an
|
|
// unchanged state goes out on the heartbeat so the display keeps
|
|
// seeing it as fresh.
|
|
now := time.Now()
|
|
if have {
|
|
if *snapshot == last {
|
|
if now.Sub(lastWrite) < p.heartbeat {
|
|
continue
|
|
}
|
|
} else if now.Sub(lastWrite) < p.writeInterval {
|
|
continue
|
|
}
|
|
}
|
|
if err := p.write(p.path, Encode(*snapshot)); err != nil {
|
|
// Failures are retried on every tick, so a persistently
|
|
// broken state path is logged at most once per heartbeat
|
|
// instead of flooding the journal.
|
|
if p.logger != nil && now.Sub(lastErrLog) >= p.heartbeat {
|
|
p.logger.Printf("osd state write %s: %v", p.path, err)
|
|
lastErrLog = now
|
|
}
|
|
continue
|
|
}
|
|
last, have, lastWrite = *snapshot, true, now
|
|
}
|
|
}
|
|
|
|
// writeStateFile replaces the state file atomically: readers either see the
|
|
// previous complete snapshot or the new one, never a partial write. The file
|
|
// stays world-readable so the unprivileged display user can read it; it
|
|
// carries only flight telemetry.
|
|
func writeStateFile(path string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
temp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := temp.Name()
|
|
defer os.Remove(tmpPath)
|
|
if _, err := temp.Write(data); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Chmod(0644); err != nil {
|
|
temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmpPath, path)
|
|
}
|
|
|
|
// SnapshotState tells the display how to treat the latest snapshot.
|
|
type SnapshotState int
|
|
|
|
const (
|
|
// SnapshotNone means no snapshot has ever been decoded: the display
|
|
// renders explicit unknown state instead of last-known values.
|
|
SnapshotNone SnapshotState = iota
|
|
// SnapshotFresh means the snapshot is within the freshness window and
|
|
// its values are current.
|
|
SnapshotFresh
|
|
// SnapshotStale means the last successfully decoded snapshot is older
|
|
// than the freshness window: the display keeps showing its values
|
|
// marked as stale, never as current.
|
|
SnapshotStale
|
|
)
|
|
|
|
// FileSource reads the supervisor-published snapshot from its state file.
|
|
// It is the display-side end of the snapshot contract: missing or corrupt
|
|
// records report none, and a record beyond the freshness window is reported
|
|
// as stale last-known data rather than hidden, so the display can mark it
|
|
// visibly instead of freezing it as current.
|
|
type FileSource struct {
|
|
path string
|
|
// now is the clock used for freshness checks; tests replace it.
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewFileSource builds a source reading the given state file.
|
|
func NewFileSource(path string) *FileSource {
|
|
return &FileSource{path: path, now: time.Now}
|
|
}
|
|
|
|
// Latest reports the current snapshot and its state.
|
|
func (s *FileSource) Latest() (Snapshot, SnapshotState) {
|
|
snapshot, err := DecodeFile(s.path)
|
|
if err != nil {
|
|
return Snapshot{}, SnapshotNone
|
|
}
|
|
if age := s.now().Sub(time.UnixMilli(snapshot.PublishedMS)); age > Freshness {
|
|
return snapshot, SnapshotStale
|
|
}
|
|
return snapshot, SnapshotFresh
|
|
}
|