feat: render minimal flight OSD from supervisor snapshots (#3)

This commit is contained in:
2026-10-17 08:49:01 +02:00
parent ffae3560c4
commit f0d5052ccb
19 changed files with 1434 additions and 50 deletions
+22 -2
View File
@@ -15,11 +15,17 @@ Current capabilities:
- validates protocol version, frame length and CRC-16;
- 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;
- 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 reserved flight OSD rendering surface: it never presents pixels over the video or intercepts its input.
- 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.
Future MIPI/touch targets use their own templates rather than sharing one global layout. The flight OSD will render on the existing transparent layer above video, preserving the pilot stream's priority.
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.
## Installation on Orange Pi
@@ -55,6 +61,20 @@ serial:
`/dev/ttyS0` is the Orange Pi boot/debug UART and must not be used for the permanently connected GX12.
The flight OSD is configured alongside:
```yaml
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
```
`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.
## Runtime dependencies
- X11 session on `:0` owned by `orangepi`; the kiosk waits for X11 and reconnects after display-manager restarts;
+21 -7
View File
@@ -12,18 +12,26 @@ import (
"os"
"os/signal"
"syscall"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// osdRefresh is the OSD overlay's redraw cadence, deliberately slower than
// the video: newly published flight snapshots appear without waiting for X
// events while the video window keeps rendering priority.
const osdRefresh = time.Second
// buildTarget selects the target-specific layers. Each target owns its own
// backend and layouts; there is no shared global template. The Viture target
// builds two independent layers — the fullscreen fallback/menu canvas below
// video and the transparent OSD overlay above it — wired to one shared video
// presence signal: the sessions observe the video output lifecycle on the
// display server and the templates draw or hide from it. WFB and video
// decode never move into this process.
func buildTarget(name string) ([]display.Layer, error) {
// display server and the templates draw or hide from it. The OSD layer also
// consumes the supervisor-published flight snapshots and refreshes on its
// own slower cadence. WFB and video decode never move into this process.
func buildTarget(name string, osdState string) ([]display.Layer, error) {
switch name {
case "x11":
video := display.NewVideoPresence()
@@ -34,9 +42,13 @@ func buildTarget(name string) ([]display.Layer, error) {
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
},
{
Name: "osd-overlay",
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
Template: display.NewOSDTemplate(video),
Name: "osd-overlay",
Opener: display.X11Opener{
Video: video,
Role: display.RoleAbove,
RefreshInterval: osdRefresh,
},
Template: display.NewOSDTemplate(video, osd.NewFileSource(osdState)),
},
}, nil
default:
@@ -68,9 +80,11 @@ func formatDiagnostic(d display.Diagnostic) string {
func main() {
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
osdState := flag.String("osd-state", osd.DefaultStatePath,
"OSD snapshot state file published by the groundstation supervisor")
flag.Parse()
layers, err := buildTarget(*target)
layers, err := buildTarget(*target, *osdState)
if err != nil {
log.Fatal(err)
}
+58 -10
View File
@@ -2,10 +2,12 @@ package main
import (
"errors"
"path/filepath"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
type recordingCanvas struct {
@@ -22,16 +24,16 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
func (c *recordingCanvas) Flush() { c.flushes++ }
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
layers, err := buildTarget("x11")
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
if err != nil {
t.Fatal(err)
}
if len(layers) != 2 {
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
}
canvas, osd := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
canvas, osdLayer := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osdLayer.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osdLayer.Name)
}
canvasOpener, ok := canvas.Opener.(display.X11Opener)
@@ -41,13 +43,21 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
if canvasOpener.Role != display.RoleBelow {
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
}
osdOpener, ok := osd.Opener.(display.X11Opener)
osdOpener, ok := osdLayer.Opener.(display.X11Opener)
if !ok {
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
t.Fatalf("OSD opener %T must be the X11 target", osdLayer.Opener)
}
if osdOpener.Role != display.RoleAbove {
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
}
// The OSD refreshes on its own slower cadence; the canvas stays purely
// event-driven.
if osdOpener.RefreshInterval <= 0 {
t.Fatal("the OSD overlay must redraw on its own refresh interval")
}
if canvasOpener.RefreshInterval != 0 {
t.Fatal("the fallback canvas must keep its event-driven redraws")
}
// Both layers must share one presence signal so the video lifecycle
// observed by either session drives both templates.
@@ -63,9 +73,10 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
}
// The OSD layer is the OSD surface, never the fallback.
// The OSD layer is the OSD surface, never the fallback: without a
// published snapshot it presents nothing.
osdSurface := &recordingCanvas{width: 800, height: 600}
osd.Template.Draw(osdSurface)
osdLayer.Template.Draw(osdSurface)
if len(osdSurface.lines) != 0 {
t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines)
}
@@ -80,8 +91,45 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
canvasOpener.Video.SetActive(false)
}
func TestBuildTargetOSDLayerRendersPublishedSnapshots(t *testing.T) {
state := filepath.Join(t.TempDir(), "run", "osd-state.bin")
publisher := osd.NewFilePublisher(state, nil)
defer publisher.Close()
snapshot := osd.Snapshot{Sequence: 11, Armed: true, Power: true}
publisher.Publish(snapshot)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := osd.DecodeFile(state); err == nil {
break
}
time.Sleep(time.Millisecond)
}
layers, err := buildTarget("x11", state)
if err != nil {
t.Fatal(err)
}
osdLayer := layers[1]
osdOpener := osdLayer.Opener.(display.X11Opener)
osdOpener.Video.SetActive(true)
surface := &recordingCanvas{width: 800, height: 600}
osdLayer.Template.Draw(surface)
want := []string{"ARM ARMED", "PWR ON"}
if len(surface.lines) != len(want) {
t.Fatalf("OSD lines=%q want %q from the supervisor-published snapshot", surface.lines, want)
}
for i := range want {
if surface.lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, surface.lines[i], want[i])
}
}
}
func TestBuildTargetRejectsUnknownTarget(t *testing.T) {
if _, err := buildTarget("hdmi-mirror"); err == nil {
if _, err := buildTarget("hdmi-mirror", ""); err == nil {
t.Fatal("unknown target must fail")
}
}
@@ -101,7 +149,7 @@ func TestFormatDiagnosticRendersStructuredFields(t *testing.T) {
}
func TestWireDiagnosticsTagsEveryLayerWithItsName(t *testing.T) {
layers, err := buildTarget("x11")
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
if err != nil {
t.Fatal(err)
}
+25 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"git.piksar.eu/piotr/groundstation/internal/serialport"
)
@@ -68,8 +69,21 @@ func runMain() int {
defer func() { _ = video.Stop() }()
}
// The flight OSD snapshot publisher survives UART reconnects: the
// display keeps seeing the last state, refreshed as a heartbeat, and
// the state file is removed on shutdown so nothing stale renders.
var publisher *osd.Publisher
if cfg.OSD.StatePath != "" {
if err := os.MkdirAll(filepath.Dir(cfg.OSD.StatePath), 0755); err != nil {
logger.Printf("osd state dir: %v", err)
return 1
}
publisher = osd.NewFilePublisher(cfg.OSD.StatePath, logger)
defer publisher.Close()
}
if *stdin {
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
if err := run(ctx, os.Stdin, logger, video, cfg, publisher); err != nil && !errors.Is(err, context.Canceled) {
logger.Printf("stdin run: %v", err)
return 1
}
@@ -85,7 +99,7 @@ func runMain() int {
}
logger.Printf("UART connected: %s", cfg.Serial.Path)
err = run(ctx, port, logger, video, cfg)
err = run(ctx, port, logger, video, cfg, publisher)
_ = port.Close()
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
logger.Printf("UART disconnected: %v", err)
@@ -121,7 +135,7 @@ func wait(ctx context.Context, duration time.Duration) {
}
}
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config) error {
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config, publisher *osd.Publisher) error {
buf := make([]byte, protocol.RCSize)
var previous *protocol.RCFrame
for {
@@ -134,11 +148,18 @@ func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoC
continue
}
active := frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold
if video != nil {
if err := video.Set(frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold); err != nil {
if err := video.Set(active); err != nil {
return err
}
}
// Publish the OSD snapshot after the video decision so the
// snapshot carries the launcher presence that resulted from it.
// Publish never blocks: the UART loop only stores and wakes.
if publisher != nil {
publisher.Publish(newSnapshot(frame, cfg, video))
}
if previous == nil || previous.Channels != frame.Channels {
logger.Printf("RC seq=%d t=%d channels=%v", frame.Sequence, frame.Timestamp, frame.Channels)
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
)
// 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.
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,
}
if video != nil {
snapshot.Video = video.Running()
}
return snapshot
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"log"
"os/exec"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
)
func TestNewSnapshotDerivesArmPowerAndVideoPresence(t *testing.T) {
cfg := config.Default()
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
snapshot := newSnapshot(frame, cfg, nil)
if snapshot.Sequence != 3 || snapshot.TimestampMS != 42 {
t.Fatalf("snapshot=%+v must carry the frame provenance", snapshot)
}
if snapshot.Channels != frame.Channels {
t.Fatal("the snapshot must carry the final RC state unchanged")
}
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")
}
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{}})
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.Video {
t.Fatal("a running video launcher is the supervisor's decoded-video presence")
}
}
func TestRunningReportsVideoLauncherPresence(t *testing.T) {
video := newVideoController("fpv-video")
if video.Running() {
t.Fatal("a launcher never started must not report running")
}
video.cmd = &exec.Cmd{}
video.done = make(chan error, 1)
if !video.Running() {
t.Fatal("a live launcher must report running")
}
// A launcher that exited on its own stays recorded until the next Set;
// its completed wait must end its decoded-video presence.
video.done <- errors.New("exited")
if video.Running() {
t.Fatal("a launcher with a completed wait must not report running")
}
}
func TestRunningEndsWhenTheLauncherExitsOnItsOwn(t *testing.T) {
video := newVideoController("true")
if err := video.Set(true); err != nil {
t.Fatal(err)
}
// The launcher exits on its own; its pending wait result must end its
// decoded-video presence without anyone consuming the stop path's
// done signal.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !video.Running() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("a launcher that exited on its own must not report running")
}
func TestRunPublishesSnapshotsForValidFramesOnly(t *testing.T) {
cfg := config.Default()
first := protocol.RCFrame{Sequence: 1}
first.Channels[4] = 512
second := protocol.RCFrame{Sequence: 2}
second.Channels[4] = -256
second.Channels[5] = 100
var input bytes.Buffer
input.Write(protocol.EncodeRC(first))
input.Write([]byte("junk frame bytes that fail the CRC"))
input.Write(protocol.EncodeRC(second))
publisher := osd.NewPublisher()
if err := run(context.Background(), &input, log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF after the input ends", err)
}
got, ok := publisher.Latest()
if !ok {
t.Fatal("valid frames must publish snapshots")
}
if got.Sequence != 2 {
t.Fatalf("sequence=%d want the latest valid frame 2", got.Sequence)
}
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.PublishedMS == 0 {
t.Fatal("the publisher must stamp the publication time")
}
}
func TestRunPublishesWithoutAConfiguredVideoLauncher(t *testing.T) {
cfg := config.Default()
frame := protocol.RCFrame{Sequence: 5}
frame.Channels[5] = 0
publisher := osd.NewPublisher()
if err := run(context.Background(),
bytes.NewReader(protocol.EncodeRC(frame)),
log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF", err)
}
got, ok := publisher.Latest()
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)
}
}
func TestRunPublishNeverBlocksTheUARTPath(t *testing.T) {
// A burst of frames must be read and published with no waiting on the
// state-file writer: the publisher's Publish is non-blocking by design,
// so the loop over a large input returns promptly.
cfg := config.Default()
publisher := osd.NewFilePublisher("/proc/nonexistent/osd-state.bin", log.New(io.Discard, "", 0))
defer publisher.Close()
frame := protocol.RCFrame{Sequence: 1}
var input bytes.Buffer
for i := 0; i < 500; i++ {
frame.Sequence = uint16(i)
input.Write(protocol.EncodeRC(frame))
}
if err := run(context.Background(), &input, log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF", err)
}
if got, _ := publisher.Latest(); got.Sequence != 499 {
t.Fatalf("sequence=%d want the last frame", got.Sequence)
}
}
func TestNewSnapshotArmChannelConfigurationIsApplied(t *testing.T) {
cfg := config.Default()
cfg.OSD.ArmIndex = 6
cfg.OSD.ArmedThreshold = 256
frame := protocol.RCFrame{}
frame.Channels[6] = 257
if snapshot := newSnapshot(frame, cfg, nil); !snapshot.Armed {
t.Fatal("the configured arm index and threshold must drive the arm state")
}
}
+16
View File
@@ -51,6 +51,22 @@ func (v *videoController) Set(active bool) error {
return nil
}
// Running reports whether the video launcher is currently alive. It is the
// supervisor's decoded-video presence for the OSD snapshot: the launcher is
// the only video decoder this process knows about. A launcher that exited
// on its own stays recorded until the next Set; its pending wait result
// proves it is no longer alive without consuming the signal the stop path
// relies on. Reading this never signals or waits on the launcher, so it
// never disturbs the video path.
func (v *videoController) Running() bool {
v.mu.Lock()
defer v.mu.Unlock()
if v.cmd == nil {
return false
}
return len(v.done) == 0
}
func (v *videoController) Stop() error {
return v.Set(false)
}
+9
View File
@@ -16,6 +16,15 @@ video:
threshold: -922 # first 5% of CH6 knob remains management/off
command: fpv-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_path: /run/groundstation/osd-state.bin
arm_index: 4 # CH5/AUX1, zero-based
armed_threshold: 0
logging:
enabled: true
path: /var/log/groundstation/groundstation.log
+41 -2
View File
@@ -1,8 +1,11 @@
package config
import (
"fmt"
"os"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"gopkg.in/yaml.v3"
)
@@ -15,6 +18,16 @@ type DisplayConfig struct {
Xauthority string `yaml:"xauthority"`
}
// OSDConfig configures the supervisor's flight OSD snapshot publication.
type OSDConfig struct {
// StatePath is the snapshot state file the display process reads.
StatePath string `yaml:"state_path"`
// ArmIndex and ArmedThreshold derive the arm state from the final RC
// channels: armed while that channel stays above the threshold.
ArmIndex int `yaml:"arm_index"`
ArmedThreshold int16 `yaml:"armed_threshold"`
}
type Config struct {
Display DisplayConfig `yaml:"display"`
Serial struct {
@@ -26,6 +39,7 @@ type Config struct {
Threshold int16 `yaml:"threshold"`
Command string `yaml:"command"`
} `yaml:"video"`
OSD OSDConfig `yaml:"osd"`
Logging struct {
Enabled bool `yaml:"enabled"`
Path string `yaml:"path"`
@@ -40,6 +54,7 @@ func Default() Config {
c.Video.AuxIndex = 5 // AUX2, zero-based
c.Video.Threshold = -512
c.Video.Command = "fpv-video"
c.OSD = DefaultOSD()
c.Logging.Enabled = true
return c
}
@@ -56,17 +71,41 @@ func DefaultDisplay() DisplayConfig {
}
}
// DefaultOSD publishes snapshots to the default tmpfs path and derives the
// arm state from CH5/AUX1, armed above the midpoint.
func DefaultOSD() OSDConfig {
return OSDConfig{
StatePath: osd.DefaultStatePath,
ArmIndex: 4,
ArmedThreshold: 0,
}
}
func Load(path string) (Config, error) {
config := Default()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return config, nil
return config, config.validate()
}
return config, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return config, err
}
return config, nil
return config, config.validate()
}
// validate rejects channel indexes outside the final RC channel range: an
// out-of-range index would panic on the first decoded frame.
func (c Config) validate() error {
for name, index := range map[string]int{
"video.aux_index": c.Video.AuxIndex,
"osd.arm_index": c.OSD.ArmIndex,
} {
if index < 0 || index >= protocol.Channels {
return fmt.Errorf("%s %d outside the RC channel range 0..%d", name, index, protocol.Channels-1)
}
}
return nil
}
+53
View File
@@ -48,3 +48,56 @@ func TestLoadOverridesDisplaySettings(t *testing.T) {
t.Fatalf("display=%q want default :0", cfg.Display.Display)
}
}
func TestDefaultConfigPublishesOSDSnapshotsFromAUX1(t *testing.T) {
cfg := Default()
if cfg.OSD.StatePath != "/run/groundstation/osd-state.bin" {
t.Fatalf("state_path=%q want the default tmpfs path", cfg.OSD.StatePath)
}
// CH5/AUX1 is the arming channel; the fail-closed convention reads it
// armed above the midpoint.
if cfg.OSD.ArmIndex != 4 {
t.Fatalf("arm_index=%d want 4 (CH5/AUX1, zero-based)", cfg.OSD.ArmIndex)
}
if cfg.OSD.ArmedThreshold != 0 {
t.Fatalf("armed_threshold=%d want 0", cfg.OSD.ArmedThreshold)
}
}
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"
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.OSD.StatePath != "/tmp/osd-state.bin" {
t.Fatalf("state_path=%q want the override", cfg.OSD.StatePath)
}
if cfg.OSD.ArmIndex != 6 || cfg.OSD.ArmedThreshold != 256 {
t.Fatalf("arm config=%+v want the overrides", cfg.OSD)
}
}
func TestLoadRejectsArmAndVideoIndexesOutsideTheChannelRange(t *testing.T) {
for name, body := range map[string]string{
"arm index below range": "osd:\n arm_index: -1\n",
"arm index above range": "osd:\n arm_index: 16\n",
"video index below range": "video:\n aux_index: -1\n",
"video index above range": "video:\n aux_index: 16\n",
} {
path := filepath.Join(t.TempDir(), "config.yml")
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatalf("%s must be rejected: an out-of-range index would panic on the first frame", name)
}
}
}
+2 -1
View File
@@ -4,7 +4,8 @@
// when told to. Each target drives two independent visual layers: a
// fullscreen fallback/menu canvas below video, visible while decoded pilot
// video is absent, and a transparent, input-transparent OSD overlay above
// video, reserved as the future flight OSD rendering surface. When video
// video that renders deliberately minimal flight telemetry from
// supervisor-published OSD snapshots. When video
// output is presented the video window takes rendering priority and the
// fallback disappears, returning when video ends. Presence is observed from
// the video output itself on the display server, never from WFB or launcher
+2 -2
View File
@@ -254,7 +254,7 @@ func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
{
Name: "osd-overlay",
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
@@ -296,7 +296,7 @@ func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
{
Name: "alive",
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
+51 -13
View File
@@ -1,23 +1,61 @@
package display
// OSDTemplate is the flight OSD layout of a display target: a transparent,
// input-transparent surface above pilot video, reserved as the future OSD
// rendering surface. It presents nothing yet and never draws the fallback:
// the OSD is a telemetry surface, not a video substitute, and WFB or video
// decode never move into the UI process to feed it. Flight telemetry
// rendering arrives in later Ground Station work; the shared presence keeps
// the OSD on the same video lifecycle as the fallback canvas, so the session
// redraws it on every presence transition.
type OSDTemplate struct {
video *VideoPresence
import (
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// SnapshotSource serves the latest supervisor-published OSD snapshot while
// it is fresh. The file-backed implementation lives in the osd package; the
// template only consumes the contract.
type SnapshotSource interface {
Latest() (osd.Snapshot, bool)
}
// NewOSDTemplate builds the OSD surface for a target.
func NewOSDTemplate(video *VideoPresence) OSDTemplate {
return OSDTemplate{video: video}
// OSDTemplate is the flight OSD layout of a display target: a transparent,
// input-transparent surface above pilot video. While decoded video is
// absent the fallback canvas owns the screen and the OSD presents nothing;
// while video is present the OSD draws deliberately minimal telemetry from
// the latest published snapshot — arm state and requested CH6 WFB power.
// The rendering gate is the video presence the display observes on the
// display server, never the snapshot's Video field: the supervisor's field
// is its launcher knowledge, while the display trusts only the presented
// video output itself. The OSD never draws the fallback: it is a telemetry
// surface, not a video substitute, and WFB or video decode never move into
// the UI process to feed it. The OSD refreshes on its own slower cadence
// than the video and the video window keeps rendering priority.
type OSDTemplate struct {
video *VideoPresence
source SnapshotSource
}
// NewOSDTemplate builds the OSD surface for a target from the shared video
// presence and the supervisor-published snapshot source.
func NewOSDTemplate(video *VideoPresence, source SnapshotSource) OSDTemplate {
return OSDTemplate{video: video, source: source}
}
func (t OSDTemplate) Draw(canvas Canvas) {
canvas.Clear()
if t.video.Active() {
if snapshot, ok := t.source.Latest(); ok {
drawOSD(canvas, snapshot)
}
}
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.
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)
}
+91 -9
View File
@@ -1,43 +1,125 @@
package display
import "testing"
import (
"testing"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// stubSource serves one fixed snapshot, or none.
type stubSource struct {
snapshot osd.Snapshot
ok bool
}
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}
}
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewOSDTemplate(NewVideoPresence()).Draw(canvas)
NewOSDTemplate(NewVideoPresence(), armedSource(1)).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none: the OSD is a telemetry surface, not a fallback", lines)
t.Fatalf("OSD lines=%q want none: without video the fallback canvas owns the screen", lines)
}
}
func TestOSDTemplateStaysTransparentWhileVideoPresent(t *testing.T) {
func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence).Draw(canvas)
NewOSDTemplate(presence, armedSource(7)).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"}
if len(lines) != len(want) {
t.Fatalf("lines=%q want %q", lines, want)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
}
}
}
func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{ok: true}).Draw(canvas)
lines := canvas.lines()
want := []string{"ARM OFF", "PWR OFF"}
if len(lines) != len(want) {
t.Fatalf("lines=%q want %q", lines, want)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
}
}
}
func TestOSDTemplateStaysTransparentWithoutASnapshotWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence, stubSource{}).Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none while video is present", lines)
t.Fatalf("OSD lines=%q want none without a published snapshot", lines)
}
}
func TestOSDTemplateGatesOnObservedVideoPresenceNotSnapshotVideo(t *testing.T) {
// The rendering gate is the video lifecycle the display observes on the
// display server, never the snapshot's Video field: the supervisor's
// field is its launcher knowledge, while the display trusts only the
// presented video output itself.
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
claimingVideo := stubSource{snapshot: osd.Snapshot{Armed: true, Video: true}, ok: true}
NewOSDTemplate(presence, claimingVideo).Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("lines=%q want none: snapshot presence must never replace the observed lifecycle", lines)
}
denyingVideo := stubSource{snapshot: osd.Snapshot{Armed: true}, ok: true}
presence.SetActive(true)
NewOSDTemplate(presence, denyingVideo).Draw(canvas)
if lines := canvas.lines(); len(lines) == 0 {
t.Fatal("the observed video lifecycle must gate rendering even when the snapshot denies video")
}
}
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
template := NewOSDTemplate(presence)
template := NewOSDTemplate(presence, armedSource(3))
for _, active := range []bool{false, true, false} {
presence.SetActive(active)
template.Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("lines=%q after presence=%v want none", lines, active)
for _, line := range canvas.lines() {
if line == wantFallbackText {
t.Fatalf("the OSD is a telemetry surface, not a fallback: drew %q", line)
}
}
}
}
+109
View File
@@ -133,6 +133,30 @@ var ewmhNonVideoTypes = map[string]bool{
"POPUP_MENU": true,
}
// osdShapePadding widens each punched output region by this many pixels on
// every side, so glyph antialiasing artifacts at fixed-font edges never
// clip visibly.
const osdShapePadding = 2
// osdFallbackFontMetrics are the font metrics used when the font query
// fails: close to the common 6x13 fixed cell so a punched region still
// covers the glyphs. The OSD only ever draws terse ASCII telemetry.
func osdFallbackFontMetrics() (ascent, descent, cell int16) {
return 11, 3, 6
}
// osdTextRect is the output region covering one drawn text: its glyph cell
// around the baseline, widened by the shape padding.
func osdTextRect(x, y, units int, cell, ascent, descent int16) xproto.Rectangle {
pad := int16(osdShapePadding)
return xproto.Rectangle{
X: int16(x) - pad,
Y: int16(y) - ascent - pad,
Width: uint16(units*int(cell) + 2*int(pad)),
Height: uint16(int(ascent+descent) + 2*int(pad)),
}
}
// X11Opener connects to an X server (selected by DISPLAY in the environment)
// and maps one fullscreen kiosk window for the layer role it is built with:
// the fallback/menu canvas below video (RoleBelow) or the transparent,
@@ -143,6 +167,12 @@ type X11Opener struct {
// PollInterval is how often X events are drained while waiting, so that
// cancellation stays responsive. Zero selects 100 ms.
PollInterval time.Duration
// RefreshInterval is how often the layer redraws without an X event,
// so template data that changes independently of the display server —
// the OSD overlay's published snapshots — appears at the OSD's own
// slower cadence. Zero keeps the purely event-driven redraws of the
// fallback canvas.
RefreshInterval time.Duration
// Fullscreen applies the fullscreen window state. It defaults to
// shelling out to wmctrl; tests may replace it. A failure is logged and
// the window degrades to a plain window.
@@ -326,6 +356,18 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
// The OSD overlay punches its output shape per drawn region, so it
// needs the real glyph metrics; the canvas never punches shapes.
ascent, descent, cell := osdFallbackFontMetrics()
if o.Role == RoleAbove {
if info, err := xproto.QueryFont(conn, xproto.Fontable(font)).Reply(); err == nil {
ascent, descent = int16(info.FontAscent), int16(info.FontDescent)
if width := int16(info.MaxBounds.CharacterWidth); width > 0 {
cell = width
}
}
}
return &x11Session{
conn: conn,
window: window,
@@ -335,12 +377,16 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
black: screen.BlackPixel,
white: screen.WhitePixel,
poll: o.pollInterval(),
refresh: o.RefreshInterval,
video: presence,
videoWindows: videoWindowSet(videoWindows),
typeAtom: typeAtom,
markerAtom: markerAtom,
role: o.Role,
unicodeFont: unicodeFont,
fontAscent: ascent,
fontDescent: descent,
fontCell: cell,
pollEvent: conn.PollForEvent,
probe: connLivenessProbe(conn),
}, nil
@@ -596,12 +642,21 @@ type x11Session struct {
black uint32
white uint32
poll time.Duration
refresh time.Duration
video *VideoPresence
videoWindows map[xproto.Window]struct{}
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
fontDescent int16
fontCell int16
// drawn accumulates the output regions of texts drawn since the last
// clear; the OSD overlay's flush punches its bounding shape over it.
drawn []xproto.Rectangle
// pollEvent drains one pending X event; conn.PollForEvent in
// production. Tests script it.
pollEvent func() (xgb.Event, xgb.Error)
@@ -615,18 +670,27 @@ func (s *x11Session) Canvas() Canvas { return s }
func (s *x11Session) Size() (int, int) { return s.width, s.height }
func (s *x11Session) Clear() {
// A fresh draw pass starts with no output regions: the OSD overlay's
// next flush re-punches its shape over exactly what this pass draws.
s.resetOSDRegions()
// ClearArea tiles the window background (black, set at creation); it
// involves no graphics context.
xproto.ClearArea(s.conn, false, s.window, 0, 0,
uint16(s.width), uint16(s.height))
}
// resetOSDRegions forgets the output regions of the previous draw pass.
func (s *x11Session) resetOSDRegions() {
s.drawn = s.drawn[:0]
}
func (s *x11Session) Text(x, y int, message string) {
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
if s.unicodeFont {
chars := utf16Char2b(message, maxImageText16Units)
xproto.ImageText16(s.conn, byte(len(chars)), xproto.Drawable(s.window), s.gc,
int16(x), int16(y), chars)
s.recordOSDText(x, y, len(chars))
return
}
message = foldToASCII(message)
@@ -635,15 +699,52 @@ func (s *x11Session) Text(x, y int, message string) {
}
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
int16(x), int16(y), message)
s.recordOSDText(x, y, len(message))
}
// recordOSDText remembers one drawn text's output region so the overlay's
// flush can punch its bounding shape over exactly what was drawn. Only the
// OSD overlay punches shapes; the fallback canvas always stays plain.
func (s *x11Session) recordOSDText(x, y, units int) {
if s.role != RoleAbove || units <= 0 {
return
}
s.drawn = append(s.drawn,
osdTextRect(x, y, units, s.fontCell, s.fontAscent, s.fontDescent))
}
func (s *x11Session) Flush() {
// The OSD overlay presents pixels only where this draw pass put text:
// its bounding shape is re-punched over the recorded regions while its
// input shape stays empty, so it never covers video elsewhere and
// never intercepts input. A failure here is a broken connection; the
// liveness probe below ends the session and the layer reconnects.
if s.role == RoleAbove {
if err := applyOSDShapes(s.conn, s.window, s.drawn); err != nil {
log.Printf("osd overlay shapes: %v", err)
}
}
s.conn.Sync()
}
// applyOSDShapes sets the overlay's bounding shape to the drawn regions
// (possibly none) and keeps its input shape empty.
func applyOSDShapes(conn *xgb.Conn, window xproto.Window, rects []xproto.Rectangle) error {
if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkBounding, 0,
window, 0, 0, rects).Check(); err != nil {
return fmt.Errorf("bounding shape: %w", err)
}
if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkInput, 0,
window, 0, 0, nil).Check(); err != nil {
return fmt.Errorf("empty input shape: %w", err)
}
return nil
}
func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
ticker := time.NewTicker(s.poll)
defer ticker.Stop()
lastRefresh := time.Now()
for {
for {
event, err := s.pollEvent()
@@ -660,6 +761,14 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
return true, nil
}
}
// The OSD overlay redraws on its own slower cadence than video so
// newly published snapshots appear without waiting for X events.
// A zero interval disables it, keeping the fallback canvas purely
// event-driven.
if s.refresh > 0 && time.Since(lastRefresh) >= s.refresh {
lastRefresh = time.Now()
return true, nil
}
// Round trip keeps the connection liveness visible even without
// incoming events; the reply-bearing cookie is resolved with Reply
// (see connLivenessProbe).
+93
View File
@@ -1,10 +1,13 @@
package display
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/shape"
"github.com/BurntSushi/xgb/xproto"
)
@@ -297,3 +300,93 @@ func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
t.Fatalf("shape kinds %v must empty both the input and the bounding region", transparentShapeKinds())
}
}
func TestOSDTextRectCoversTheGlyphCellAroundTheBaseline(t *testing.T) {
rect := osdTextRect(32, 32, 9, 6, 11, 3)
if rect.X != 30 || rect.Y != 19 {
t.Fatalf("origin=(%d,%d) want (30,19): the region must start left of and above the baseline", rect.X, rect.Y)
}
if rect.Width != 9*6+4 || rect.Height != 11+3+4 {
t.Fatalf("size=%dx%d want %dx%d with padding", rect.Width, rect.Height, 9*6+4, 11+3+4)
}
if osdTextRect(0, 0, 1, 6, 11, 3).Width == 0 {
t.Fatal("a one-unit text must still produce a visible region")
}
}
func TestX11SessionRecordsOSDTextRegionsOnlyAboveVideo(t *testing.T) {
above := &x11Session{role: RoleAbove, fontCell: 6, fontAscent: 11, fontDescent: 3}
above.recordOSDText(32, 32, 9)
above.recordOSDText(32, 64, 5)
if len(above.drawn) != 2 {
t.Fatalf("drawn=%v want one region per OSD text", above.drawn)
}
if above.drawn[0] != osdTextRect(32, 32, 9, 6, 11, 3) {
t.Fatalf("first region=%v want the glyph cell of the first text", above.drawn[0])
}
above.resetOSDRegions()
if len(above.drawn) != 0 {
t.Fatalf("drawn=%v want the recorded regions reset on clear", above.drawn)
}
below := &x11Session{role: RoleBelow, fontCell: 6, fontAscent: 11, fontDescent: 3}
below.recordOSDText(32, 32, 9)
if len(below.drawn) != 0 {
t.Fatalf("drawn=%v want none: the fallback canvas never punches output shapes", below.drawn)
}
}
func TestX11SessionWaitEventRefreshesOnItsInterval(t *testing.T) {
// The OSD layer redraws on its own slower cadence: refresh must return
// redraw even with no X events pending, so template data — the latest
// published snapshot — appears without video or exposure churn.
session := &x11Session{
poll: time.Millisecond,
refresh: 2 * time.Millisecond,
pollEvent: func() (xgb.Event, xgb.Error) { return nil, nil },
probe: func() error { return nil },
}
done := make(chan eventResult, 1)
go func() {
redraw, err := session.WaitEvent(context.Background())
done <- eventResult{redraw: redraw, err: err}
}()
select {
case result := <-done:
if result.err != nil {
t.Fatalf("WaitEvent=%v", result.err)
}
if !result.redraw {
t.Fatal("the refresh interval must request a redraw")
}
case <-time.After(2 * time.Second):
t.Fatal("WaitEvent never returned the refresh redraw")
}
}
func TestX11SessionWaitEventWithoutRefreshKeepsWaiting(t *testing.T) {
probes := 0
session := &x11Session{
poll: time.Millisecond,
pollEvent: func() (xgb.Event, xgb.Error) { return nil, nil },
probe: func() error {
probes++
return nil
},
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
redraw, err := session.WaitEvent(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("WaitEvent=%v want clean cancellation without a refresh interval", err)
}
if redraw {
t.Fatal("no refresh interval must mean no refresh redraws")
}
if probes == 0 {
t.Fatal("liveness probes must continue while idle")
}
}
+331
View File
@@ -0,0 +1,331 @@
// 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
}
+183
View File
@@ -0,0 +1,183 @@
package osd
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestPublisherKeepsOnlyTheLatestSnapshot(t *testing.T) {
publisher := NewPublisher()
if _, ok := publisher.Latest(); ok {
t.Fatal("a publisher without snapshots must report none")
}
first := testSnapshot()
publisher.Publish(first)
got, ok := publisher.Latest()
if !ok {
t.Fatal("a published snapshot must be readable")
}
if got.Sequence != first.Sequence || got.PublishedMS == 0 {
t.Fatalf("latest=%+v must carry the published state with a publish timestamp", got)
}
second := first
second.Sequence = 7
second.Video = false
publisher.Publish(second)
got, _ = publisher.Latest()
if got.Sequence != 7 || got.Video {
t.Fatalf("latest=%+v must be the most recent snapshot", got)
}
}
func TestPublishNeverBlocksOnASlowWriter(t *testing.T) {
release := make(chan struct{})
var writes atomic.Int32
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"), func(string, []byte) error {
writes.Add(1)
<-release
return errors.New("unreachable")
}, Heartbeat, time.Millisecond, nil)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
snapshot := testSnapshot()
snapshot.Sequence = uint16(i)
publisher.Publish(snapshot)
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("publish must never wait for the state file writer")
}
close(release)
}
func TestFilePublisherPublishesSnapshotsToTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "run", "osd-state.bin")
publisher := NewFilePublisher(path, nil)
defer publisher.Close()
want := testSnapshot()
publisher.Publish(want)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got, err := DecodeFile(path); err == nil && got == want {
if info, statErr := os.Stat(path); statErr != nil || info.Mode().Perm() != 0644 {
t.Fatalf("state file mode=%v want 0644 for the unprivileged display user", info.Mode())
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the published snapshot never reached the state file")
}
func TestFilePublisherRewritesUnchangedStateAsHeartbeat(t *testing.T) {
var writes sync.Mutex
count := 0
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"),
func(path string, data []byte) error {
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
writes.Lock()
count++
writes.Unlock()
return nil
}, 10*time.Millisecond, 5*time.Millisecond, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
writes.Lock()
got := count
writes.Unlock()
if got >= 3 {
publisher.Close()
return
}
time.Sleep(time.Millisecond)
}
publisher.Close()
t.Fatal("an unchanged snapshot must keep being republished so it stays fresh")
}
func TestFilePublisherCloseRemovesTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := NewFilePublisher(path, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
break
}
time.Sleep(time.Millisecond)
}
publisher.Close()
publisher.Close() // must be idempotent
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("state file stat=%v want removed after close", err)
}
}
func TestFilePublisherKeepsRetryingAfterAWriteFailure(t *testing.T) {
var attempts atomic.Int32
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := newFilePublisher(path, func(path string, data []byte) error {
if attempts.Add(1) == 1 {
return errors.New("transient write failure")
}
return os.WriteFile(path, data, 0644)
}, time.Second, 5*time.Millisecond, log.New(os.Stderr, "", 0))
defer publisher.Close()
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := DecodeFile(path); err == nil {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("a failed state write must be retried, not dropped")
}
func TestFilePublisherThrottlesPersistentFailureLogs(t *testing.T) {
var logs sync.Mutex
var buffer bytes.Buffer
publisher := newFilePublisher(filepath.Join(t.TempDir(), "missing-dir", "osd-state.bin"),
func(string, []byte) error {
return errors.New("state path is broken")
}, 20*time.Millisecond, 5*time.Millisecond, log.New(&buffer, "", 0))
publisher.Publish(testSnapshot())
time.Sleep(150 * time.Millisecond)
publisher.Close()
logs.Lock()
defer logs.Unlock()
lines := strings.Count(buffer.String(), "\n")
// The write retries on every tick but must log at most once per
// heartbeat: about 150ms/20ms plus the first line, never per attempt.
if lines == 0 || lines > 8 {
t.Fatalf("failure log lines=%d want throttled reporting (1..8)", lines)
}
}
+115
View File
@@ -0,0 +1,115 @@
package osd
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/protocol"
)
func testSnapshot() Snapshot {
var snapshot Snapshot
snapshot.Sequence = 0xbEEF
snapshot.TimestampMS = 123456
for i := range snapshot.Channels {
snapshot.Channels[i] = int16(-1024 + i*137)
}
snapshot.Armed = true
snapshot.Power = true
snapshot.Video = true
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 {
t.Fatalf("Size=%d must match the documented layout", Size)
}
if encoded := Encode(testSnapshot()); len(encoded) != Size {
t.Fatalf("encoded=%d bytes want %d", len(encoded), Size)
}
}
func TestEncodeDecodeSnapshotRoundtrip(t *testing.T) {
want := testSnapshot()
got, err := Decode(Encode(want))
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("decoded=%+v want %+v", got, want)
}
}
func TestDecodeRejectsDamagedSnapshots(t *testing.T) {
encoded := Encode(testSnapshot())
if _, err := Decode(encoded[:len(encoded)-1]); !errors.Is(err, ErrLength) {
t.Fatalf("truncated decode=%v want ErrLength", err)
}
magic := append([]byte(nil), encoded...)
magic[1] = 'X'
if _, err := Decode(magic); !errors.Is(err, ErrMagic) {
t.Fatalf("magic decode=%v want ErrMagic", err)
}
version := append([]byte(nil), encoded...)
version[2] = Version + 1
if _, err := Decode(version); !errors.Is(err, ErrVersion) {
t.Fatalf("version decode=%v want ErrVersion", err)
}
corrupt := append([]byte(nil), encoded...)
corrupt[10] ^= 0xff
if _, err := Decode(corrupt); !errors.Is(err, ErrCRC) {
t.Fatalf("corrupt payload decode=%v want ErrCRC", err)
}
corruptCRC := append([]byte(nil), encoded...)
corruptCRC[len(corruptCRC)-1] ^= 0xff
if _, err := Decode(corruptCRC); !errors.Is(err, ErrCRC) {
t.Fatalf("corrupt crc decode=%v want ErrCRC", err)
}
}
func TestFileSourceReportsFreshSnapshotAndHidesStaleOrBrokenOnes(t *testing.T) {
path := filepath.Join(t.TempDir(), "osd-state.bin")
source := NewFileSource(path)
fixed := time.UnixMilli(10_000_000)
source.now = func() time.Time { return fixed }
if _, ok := source.Latest(); ok {
t.Fatal("a missing state file must report no snapshot")
}
if err := os.WriteFile(path, []byte("garbage that is not a snapshot"), 0644); err != nil {
t.Fatal(err)
}
if _, ok := source.Latest(); ok {
t.Fatal("a corrupt state file must report no snapshot")
}
want := testSnapshot()
want.PublishedMS = fixed.UnixMilli()
if err := os.WriteFile(path, Encode(want), 0644); err != nil {
t.Fatal(err)
}
got, ok := source.Latest()
if !ok {
t.Fatal("a fresh state file must report its snapshot")
}
if got != want {
t.Fatalf("got=%+v want %+v", got, want)
}
stale := want
stale.PublishedMS = fixed.Add(-Freshness - time.Second).UnixMilli()
if err := os.WriteFile(path, Encode(stale), 0644); err != nil {
t.Fatal(err)
}
if _, ok := source.Latest(); ok {
t.Fatal("a snapshot older than the freshness window must not render")
}
}