Files
groundstation/internal/osd/osd.go
T

332 lines
9.9 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, 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.
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 = 1
// 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
// 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
)
// Snapshot flag bits in the encoded record.
const (
flagArmed byte = 1 << iota
flagPower
flagVideo
)
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")
)
// 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.
type Snapshot struct {
Sequence uint16
TimestampMS uint32
Channels [protocol.Channels]int16
Armed bool
Power bool
Video bool
PublishedMS int64
}
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.Power {
flags |= flagPower
}
if snapshot.Video {
flags |= flagVideo
}
buf[41] = flags
binary.LittleEndian.PutUint64(buf[42:50], uint64(snapshot.PublishedMS))
binary.LittleEndian.PutUint16(buf[Size-2:], protocol.CRC16CCITT(buf[:Size-2]))
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[Size-2:]), protocol.CRC16CCITT(buf[:Size-2]); 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.Power = flags&flagPower != 0
snapshot.Video = flags&flagVideo != 0
snapshot.PublishedMS = int64(binary.LittleEndian.Uint64(buf[42:50]))
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)
}
// FileSource reads the supervisor-published snapshot from its state file.
// It is the display-side end of the snapshot contract: reads that fail or
// return stale snapshots report no snapshot, so the OSD stays transparent
// instead of rendering telemetry from a dead UART.
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 while it is fresh.
func (s *FileSource) Latest() (Snapshot, bool) {
snapshot, err := DecodeFile(s.path)
if err != nil {
return Snapshot{}, false
}
if age := s.now().Sub(time.UnixMilli(snapshot.PublishedMS)); age > Freshness {
return Snapshot{}, false
}
return snapshot, true
}