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
+21 -7
View File
@@ -12,18 +12,26 @@ import (
"os"
"os/signal"
"syscall"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// osdRefresh is the OSD overlay's redraw cadence, deliberately slower than
// the video: newly published flight snapshots appear without waiting for X
// events while the video window keeps rendering priority.
const osdRefresh = time.Second
// buildTarget selects the target-specific layers. Each target owns its own
// backend and layouts; there is no shared global template. The Viture target
// builds two independent layers — the fullscreen fallback/menu canvas below
// video and the transparent OSD overlay above it — wired to one shared video
// presence signal: the sessions observe the video output lifecycle on the
// display server and the templates draw or hide from it. WFB and video
// decode never move into this process.
func buildTarget(name string) ([]display.Layer, error) {
// display server and the templates draw or hide from it. The OSD layer also
// consumes the supervisor-published flight snapshots and refreshes on its
// own slower cadence. WFB and video decode never move into this process.
func buildTarget(name string, osdState string) ([]display.Layer, error) {
switch name {
case "x11":
video := display.NewVideoPresence()
@@ -34,9 +42,13 @@ func buildTarget(name string) ([]display.Layer, error) {
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
},
{
Name: "osd-overlay",
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
Template: display.NewOSDTemplate(video),
Name: "osd-overlay",
Opener: display.X11Opener{
Video: video,
Role: display.RoleAbove,
RefreshInterval: osdRefresh,
},
Template: display.NewOSDTemplate(video, osd.NewFileSource(osdState)),
},
}, nil
default:
@@ -68,9 +80,11 @@ func formatDiagnostic(d display.Diagnostic) string {
func main() {
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
osdState := flag.String("osd-state", osd.DefaultStatePath,
"OSD snapshot state file published by the groundstation supervisor")
flag.Parse()
layers, err := buildTarget(*target)
layers, err := buildTarget(*target, *osdState)
if err != nil {
log.Fatal(err)
}
+58 -10
View File
@@ -2,10 +2,12 @@ package main
import (
"errors"
"path/filepath"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
type recordingCanvas struct {
@@ -22,16 +24,16 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
func (c *recordingCanvas) Flush() { c.flushes++ }
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
layers, err := buildTarget("x11")
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
if err != nil {
t.Fatal(err)
}
if len(layers) != 2 {
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
}
canvas, osd := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
canvas, osdLayer := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osdLayer.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osdLayer.Name)
}
canvasOpener, ok := canvas.Opener.(display.X11Opener)
@@ -41,13 +43,21 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
if canvasOpener.Role != display.RoleBelow {
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
}
osdOpener, ok := osd.Opener.(display.X11Opener)
osdOpener, ok := osdLayer.Opener.(display.X11Opener)
if !ok {
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
t.Fatalf("OSD opener %T must be the X11 target", osdLayer.Opener)
}
if osdOpener.Role != display.RoleAbove {
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
}
// The OSD refreshes on its own slower cadence; the canvas stays purely
// event-driven.
if osdOpener.RefreshInterval <= 0 {
t.Fatal("the OSD overlay must redraw on its own refresh interval")
}
if canvasOpener.RefreshInterval != 0 {
t.Fatal("the fallback canvas must keep its event-driven redraws")
}
// Both layers must share one presence signal so the video lifecycle
// observed by either session drives both templates.
@@ -63,9 +73,10 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
}
// The OSD layer is the OSD surface, never the fallback.
// The OSD layer is the OSD surface, never the fallback: without a
// published snapshot it presents nothing.
osdSurface := &recordingCanvas{width: 800, height: 600}
osd.Template.Draw(osdSurface)
osdLayer.Template.Draw(osdSurface)
if len(osdSurface.lines) != 0 {
t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines)
}
@@ -80,8 +91,45 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
canvasOpener.Video.SetActive(false)
}
func TestBuildTargetOSDLayerRendersPublishedSnapshots(t *testing.T) {
state := filepath.Join(t.TempDir(), "run", "osd-state.bin")
publisher := osd.NewFilePublisher(state, nil)
defer publisher.Close()
snapshot := osd.Snapshot{Sequence: 11, Armed: true, Power: true}
publisher.Publish(snapshot)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := osd.DecodeFile(state); err == nil {
break
}
time.Sleep(time.Millisecond)
}
layers, err := buildTarget("x11", state)
if err != nil {
t.Fatal(err)
}
osdLayer := layers[1]
osdOpener := osdLayer.Opener.(display.X11Opener)
osdOpener.Video.SetActive(true)
surface := &recordingCanvas{width: 800, height: 600}
osdLayer.Template.Draw(surface)
want := []string{"ARM ARMED", "PWR ON"}
if len(surface.lines) != len(want) {
t.Fatalf("OSD lines=%q want %q from the supervisor-published snapshot", surface.lines, want)
}
for i := range want {
if surface.lines[i] != want[i] {
t.Fatalf("line %d=%q want %q", i, surface.lines[i], want[i])
}
}
}
func TestBuildTargetRejectsUnknownTarget(t *testing.T) {
if _, err := buildTarget("hdmi-mirror"); err == nil {
if _, err := buildTarget("hdmi-mirror", ""); err == nil {
t.Fatal("unknown target must fail")
}
}
@@ -101,7 +149,7 @@ func TestFormatDiagnosticRendersStructuredFields(t *testing.T) {
}
func TestWireDiagnosticsTagsEveryLayerWithItsName(t *testing.T) {
layers, err := buildTarget("x11")
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
if err != nil {
t.Fatal(err)
}
+25 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"git.piksar.eu/piotr/groundstation/internal/serialport"
)
@@ -68,8 +69,21 @@ func runMain() int {
defer func() { _ = video.Stop() }()
}
// The flight OSD snapshot publisher survives UART reconnects: the
// display keeps seeing the last state, refreshed as a heartbeat, and
// the state file is removed on shutdown so nothing stale renders.
var publisher *osd.Publisher
if cfg.OSD.StatePath != "" {
if err := os.MkdirAll(filepath.Dir(cfg.OSD.StatePath), 0755); err != nil {
logger.Printf("osd state dir: %v", err)
return 1
}
publisher = osd.NewFilePublisher(cfg.OSD.StatePath, logger)
defer publisher.Close()
}
if *stdin {
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
if err := run(ctx, os.Stdin, logger, video, cfg, publisher); err != nil && !errors.Is(err, context.Canceled) {
logger.Printf("stdin run: %v", err)
return 1
}
@@ -85,7 +99,7 @@ func runMain() int {
}
logger.Printf("UART connected: %s", cfg.Serial.Path)
err = run(ctx, port, logger, video, cfg)
err = run(ctx, port, logger, video, cfg, publisher)
_ = port.Close()
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
logger.Printf("UART disconnected: %v", err)
@@ -121,7 +135,7 @@ func wait(ctx context.Context, duration time.Duration) {
}
}
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config) error {
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config, publisher *osd.Publisher) error {
buf := make([]byte, protocol.RCSize)
var previous *protocol.RCFrame
for {
@@ -134,11 +148,18 @@ func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoC
continue
}
active := frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold
if video != nil {
if err := video.Set(frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold); err != nil {
if err := video.Set(active); err != nil {
return err
}
}
// Publish the OSD snapshot after the video decision so the
// snapshot carries the launcher presence that resulted from it.
// Publish never blocks: the UART loop only stores and wakes.
if publisher != nil {
publisher.Publish(newSnapshot(frame, cfg, video))
}
if previous == nil || previous.Channels != frame.Channels {
logger.Printf("RC seq=%d t=%d channels=%v", frame.Sequence, frame.Timestamp, frame.Channels)
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
)
// newSnapshot builds the immutable flight OSD snapshot from the latest
// valid RC frame: the final RC channels, the arm state derived from the
// configured arm channel, the requested CH6 WFB power (the same channel
// comparison that drives the video launcher) and the supervisor's
// decoded-video presence (its video launcher ownership). Building it is
// pure computation: publication never blocks the UART loop.
func newSnapshot(frame protocol.RCFrame, cfg config.Config, video *videoController) osd.Snapshot {
snapshot := osd.Snapshot{
Sequence: frame.Sequence,
TimestampMS: frame.Timestamp,
Channels: frame.Channels,
Armed: frame.Channels[cfg.OSD.ArmIndex] > cfg.OSD.ArmedThreshold,
Power: frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold,
}
if video != nil {
snapshot.Video = video.Running()
}
return snapshot
}
+185
View File
@@ -0,0 +1,185 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"log"
"os/exec"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
)
func TestNewSnapshotDerivesArmPowerAndVideoPresence(t *testing.T) {
cfg := config.Default()
frame := protocol.RCFrame{Sequence: 3, Timestamp: 42}
frame.Channels[4] = 512 // arm channel above the armed threshold
frame.Channels[5] = -1000 // CH6 knob below the video threshold
snapshot := newSnapshot(frame, cfg, nil)
if snapshot.Sequence != 3 || snapshot.TimestampMS != 42 {
t.Fatalf("snapshot=%+v must carry the frame provenance", snapshot)
}
if snapshot.Channels != frame.Channels {
t.Fatal("the snapshot must carry the final RC state unchanged")
}
if !snapshot.Armed {
t.Fatal("arm channel above the threshold must derive armed")
}
if snapshot.Power {
t.Fatal("CH6 below the video threshold must derive no requested WFB power")
}
if snapshot.Video {
t.Fatal("without a video launcher there is no decoded-video presence")
}
frame.Channels[4] = -256
frame.Channels[5] = 100
snapshot = newSnapshot(frame, cfg, &videoController{cmd: &exec.Cmd{}})
if snapshot.Armed {
t.Fatal("arm channel below the threshold must derive disarmed")
}
if !snapshot.Power {
t.Fatal("CH6 above the video threshold must derive requested WFB power")
}
if !snapshot.Video {
t.Fatal("a running video launcher is the supervisor's decoded-video presence")
}
}
func TestRunningReportsVideoLauncherPresence(t *testing.T) {
video := newVideoController("fpv-video")
if video.Running() {
t.Fatal("a launcher never started must not report running")
}
video.cmd = &exec.Cmd{}
video.done = make(chan error, 1)
if !video.Running() {
t.Fatal("a live launcher must report running")
}
// A launcher that exited on its own stays recorded until the next Set;
// its completed wait must end its decoded-video presence.
video.done <- errors.New("exited")
if video.Running() {
t.Fatal("a launcher with a completed wait must not report running")
}
}
func TestRunningEndsWhenTheLauncherExitsOnItsOwn(t *testing.T) {
video := newVideoController("true")
if err := video.Set(true); err != nil {
t.Fatal(err)
}
// The launcher exits on its own; its pending wait result must end its
// decoded-video presence without anyone consuming the stop path's
// done signal.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !video.Running() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("a launcher that exited on its own must not report running")
}
func TestRunPublishesSnapshotsForValidFramesOnly(t *testing.T) {
cfg := config.Default()
first := protocol.RCFrame{Sequence: 1}
first.Channels[4] = 512
second := protocol.RCFrame{Sequence: 2}
second.Channels[4] = -256
second.Channels[5] = 100
var input bytes.Buffer
input.Write(protocol.EncodeRC(first))
input.Write([]byte("junk frame bytes that fail the CRC"))
input.Write(protocol.EncodeRC(second))
publisher := osd.NewPublisher()
if err := run(context.Background(), &input, log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF after the input ends", err)
}
got, ok := publisher.Latest()
if !ok {
t.Fatal("valid frames must publish snapshots")
}
if got.Sequence != 2 {
t.Fatalf("sequence=%d want the latest valid frame 2", got.Sequence)
}
if got.Armed {
t.Fatal("the latest frame's arm state must be disarmed")
}
if !got.Power {
t.Fatal("the latest frame's CH6 must request WFB power")
}
if got.PublishedMS == 0 {
t.Fatal("the publisher must stamp the publication time")
}
}
func TestRunPublishesWithoutAConfiguredVideoLauncher(t *testing.T) {
cfg := config.Default()
frame := protocol.RCFrame{Sequence: 5}
frame.Channels[5] = 0
publisher := osd.NewPublisher()
if err := run(context.Background(),
bytes.NewReader(protocol.EncodeRC(frame)),
log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF", err)
}
got, ok := publisher.Latest()
if !ok {
t.Fatal("frames must publish even without a video launcher")
}
if !got.Power || got.Video {
t.Fatalf("snapshot=%+v must derive the requested power without video presence", got)
}
}
func TestRunPublishNeverBlocksTheUARTPath(t *testing.T) {
// A burst of frames must be read and published with no waiting on the
// state-file writer: the publisher's Publish is non-blocking by design,
// so the loop over a large input returns promptly.
cfg := config.Default()
publisher := osd.NewFilePublisher("/proc/nonexistent/osd-state.bin", log.New(io.Discard, "", 0))
defer publisher.Close()
frame := protocol.RCFrame{Sequence: 1}
var input bytes.Buffer
for i := 0; i < 500; i++ {
frame.Sequence = uint16(i)
input.Write(protocol.EncodeRC(frame))
}
if err := run(context.Background(), &input, log.New(io.Discard, "", 0), nil, cfg, publisher); !errors.Is(err, io.EOF) {
t.Fatalf("run=%v want clean EOF", err)
}
if got, _ := publisher.Latest(); got.Sequence != 499 {
t.Fatalf("sequence=%d want the last frame", got.Sequence)
}
}
func TestNewSnapshotArmChannelConfigurationIsApplied(t *testing.T) {
cfg := config.Default()
cfg.OSD.ArmIndex = 6
cfg.OSD.ArmedThreshold = 256
frame := protocol.RCFrame{}
frame.Channels[6] = 257
if snapshot := newSnapshot(frame, cfg, nil); !snapshot.Armed {
t.Fatal("the configured arm index and threshold must drive the arm state")
}
}
+16
View File
@@ -51,6 +51,22 @@ func (v *videoController) Set(active bool) error {
return nil
}
// Running reports whether the video launcher is currently alive. It is the
// supervisor's decoded-video presence for the OSD snapshot: the launcher is
// the only video decoder this process knows about. A launcher that exited
// on its own stays recorded until the next Set; its pending wait result
// proves it is no longer alive without consuming the signal the stop path
// relies on. Reading this never signals or waits on the launcher, so it
// never disturbs the video path.
func (v *videoController) Running() bool {
v.mu.Lock()
defer v.mu.Unlock()
if v.cmd == nil {
return false
}
return len(v.done) == 0
}
func (v *videoController) Stop() error {
return v.Set(false)
}