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
+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")
}
}