diff --git a/README.md b/README.md index ed9559e..c6891ab 100644 --- a/README.md +++ b/README.md @@ -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 +` 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; diff --git a/cmd/groundstation-display/main.go b/cmd/groundstation-display/main.go index f5e2aac..e40af82 100644 --- a/cmd/groundstation-display/main.go +++ b/cmd/groundstation-display/main.go @@ -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) } diff --git a/cmd/groundstation-display/main_test.go b/cmd/groundstation-display/main_test.go index dd19edb..2dadaa6 100644 --- a/cmd/groundstation-display/main_test.go +++ b/cmd/groundstation-display/main_test.go @@ -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) } diff --git a/cmd/groundstation/main.go b/cmd/groundstation/main.go index e41b258..ea69f44 100644 --- a/cmd/groundstation/main.go +++ b/cmd/groundstation/main.go @@ -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) diff --git a/cmd/groundstation/osd.go b/cmd/groundstation/osd.go new file mode 100644 index 0000000..093f71b --- /dev/null +++ b/cmd/groundstation/osd.go @@ -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 +} diff --git a/cmd/groundstation/osd_test.go b/cmd/groundstation/osd_test.go new file mode 100644 index 0000000..6c87246 --- /dev/null +++ b/cmd/groundstation/osd_test.go @@ -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") + } +} diff --git a/cmd/groundstation/video.go b/cmd/groundstation/video.go index c6fcc7a..1a834d0 100644 --- a/cmd/groundstation/video.go +++ b/cmd/groundstation/video.go @@ -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) } diff --git a/deploy/config.yml b/deploy/config.yml index b0d1367..ffe3f52 100644 --- a/deploy/config.yml +++ b/deploy/config.yml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index a1874c7..0bccb4b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 83777b5..0d978b8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/display/display.go b/internal/display/display.go index 0dc0043..3149b72 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -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 diff --git a/internal/display/display_test.go b/internal/display/display_test.go index 25c0e55..ea0b9ae 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -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{}), }, } diff --git a/internal/display/osd.go b/internal/display/osd.go index aac1b25..cad9ec2 100644 --- a/internal/display/osd.go +++ b/internal/display/osd.go @@ -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) +} diff --git a/internal/display/osd_test.go b/internal/display/osd_test.go index d99fd38..6c59dea 100644 --- a/internal/display/osd_test.go +++ b/internal/display/osd_test.go @@ -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) + } } } } diff --git a/internal/display/x11.go b/internal/display/x11.go index 7215599..a068cda 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -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). diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index 2b39473..ad804f7 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -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") + } +} diff --git a/internal/osd/osd.go b/internal/osd/osd.go new file mode 100644 index 0000000..b2fec6f --- /dev/null +++ b/internal/osd/osd.go @@ -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 +} diff --git a/internal/osd/publisher_test.go b/internal/osd/publisher_test.go new file mode 100644 index 0000000..64686a2 --- /dev/null +++ b/internal/osd/publisher_test.go @@ -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) + } +} diff --git a/internal/osd/snapshot_test.go b/internal/osd/snapshot_test.go new file mode 100644 index 0000000..c3f6622 --- /dev/null +++ b/internal/osd/snapshot_test.go @@ -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") + } +}