feat: render minimal flight OSD from supervisor snapshots (#3)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user