diff --git a/README.md b/README.md index ed9559e..14e9435 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,19 @@ 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, the adaptive-link contract values + (operator target table, temporary CH6 maxPower ceiling, requested runtime + level, VTX acknowledgment, observed link — pending/unknown until their + producers exist) 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 the flight OSD from startup on the transparent overlay — on top of the no-video fallback presentation and above fullscreen video alike: the arm state, the compact adaptive-link line, and an explicit VIDEO ON/OFF line sourced from the display-observed video lifecycle; before any snapshot has arrived it shows explicit NO DATA / unknown marks, and a snapshot beyond the freshness window stays visible marked DATA STALE instead of being presented as current — refreshing on a slower cadence than the video so the pilot stream keeps priority. -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 +63,24 @@ 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 + vtx_table_gen: 0 # operator-selected VTX table (0 = none selected) + vtx_table_hash: 0 +``` + +CH6 acts only as the temporary operator maxPower ceiling and static +configuration is never applied VTX state. `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..804ccf5 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. @@ -56,18 +66,26 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) { } // The canvas layer keeps the target-specific fallback template with the - // exact fallback text. + // exact fallback text; the OSD layer shows explicit state without a + // snapshot. Both layers are fullscreen at startup with no video — the + // canvas below (RoleBelow) and the OSD above (RoleAbove), per the + // display package's fullscreen-args contract. canvasSurface := &recordingCanvas{width: 800, height: 600} canvas.Template.Draw(canvasSurface) if len(canvasSurface.lines) != 1 || canvasSurface.lines[0] != "Brak połączenia WFB" { t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines) } - // The OSD layer is the OSD surface, never the fallback. osdSurface := &recordingCanvas{width: 800, height: 600} - osd.Template.Draw(osdSurface) - if len(osdSurface.lines) != 0 { - t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines) + osdLayer.Template.Draw(osdSurface) + wantStartup := []string{"NO DATA", "VTX tbl:-- ceil:-- req:-- ack:--", "VIDEO OFF"} + if len(osdSurface.lines) != len(wantStartup) { + t.Fatalf("OSD lines=%q want the explicit startup state %q", osdSurface.lines, wantStartup) + } + for i := range wantStartup { + if osdSurface.lines[i] != wantStartup[i] { + t.Fatalf("line %d=%q want %q", i, osdSurface.lines[i], wantStartup[i]) + } } // Video presence hides the fallback on the canvas layer only. @@ -80,8 +98,52 @@ 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, + OpConfig: osd.OperatorConfig{MaxPower: 95}, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending}, + Link: osd.GSObservedLink{Known: false}, + } + 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", "VTX tbl:-- ceil:95 req:-- ack:--", "VIDEO 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 +163,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..4c55624 --- /dev/null +++ b/cmd/groundstation/osd.go @@ -0,0 +1,38 @@ +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, and the adaptive-link contract values. CH6 feeds +// only the temporary operator maxPower ceiling — it constrains what may be +// requested and is never proof of applied VTX power. The policy, +// acknowledgment and link producers (adaptive control, the VTX ack +// transport, WFB RX metrics) do not exist yet, so the snapshot carries +// their explicit pending/unknown states instead of fabricated data. The +// supervisor's decoded-video presence stays its launcher ownership. +// Building the snapshot 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, + OpConfig: osd.OperatorConfig{ + Table: osd.Table{Gen: cfg.OSD.VtxTableGen, Hash: cfg.OSD.VtxTableHash}, + MaxPower: osd.CeilingFromChannel(frame.Channels[cfg.Video.AuxIndex]), + }, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending}, + Link: osd.GSObservedLink{Known: false}, + } + 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..fa8ad73 --- /dev/null +++ b/cmd/groundstation/osd_test.go @@ -0,0 +1,207 @@ +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 TestNewSnapshotDerivesArmCeilingAndContractStates(t *testing.T) { + cfg := config.Default() + cfg.OSD.VtxTableGen = 3 + cfg.OSD.VtxTableHash = 0xcafe + frame := protocol.RCFrame{Sequence: 3, Timestamp: 42} + frame.Channels[4] = 512 // arm channel above the armed threshold + frame.Channels[5] = 100 // CH6 knob mid-range + frame.Channels[15] = -999 // untouched channel must stay unchanged + + 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") + } + // CH6 is only the temporary operator maxPower ceiling, never applied + // VTX power. + if snapshot.OpConfig.MaxPower != osd.CeilingFromChannel(100) { + t.Fatalf("maxPower=%d want the CH6-derived ceiling", snapshot.OpConfig.MaxPower) + } + if snapshot.OpConfig.Table != (osd.Table{Gen: 3, Hash: 0xcafe}) { + t.Fatalf("table=%+v want the operator-selected table", snapshot.OpConfig.Table) + } + // No policy, acknowledgment or link producer exists yet: the snapshot + // must carry explicit pending/unknown states, never fabricated data. + if !snapshot.Policy.Pending || snapshot.Policy.Level != 0 { + t.Fatalf("policy=%+v must stay explicitly pending", snapshot.Policy) + } + if !snapshot.Ack.Pending || snapshot.Ack.Lifecycle != osd.VTXLifecyclePending { + t.Fatalf("ack=%+v must stay explicitly pending", snapshot.Ack) + } + if snapshot.Link.Known { + t.Fatalf("link=%+v must stay explicitly unknown", snapshot.Link) + } + if snapshot.Video { + t.Fatal("without a video launcher there is no decoded-video presence") + } + + frame.Channels[4] = -256 + frame.Channels[5] = -1000 + snapshot = newSnapshot(frame, cfg, &videoController{cmd: &exec.Cmd{}, done: make(chan error, 1)}) + if snapshot.Armed { + t.Fatal("arm channel below the threshold must derive disarmed") + } + if snapshot.OpConfig.MaxPower != osd.CeilingFromChannel(-1000) { + t.Fatalf("maxPower=%d must follow the CH6 knob", snapshot.OpConfig.MaxPower) + } + 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.OpConfig.MaxPower != osd.CeilingFromChannel(100) { + t.Fatalf("maxPower=%d must follow the latest frame's CH6 knob", got.OpConfig.MaxPower) + } + if !got.Policy.Pending || !got.Ack.Pending || got.Link.Known { + t.Fatalf("contract values %+v/%+v/%+v must stay pending/unknown", got.Policy, got.Ack, got.Link) + } + 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.OpConfig.MaxPower != osd.CeilingFromChannel(0) || got.Video { + t.Fatalf("snapshot=%+v must derive the ceiling 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..c2c1638 100644 --- a/deploy/config.yml +++ b/deploy/config.yml @@ -16,6 +16,22 @@ 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, the adaptive-link contract values 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 + # Operator-selected VTX power table identifiers (0 = none selected yet). + # Static configuration is operator intent only: it is never rendered or + # serialized as applied VTX state, and CH6 acts only as the temporary + # maxPower ceiling. + vtx_table_gen: 0 + vtx_table_hash: 0 + logging: enabled: true path: /var/log/groundstation/groundstation.log diff --git a/internal/config/config.go b/internal/config/config.go index a1874c7..9d617a7 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,21 @@ 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"` + // VtxTableGen and VtxTableHash are the operator-selected VTX power + // table identifiers. Zero means no table is selected yet; a static + // selection is operator intent and never applied VTX state. + VtxTableGen uint32 `yaml:"vtx_table_gen"` + VtxTableHash uint64 `yaml:"vtx_table_hash"` +} + type Config struct { Display DisplayConfig `yaml:"display"` Serial struct { @@ -26,6 +44,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 +59,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 +76,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..1774058 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -48,3 +48,69 @@ 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 vtx_table_gen: 3\n vtx_table_hash: 9\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) + } + if cfg.OSD.VtxTableGen != 3 || cfg.OSD.VtxTableHash != 9 { + t.Fatalf("table config=%+v want the selected table", cfg.OSD) + } +} + +func TestDefaultConfigSelectsNoVtxTable(t *testing.T) { + // No table exists yet: the operator selection defaults to none, so the + // OSD shows an explicit pending table instead of static configuration + // dressed up as applied VTX state. + cfg := Default() + if cfg.OSD.VtxTableGen != 0 || cfg.OSD.VtxTableHash != 0 { + t.Fatalf("table config=%+v must default to no selection", 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..259ea30 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -4,7 +4,10 @@ // 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 from startup — on top of the fallback +// presentation and above fullscreen video alike, with the observed video +// lifecycle shown as an explicit VIDEO line. 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..9acb632 100644 --- a/internal/display/osd.go +++ b/internal/display/osd.go @@ -1,23 +1,117 @@ 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 ( + "strconv" + + "git.piksar.eu/piotr/groundstation/internal/osd" +) + +// SnapshotSource serves the latest supervisor-published OSD snapshot and its +// state (none / fresh / stale). The file-backed implementation lives in the +// osd package; the template only consumes the contract. +type SnapshotSource interface { + Latest() (osd.Snapshot, osd.SnapshotState) } -// 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 everything else on the screen. It renders +// from startup — on top of the no-video fallback presentation and above +// fullscreen video alike — and never gates on video presence: the video +// lifecycle the display observes on the display server is itself displayed +// as the VIDEO line, never the snapshot's Video field. ARM and the compact +// adaptive-link line come from the latest snapshot; a stale last-known +// snapshot stays visible and marked DATA STALE, and before any snapshot has +// existed the OSD shows explicit NO DATA / unknown marks. 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 +// display-observed 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() + snapshot, state := t.source.Latest() + t.draw(canvas, snapshot, state) canvas.Flush() } + +// draw renders the deliberately minimal telemetry lines in the top-left +// corner: arm state, the adaptive-link contract, an optional staleness or +// no-data marker, and the observed video state. The full final RC state +// stays in the snapshot; the pilot-facing overlay stays terse. +func (t OSDTemplate) draw(canvas Canvas, snapshot osd.Snapshot, state osd.SnapshotState) { + y := 32 + switch state { + case osd.SnapshotNone: + canvas.Text(32, y, "NO DATA") + y += 32 + canvas.Text(32, y, "VTX tbl:-- ceil:-- req:-- ack:--") + y += 32 + default: + canvas.Text(32, y, armLine(snapshot)) + y += 32 + canvas.Text(32, y, vtxLine(snapshot)) + y += 32 + if state == osd.SnapshotStale { + canvas.Text(32, y, "DATA STALE") + y += 32 + } + } + canvas.Text(32, y, "VIDEO "+videoState(t.video.Active())) +} + +func armLine(snapshot osd.Snapshot) string { + if snapshot.Armed { + return "ARM ARMED" + } + return "ARM OFF" +} + +func videoState(active bool) string { + if active { + return "ON" + } + return "OFF" +} + +// vtxLine is the compact adaptive-link contract line. +func vtxLine(snapshot osd.Snapshot) string { + return "VTX tbl:" + vtxTable(snapshot.OpConfig.Table) + + " ceil:" + strconv.FormatUint(uint64(snapshot.OpConfig.MaxPower), 10) + + " req:" + vtxLevel(snapshot.Policy.Pending, snapshot.Policy.Level) + + " ack:" + vtxAck(snapshot.Ack) +} + +func vtxTable(table osd.Table) string { + if !table.Selected() { + return "--" + } + return strconv.FormatUint(uint64(table.Gen), 10) +} + +func vtxLevel(pending bool, level uint8) string { + if pending { + return "--" + } + return strconv.FormatUint(uint64(level), 10) +} + +func vtxAck(ack osd.VTXAck) string { + if ack.Pending { + return "--" + } + if ack.Lifecycle == osd.VTXLifecycleError { + return "ERR" + } + return vtxLevel(false, ack.Level) +} diff --git a/internal/display/osd_test.go b/internal/display/osd_test.go index d99fd38..025f4c7 100644 --- a/internal/display/osd_test.go +++ b/internal/display/osd_test.go @@ -1,43 +1,227 @@ package display -import "testing" +import ( + "strings" + "testing" -func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) { + "git.piksar.eu/piotr/groundstation/internal/osd" +) + +// stubSource serves one fixed snapshot state, or none. +type stubSource struct { + snapshot osd.Snapshot + state osd.SnapshotState +} + +func (s stubSource) Latest() (osd.Snapshot, osd.SnapshotState) { return s.snapshot, s.state } + +// pendingSource is the contract state while the adaptive-link producers do +// not exist: arm state and CH6 ceiling from the snapshot, everything else +// explicitly pending. +func pendingSource(armed bool, ceiling uint8) stubSource { + return stubSource{snapshot: osd.Snapshot{ + Armed: armed, + OpConfig: osd.OperatorConfig{MaxPower: ceiling}, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Pending: true, Lifecycle: osd.VTXLifecyclePending}, + Link: osd.GSObservedLink{Known: false}, + }, state: osd.SnapshotFresh} +} + +func assertLines(t *testing.T, canvas *fakeCanvas, want []string) { + t.Helper() + lines := canvas.lines() + if len(lines) < len(want) { + t.Fatalf("lines=%q want at least %q", lines, want) + } + // Compare only the most recent draw pass: the canvas accumulates lines + // across passes. + lines = lines[len(lines)-len(want):] + for i := range want { + if lines[i] != want[i] { + t.Fatalf("line %d=%q want %q", i, lines[i], want[i]) + } + } +} + +func TestOSDTemplateRendersExplicitNoDataState(t *testing.T) { + // Before any snapshot has ever existed the overlay must show explicit + // unknown state plus the authoritative video state instead of a blank + // overlay — including over the no-video fallback presentation. canvas := &fakeCanvas{width: 800, height: 600} - NewOSDTemplate(NewVideoPresence()).Draw(canvas) + NewOSDTemplate(NewVideoPresence(), stubSource{}).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) - } + assertLines(t, canvas, []string{ + "NO DATA", + "VTX tbl:-- ceil:-- req:-- ack:--", + "VIDEO OFF", + }) } -func TestOSDTemplateStaysTransparentWhileVideoPresent(t *testing.T) { +func TestOSDTemplateDrawsFreshSnapshotWithVideoOff(t *testing.T) { + // Fresh snapshot data renders on top of the no-video fallback + // presentation; the observed video lifecycle shows as VIDEO OFF. + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + + NewOSDTemplate(presence, pendingSource(true, 63)).Draw(canvas) + + assertLines(t, canvas, []string{ + "ARM ARMED", + "VTX tbl:-- ceil:63 req:-- ack:--", + "VIDEO OFF", + }) +} + +func TestOSDTemplateMarksStaleLastKnownData(t *testing.T) { + // A snapshot beyond the freshness window stays visible as last-known + // data, explicitly marked stale — never presented as current. + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + + NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{ + Armed: true, + OpConfig: osd.OperatorConfig{ + Table: osd.Table{Gen: 3, Hash: 0xcafe}, + MaxPower: 63, + }, + Policy: osd.GSPolicy{Level: 42}, + Ack: osd.VTXAck{ + Table: osd.Table{Gen: 3}, + Level: 40, + Lifecycle: osd.VTXLifecycleApplied, + }, + }, state: osd.SnapshotStale}).Draw(canvas) + + assertLines(t, canvas, []string{ + "ARM ARMED", + "VTX tbl:3 ceil:63 req:42 ack:40", + "DATA STALE", + "VIDEO OFF", + }) +} + +func TestOSDTemplateStaysVisibleAcrossVideoTransition(t *testing.T) { + // Video appearing fullscreen must not hide the OSD: the data lines stay + // and only the authoritative VIDEO line flips. + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + template := NewOSDTemplate(presence, pendingSource(true, 63)) + + template.Draw(canvas) + assertLines(t, canvas, []string{ + "ARM ARMED", + "VTX tbl:-- ceil:63 req:-- ack:--", + "VIDEO OFF", + }) + + presence.SetActive(true) + template.Draw(canvas) + assertLines(t, canvas, []string{ + "ARM ARMED", + "VTX tbl:-- ceil:63 req:-- ack:--", + "VIDEO ON", + }) +} + +func TestOSDTemplateRendersSelectedTableRequestedAndAppliedStates(t *testing.T) { canvas := &fakeCanvas{width: 800, height: 600} presence := NewVideoPresence() presence.SetActive(true) - NewOSDTemplate(presence).Draw(canvas) + NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{ + Armed: true, + OpConfig: osd.OperatorConfig{ + Table: osd.Table{Gen: 3, Hash: 0xcafe}, + MaxPower: 127, + }, + Policy: osd.GSPolicy{Level: 42}, + Ack: osd.VTXAck{ + Table: osd.Table{Gen: 3}, + Level: 40, + Lifecycle: osd.VTXLifecycleApplied, + }, + }, state: osd.SnapshotFresh}).Draw(canvas) - if lines := canvas.lines(); len(lines) != 0 { - t.Fatalf("OSD lines=%q want none while video is present", lines) + assertLines(t, canvas, []string{ + "ARM ARMED", + "VTX tbl:3 ceil:127 req:42 ack:40", + "VIDEO ON", + }) +} + +func TestOSDTemplateRendersAckErrorState(t *testing.T) { + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + + NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{ + OpConfig: osd.OperatorConfig{MaxPower: 10}, + Policy: osd.GSPolicy{Pending: true}, + Ack: osd.VTXAck{Lifecycle: osd.VTXLifecycleError}, + }, state: osd.SnapshotFresh}).Draw(canvas) + + assertLines(t, canvas, []string{ + "ARM OFF", + "VTX tbl:-- ceil:10 req:-- ack:ERR", + "VIDEO OFF", + }) +} + +func TestOSDTemplateRendersLevelZeroAsExplicitOff(t *testing.T) { + // Level 0 in an available policy or acknowledgment is an explicit + // off / no-runtime-command state and must render as 0; only a pending + // value — no producer yet — renders as the pending mark. + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + + NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{ + OpConfig: osd.OperatorConfig{MaxPower: 0}, + Policy: osd.GSPolicy{Level: 0}, + Ack: osd.VTXAck{Level: 0, Lifecycle: osd.VTXLifecycleApplied}, + }, state: osd.SnapshotFresh}).Draw(canvas) + + assertLines(t, canvas, []string{ + "ARM OFF", + "VTX tbl:-- ceil:0 req:0 ack:0", + "VIDEO OFF", + }) +} + +func TestOSDTemplateVideoLineUsesOnlyObservedLifecycle(t *testing.T) { + // The VIDEO line is sourced from the video lifecycle the display + // observes on the display server, never from the snapshot's Video + // field (the supervisor's launcher knowledge). + canvas := &fakeCanvas{width: 800, height: 600} + presence := NewVideoPresence() + + NewOSDTemplate(presence, stubSource{snapshot: osd.Snapshot{ + Video: true, + }, state: osd.SnapshotFresh}).Draw(canvas) + + for _, line := range canvas.lines() { + if strings.HasPrefix(line, "VIDEO") && line != "VIDEO OFF" { + t.Fatalf("line=%q: snapshot presence must never replace the observed lifecycle", line) + } } } func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) { canvas := &fakeCanvas{width: 800, height: 600} presence := NewVideoPresence() - template := NewOSDTemplate(presence) + template := NewOSDTemplate(presence, pendingSource(true, 63)) 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..251fa84 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -27,6 +27,10 @@ const ( maxImageText16Units = 127 // windowTypePropertyName is the EWMH property naming a window's role. windowTypePropertyName = "_NET_WM_WINDOW_TYPE" + // windowStatePropertyName is the EWMH property carrying a window's + // initial states; a compliant window manager must honor it when the + // window is mapped. + windowStatePropertyName = "_NET_WM_STATE" // layerMarkerPropertyName tags Ground Station layer windows so sibling // layers — and their window-manager frames — never count as video // output for each other. @@ -90,6 +94,20 @@ func fullscreenState(role LayerRole) string { return "below" } +// fullscreenStateAtoms are the initial EWMH states announced as a property +// before mapping: every layer is genuinely fullscreen and undecorated from +// its first frame, with the canvas additionally parking below and the OSD +// overlay above. A compliant window manager must honor pre-map states; the +// post-map wmctrl request remains as reinforcement, never as the only +// fullscreen mechanism. +func fullscreenStateAtoms(role LayerRole) []string { + atoms := []string{"_NET_WM_STATE_FULLSCREEN"} + if role == RoleAbove { + return append(atoms, "_NET_WM_STATE_ABOVE") + } + return append(atoms, "_NET_WM_STATE_BELOW") +} + // stackModeForVideoAppear is where a layer goes relative to the first // presented video window: the canvas parks below it, the OSD rises above it. func stackModeForVideoAppear(role LayerRole) uint32 { @@ -99,6 +117,29 @@ func stackModeForVideoAppear(role LayerRole) uint32 { return xproto.StackModeBelow } +// stackReassert decides the OSD overlay's stacking correction on its +// refresh: above the tracked video window while video is live, and raised +// to the top while video is absent — the fallback canvas raises itself to +// the top when video ends and would otherwise bury the overlay's text under +// the fullscreen presentation indefinitely. Only the OSD overlay ever +// re-asserts; the canvas and video windows are never moved by this. +func stackReassert(role LayerRole, videoActive bool, topVideo xproto.Window, videoWindows map[xproto.Window]struct{}) (sibling xproto.Window, mode uint32, ok bool) { + if role != RoleAbove { + return 0, 0, false + } + if !videoActive { + // No sibling: raise the overlay to the top of the stack. + return 0, xproto.StackModeAbove, true + } + if topVideo == 0 { + return 0, 0, false + } + if _, tracked := videoWindows[topVideo]; !tracked { + return 0, 0, false + } + return topVideo, xproto.StackModeAbove, true +} + // raisesOnVideoEnd reports whether a layer raises itself when video ends: // only the below canvas needs that to make the fallback visible again; the // OSD overlay is already above everything. @@ -133,6 +174,155 @@ 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 +} + +// createWindowValues builds the CreateWindow value mask and values; the +// order must follow the mask bit order (back pixel, event mask, cursor) so +// the server reads each value correctly. A zero cursor means cursor setup +// failed: the cursor bit is dropped entirely so the window maps with the +// server default cursor — an always-visible main window takes priority +// over cursor hiding. +func createWindowValues(backPixel uint32, eventMask uint32, cursor xproto.Cursor) (uint32, []uint32) { + if cursor == 0 { + return xproto.CwBackPixel | xproto.CwEventMask, []uint32{backPixel, eventMask} + } + values := []uint32{backPixel, eventMask, uint32(cursor)} + return xproto.CwBackPixel | xproto.CwEventMask | xproto.CwCursor, values +} + +// cursorBitmapDepth is the protocol-required depth of a cursor's source and +// mask bitmaps. Creating them at any other depth makes CreateCursor fail +// asynchronously — which previously poisoned the window id and surfaced +// only as a downstream BadWindow. +const cursorBitmapDepth = 1 + +// eventDrainBatch bounds how many pending events one wait cycle drains. A +// busy server (video fullscreen repaints) can serve unrelated non-Expose +// events faster than they arrive; an unbounded drain would never reach the +// timer checks, so the OSD overlay's initially empty bounding shape would +// never be punched and its telemetry would never appear. Thirty-two events +// per 100 ms poll cycle sustains well over three thousand events per +// second — orders of magnitude above any plausible root-event rate — while +// bounding liveness and cancellation latency to a single batch. +const eventDrainBatch = 32 + +// blankCursorX is the checked X-request surface of the blank cursor setup, +// so the sequence can be driven by tests that script failures at exact +// operations and pin request order and resource reclamation. +type blankCursorX interface { + CreatePixmapChecked(pid xproto.Pixmap, drawable xproto.Drawable, depth uint8, width, height uint16) error + CreateGCChecked(cid xproto.Gcontext, drawable xproto.Drawable) error + PolyFillRectangleChecked(drawable xproto.Drawable, gc xproto.Gcontext, rectangles []xproto.Rectangle) error + FreeGC(cid xproto.Gcontext) + CreateCursorChecked(cid xproto.Cursor, source, mask xproto.Pixmap) error + FreePixmap(pid xproto.Pixmap) +} + +type connCursorX struct{ conn *xgb.Conn } + +func (x connCursorX) CreatePixmapChecked(pid xproto.Pixmap, drawable xproto.Drawable, depth uint8, width, height uint16) error { + return xproto.CreatePixmapChecked(x.conn, depth, pid, drawable, width, height).Check() +} + +func (x connCursorX) CreateGCChecked(cid xproto.Gcontext, drawable xproto.Drawable) error { + return xproto.CreateGCChecked(x.conn, cid, drawable, 0, nil).Check() +} + +func (x connCursorX) PolyFillRectangleChecked(drawable xproto.Drawable, gc xproto.Gcontext, rectangles []xproto.Rectangle) error { + return xproto.PolyFillRectangleChecked(x.conn, drawable, gc, rectangles).Check() +} + +func (x connCursorX) FreeGC(cid xproto.Gcontext) { + xproto.FreeGC(x.conn, cid) +} + +func (x connCursorX) CreateCursorChecked(cid xproto.Cursor, source, mask xproto.Pixmap) error { + return xproto.CreateCursorChecked(x.conn, cid, source, mask, + 0, 0, 0, 0, 0, 0, 0, 0).Check() +} + +func (x connCursorX) FreePixmap(pid xproto.Pixmap) { + xproto.FreePixmap(x.conn, pid) +} + +// blankCursor allocates the ids for the blank cursor sequence and runs it +// over the connection. On failure the caller closes the connection, which +// reclaims every partially created resource. +func blankCursor(conn *xgb.Conn, screen *xproto.ScreenInfo) (xproto.Cursor, error) { + pixmap, _ := xproto.NewPixmapId(conn) + mask, _ := xproto.NewPixmapId(conn) + gc, _ := xproto.NewGcontextId(conn) + cursor, _ := xproto.NewCursorId(conn) + return blankCursorOver(connCursorX{conn}, xproto.Drawable(screen.Root), + cursorBitmapDepth, pixmap, mask, gc, cursor) +} + +// blankCursorOver performs the blank-cursor resource sequence over the +// checked request surface: 1-bit source and mask bitmaps, a matching-depth +// graphics context, both bitmaps cleared to zero (an all-zero mask displays +// nothing regardless of the source), the cursor created, then the bitmaps +// and context reclaimed. Every request is checked at its own operation: the +// first failure stops the sequence, names the exact operation, frees what +// was already created, and returns before any window request runs. +func blankCursorOver(x blankCursorX, root xproto.Drawable, depth uint8, pixmap, mask xproto.Pixmap, gc xproto.Gcontext, cursor xproto.Cursor) (xproto.Cursor, error) { + if err := x.CreatePixmapChecked(pixmap, root, depth, 1, 1); err != nil { + return 0, fmt.Errorf("create cursor source bitmap: %w", err) + } + if err := x.CreatePixmapChecked(mask, root, depth, 1, 1); err != nil { + x.FreePixmap(pixmap) + return 0, fmt.Errorf("create cursor mask bitmap: %w", err) + } + if err := x.CreateGCChecked(gc, xproto.Drawable(pixmap)); err != nil { + x.FreePixmap(mask) + x.FreePixmap(pixmap) + return 0, fmt.Errorf("create cursor graphics context: %w", err) + } + unit := []xproto.Rectangle{{Width: 1, Height: 1}} + if err := x.PolyFillRectangleChecked(xproto.Drawable(pixmap), gc, unit); err != nil { + x.FreeGC(gc) + x.FreePixmap(mask) + x.FreePixmap(pixmap) + return 0, fmt.Errorf("clear cursor source bitmap: %w", err) + } + if err := x.PolyFillRectangleChecked(xproto.Drawable(mask), gc, unit); err != nil { + x.FreeGC(gc) + x.FreePixmap(mask) + x.FreePixmap(pixmap) + return 0, fmt.Errorf("clear cursor mask bitmap: %w", err) + } + x.FreeGC(gc) + if err := x.CreateCursorChecked(cursor, pixmap, mask); err != nil { + x.FreePixmap(mask) + x.FreePixmap(pixmap) + return 0, fmt.Errorf("create blank cursor: %w", err) + } + x.FreePixmap(pixmap) + x.FreePixmap(mask) + return cursor, nil +} + +// 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 +333,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. @@ -237,11 +433,34 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) { if o.Role == RoleAbove { eventMask = osdEventMask } - values := uint32(xproto.CwBackPixel | xproto.CwEventMask) - xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root, + // The blank cursor hides the pointer over this layer from its first + // frame and stays on the layer across no-video/video transitions. It + // is optional: a cursor setup failure is surfaced as a bounded warning + // and the window still maps with the server default cursor — an + // always-visible main window takes priority over cursor hiding, and a + // cursor failure must never destroy or reconnect the display. + cursor, cursorErr := blankCursor(conn, screen) + if cursorErr != nil { + log.Printf("blank cursor unavailable (%v); mapping with the default cursor", cursorErr) + cursor = 0 + } + valueMask, values := createWindowValues(screen.BlackPixel, eventMask, cursor) + // The window creation is checked at its own operation: a failure here + // is reported exactly, and no dependent request — properties, shapes, + // map, liveness — ever runs against an invalid window. + if err := xproto.CreateWindowChecked(conn, screen.RootDepth, window, screen.Root, 0, 0, screen.WidthInPixels, screen.HeightInPixels, 0, - xproto.WindowClassInputOutput, screen.RootVisual, values, - []uint32{screen.BlackPixel, eventMask}) + xproto.WindowClassInputOutput, screen.RootVisual, valueMask, + values).Check(); err != nil { + // The window was never created: reclaim the connection (and the + // live cursor with a best-effort free) exactly like the other + // open failures, so reconnect attempts cannot leak either. + if cursor != 0 { + xproto.FreeCursor(conn, cursor) + } + conn.Close() + return nil, fmt.Errorf("create kiosk window: %w", err) + } // Tag the window as a Ground Station layer and announce its EWMH role // before mapping, so no observer or window manager ever sees an // untagged frame of our own layers. @@ -261,6 +480,26 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) { xproto.AtomAtom, 32, 1, value) } } + // Announce the initial fullscreen state before mapping: a compliant + // window manager then maps the layer fullscreen and undecorated from + // its first frame, instead of a decorated, repositioned window that + // only becomes fullscreen after a later wmctrl round trip (or never, + // when wmctrl is missing). + stateProp := internAtom(conn, windowStatePropertyName) + if stateProp != 0 { + var stateData []byte + for _, name := range fullscreenStateAtoms(o.Role) { + if atom := internAtom(conn, name); atom != 0 { + value := make([]byte, 4) + binary.LittleEndian.PutUint32(value, uint32(atom)) + stateData = append(stateData, value...) + } + } + if len(stateData) > 0 { + xproto.ChangeProperty(conn, xproto.PropModeReplace, window, stateProp, + xproto.AtomAtom, 32, uint32(len(stateData)/4), stateData) + } + } // The OSD overlay must be transparent and input-transparent from its // first mapped frame: the shapes are emptied before mapping, and a // server without the shape extension never gets the overlay at all, so @@ -291,9 +530,11 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) { presence := o.presence() typeAtom := internWindowTypeAtom(conn) videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom) + var topVideo xproto.Window if len(videoWindows) > 0 { presence.SetActive(true) - stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1], + topVideo = videoWindows[len(videoWindows)-1] + stackWindowRelative(conn, window, topVideo, stackModeForVideoAppear(o.Role)) } else { presence.SetActive(false) @@ -326,6 +567,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 +588,18 @@ 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), + topVideo: topVideo, typeAtom: typeAtom, markerAtom: markerAtom, role: o.Role, unicodeFont: unicodeFont, + cursor: cursor, + fontAscent: ascent, + fontDescent: descent, + fontCell: cell, pollEvent: conn.PollForEvent, probe: connLivenessProbe(conn), }, nil @@ -596,12 +855,28 @@ 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 + // topVideo is the video window the OSD overlay must sit above; it is + // re-asserted on the overlay's refresh because window managers may + // promote fullscreened video back on top after the appear restack. + topVideo xproto.Window + typeAtom xproto.Atom + markerAtom xproto.Atom + role LayerRole + unicodeFont bool + // cursor is the blank cursor applied to the layer window at creation; + // it is freed when the session closes. + cursor xproto.Cursor + // 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 +890,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,17 +919,59 @@ 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 { + // Drain a bounded batch of pending events: a busy server (video + // fullscreen repaints) can serve unrelated non-Expose events faster + // than they arrive, and an unbounded drain would never reach the + // timer checks — the OSD overlay's initially empty bounding shape + // would never be punched and its telemetry would never appear. + for drained := 0; drained < eventDrainBatch; drained++ { event, err := s.pollEvent() if err != nil { return false, err @@ -660,6 +986,18 @@ 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() + // The redraw is also where the overlay re-claims its place + // above fullscreen video: window managers may have promoted + // the video window back on top since the appear restack. + s.reassertStacking() + return true, nil + } // Round trip keeps the connection liveness visible even without // incoming events; the reply-bearing cookie is resolved with Reply // (see connLivenessProbe). @@ -674,6 +1012,24 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) { } } +// reassertStacking re-claims the overlay's stacking place on the OSD's +// slower refresh cadence: above the live video window, or on top of the +// stack while video is absent so the fallback canvas can never bury it. A +// no-op for the canvas layer. +func (s *x11Session) reassertStacking() { + videoActive := s.video != nil && s.video.Active() + sibling, mode, ok := stackReassert(s.role, videoActive, s.topVideo, s.videoWindows) + if !ok { + return + } + if sibling != 0 { + stackWindowRelative(s.conn, s.window, sibling, mode) + return + } + xproto.ConfigureWindow(s.conn, s.window, + xproto.ConfigWindowStackMode, []uint32{mode}) +} + // applyVideoChange folds one root structure event into the shared video // presence and restacks this layer so video keeps rendering priority: the // canvas parks below video and the OSD overlay rises above it. It reports @@ -694,13 +1050,17 @@ func (s *x11Session) applyVideoChange(event xgb.Event) bool { return false } // The first presented video output takes priority: park on our side - // of it. + // of it, and remember it as the restack target for later re-claims. + s.topVideo = w stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role)) return s.video.SetActive(true) case videoDisappeared: if !removeVideoWindow(s.videoWindows, w) { return false } + if s.topVideo == w { + s.topVideo = 0 + } // Only the below canvas raises itself so the fallback returns // visibly even if some other window was stacked above it; the OSD // overlay is already above video. @@ -791,7 +1151,12 @@ func foldToASCII(message string) string { } func (s *x11Session) Close() error { - // Closing the connection makes the X server destroy the kiosk window. + // Free the blank cursor when one was created; closing the connection + // then makes the X server reclaim everything else, including the kiosk + // window. + if s.cursor != 0 { + xproto.FreeCursor(s.conn, s.cursor) + } s.conn.Close() return nil } diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index 2b39473..bd0b778 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -1,10 +1,16 @@ package display import ( + "context" "errors" + "fmt" + "strconv" "strings" + "sync/atomic" "testing" + "time" + "github.com/BurntSushi/xgb" "github.com/BurntSushi/xgb/shape" "github.com/BurntSushi/xgb/xproto" ) @@ -283,6 +289,215 @@ func TestOSDEventMaskNeverSelectsDeviceInput(t *testing.T) { } } +func TestStackReassertKeepsTheOSDOnTop(t *testing.T) { + // While video is live the overlay must sit above the tracked video + // window; while video is absent the overlay must rise to the top, + // because the fallback canvas raises itself to the top when video ends + // and would otherwise bury the overlay's text. Only the OSD overlay + // re-asserts; the canvas and video windows are never moved by this. + windows := map[xproto.Window]struct{}{0x20: {}} + + if sibling, mode, ok := stackReassert(RoleAbove, true, 0x20, windows); !ok || sibling != 0x20 || mode != xproto.StackModeAbove { + t.Fatalf("sibling=%v mode=%v ok=%v want above the tracked video window", sibling, mode, ok) + } + if sibling, mode, ok := stackReassert(RoleAbove, false, 0, nil); !ok || sibling != 0 || mode != xproto.StackModeAbove { + t.Fatalf("sibling=%v mode=%v ok=%v want a top raise while video is absent", sibling, mode, ok) + } + if _, _, ok := stackReassert(RoleBelow, true, 0x20, windows); ok { + t.Fatal("the fallback canvas must never re-assert stacking") + } + if _, _, ok := stackReassert(RoleAbove, true, 0x30, windows); ok { + t.Fatal("a stale top video window must not be restacked against") + } + if _, _, ok := stackReassert(RoleAbove, true, 0, windows); ok { + t.Fatal("no tracked video window means no video-relative restack") + } +} + +func TestCreateWindowValuesOrderMatchesTheValueMask(t *testing.T) { + // The CreateWindow values must follow the mask bit order (back pixel, + // event mask, cursor) so the server reads each value correctly; the + // blank cursor hides the pointer over the layer from its first frame. + mask, values := createWindowValues(0x100, 0x200, 0xc0de) + if mask != xproto.CwBackPixel|xproto.CwEventMask|xproto.CwCursor { + t.Fatalf("mask=%b want back pixel, event mask and cursor bits", mask) + } + if len(values) != 3 || values[0] != 0x100 || values[1] != 0x200 || values[2] != 0xc0de { + t.Fatalf("values=%v want [backPixel eventMask cursor]", values) + } +} + +func TestFullscreenStateAtomsOrderByRole(t *testing.T) { + // Fullscreen is requested as a pre-map _NET_WM_STATE property so a + // compliant window manager maps each layer genuinely fullscreen and + // undecorated from its first frame; a post-map client message alone + // leaves a decorated, repositioned window on the screen at startup. + below := fullscreenStateAtoms(RoleBelow) + if len(below) != 2 || below[0] != "_NET_WM_STATE_FULLSCREEN" || below[1] != "_NET_WM_STATE_BELOW" { + t.Fatalf("canvas state atoms=%v want fullscreen below", below) + } + above := fullscreenStateAtoms(RoleAbove) + if len(above) != 2 || above[0] != "_NET_WM_STATE_FULLSCREEN" || above[1] != "_NET_WM_STATE_ABOVE" { + t.Fatalf("OSD state atoms=%v want fullscreen above", above) + } +} + +// scriptedCursorX scripts the checked blank-cursor request surface: it +// records every operation in order, can fail any single operation, and +// records reclaim calls — reproducing the production request-order and +// resource-lifecycle discipline without a server. +type scriptedCursorX struct { + failAt string + ops []string + freed []string + depths []uint8 +} + +func (s *scriptedCursorX) CreatePixmapChecked(pid xproto.Pixmap, _ xproto.Drawable, depth uint8, _, _ uint16) error { + s.depths = append(s.depths, depth) + s.ops = append(s.ops, "pixmap:"+strconv.FormatUint(uint64(pid), 10)) + if s.failAt == s.ops[len(s.ops)-1] { + return fmt.Errorf("bad pixmap request") + } + if s.failAt == "bitmap-depth" && depth != 1 { + return fmt.Errorf("cursor bitmaps must be depth 1") + } + return nil +} + +func (s *scriptedCursorX) CreateGCChecked(gc xproto.Gcontext, _ xproto.Drawable) error { + s.ops = append(s.ops, "gc") + if s.failAt == "gc" { + return fmt.Errorf("bad gc request") + } + return nil +} + +func (s *scriptedCursorX) PolyFillRectangleChecked(drawable xproto.Drawable, _ xproto.Gcontext, _ []xproto.Rectangle) error { + s.ops = append(s.ops, "fill:"+strconv.FormatUint(uint64(drawable), 10)) + if s.failAt == s.ops[len(s.ops)-1] { + return fmt.Errorf("bad fill request") + } + return nil +} + +func (s *scriptedCursorX) FreeGC(gc xproto.Gcontext) { + s.ops = append(s.ops, "free-gc") + s.freed = append(s.freed, "gc") +} + +func (s *scriptedCursorX) CreateCursorChecked(cid xproto.Cursor, _, _ xproto.Pixmap) error { + s.ops = append(s.ops, "cursor") + if s.failAt == "cursor" { + return fmt.Errorf("bad cursor request") + } + return nil +} + +func (s *scriptedCursorX) FreePixmap(pid xproto.Pixmap) { + s.freed = append(s.freed, "pixmap:"+strconv.FormatUint(uint64(pid), 10)) +} + +func TestBlankCursorBuildsCheckedDepthOneBitmapsInOrder(t *testing.T) { + // Cursor source and mask must be 1-bit bitmaps — the depth mismatch of + // the previous implementation is what made CreateCursor fail on the + // device and poisoned the window id. Every request is checked at its + // own operation, in order, and the pixmaps and context are reclaimed + // once the cursor exists. + x := &scriptedCursorX{} + cursor, err := blankCursorOver(x, 0x1, 1, 0x20, 0x21, 0x30, 0x40) + if err != nil { + t.Fatal(err) + } + if cursor != 0x40 { + t.Fatalf("cursor=%d want 0x40", cursor) + } + want := []string{"pixmap:32", "pixmap:33", "gc", "fill:32", "fill:33", "free-gc", "cursor"} + if strings.Join(x.ops, ",") != strings.Join(want, ",") { + t.Fatalf("ops=%v want %v", x.ops, want) + } + for _, depth := range x.depths { + if depth != 1 { + t.Fatalf("cursor bitmap depth=%d want 1", depth) + } + } + if strings.Join(x.freed, ",") != "gc,pixmap:32,pixmap:33" { + t.Fatalf("freed=%v want the context and both bitmaps reclaimed", x.freed) + } +} + +func TestBlankCursorRejectsNonBitmapDepths(t *testing.T) { + // The device regression: cursor bitmaps created at the root depth made + // the unchecked CreateCursor fail asynchronously, which poisoned the + // window id and surfaced only later as BadWindow on the shape request. + // The checked sequence must reject such a depth at its own operation. + x := &scriptedCursorX{failAt: "bitmap-depth"} + cursor, err := blankCursorOver(x, 0x1, 24, 0x20, 0x21, 0x30, 0x40) + if err == nil { + t.Fatal("a non-bitmap depth must fail the cursor sequence") + } + if cursor != 0 { + t.Fatal("a failed sequence must not return a cursor") + } + if len(x.ops) != 1 || x.ops[0] != "pixmap:32" { + t.Fatalf("ops=%v want the failure to stop the sequence at the first request", x.ops) + } +} + +func TestBlankCursorAttributesFailureToItsOwnOperation(t *testing.T) { + cases := []struct { + failAt string + want string + }{ + {"pixmap:32", "create cursor source bitmap"}, + {"pixmap:33", "create cursor mask bitmap"}, + {"gc", "create cursor graphics context"}, + {"fill:32", "clear cursor source bitmap"}, + {"fill:33", "clear cursor mask bitmap"}, + {"cursor", "create blank cursor"}, + } + for _, c := range cases { + x := &scriptedCursorX{failAt: c.failAt} + cursor, err := blankCursorOver(x, 0x1, 1, 0x20, 0x21, 0x30, 0x40) + if err == nil { + t.Fatalf("failAt=%q must fail", c.failAt) + } + if cursor != 0 { + t.Fatalf("failAt=%q must not return a cursor", c.failAt) + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("failAt=%q error=%v want attribution %q", c.failAt, err, c.want) + } + // No operation after the failed one may run — resource reclamation + // (free-gc, pixmap frees) is the expected cleanup, not a dependent + // request. + for i, op := range x.ops { + if op == c.failAt { + for _, later := range x.ops[i+1:] { + if strings.HasPrefix(later, "free-") || strings.HasPrefix(later, "pixmap:") { + continue + } + t.Fatalf("failAt=%q: dependent operation ran after failure: %v", c.failAt, later) + } + break + } + } + } +} + +func TestCreateWindowValuesOmitCursorWhenUnavailable(t *testing.T) { + // A failed cursor setup must never poison the window: with no cursor, + // the window maps with the default cursor and the value list drops the + // cursor bit entirely. + mask, values := createWindowValues(0x100, 0x200, 0) + if mask != xproto.CwBackPixel|xproto.CwEventMask { + t.Fatalf("mask=%b want back pixel and event mask only", mask) + } + if len(values) != 2 || values[0] != 0x100 || values[1] != 0x200 { + t.Fatalf("values=%v want [backPixel eventMask]", values) + } +} + func TestTransparentShapesEmptyInputAndOutput(t *testing.T) { var input, output bool for _, kind := range transparentShapeKinds() { @@ -297,3 +512,192 @@ func TestTransparentShapesEmptyInputAndOutput(t *testing.T) { t.Fatalf("shape kinds %v must empty both the input and the bounding region", transparentShapeKinds()) } } + +// stormSession builds an OSD layer session whose event source never runs +// dry: it serves an endless stream of non-Expose root events (the video +// fullscreen repaint storm), so the drain loop can only exit through its +// bound — never through an empty queue. +func stormSession(refresh time.Duration) *x11Session { + return &x11Session{ + poll: time.Millisecond, + refresh: refresh, + pollEvent: func() (xgb.Event, xgb.Error) { + return xproto.ConfigureNotifyEvent{}, nil + }, + probe: func() error { return nil }, + } +} + +func TestWaitEventRefreshesDuringContinuousEventStorm(t *testing.T) { + // The initially empty bounding shape is punched only by redraws: an + // endless stream of unrelated events must never starve the timer that + // drives the OSD refresh cadence. + session := stormSession(3 * time.Millisecond) + session.topVideo = 0 + + 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 must request a redraw during an event storm") + } + case <-time.After(2 * time.Second): + t.Fatal("WaitEvent starved: the timer redraw never fired during the storm") + } +} + +func TestWaitEventHonorsCancellationDuringEventStorm(t *testing.T) { + session := stormSession(time.Hour) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan eventResult, 1) + go func() { + redraw, err := session.WaitEvent(ctx) + done <- eventResult{redraw: redraw, err: err} + }() + select { + case result := <-done: + if result.redraw { + t.Fatal("cancellation must not be reported as a redraw") + } + if !errors.Is(result.err, context.Canceled) { + t.Fatalf("err=%v want clean cancellation during the storm", result.err) + } + case <-time.After(2 * time.Second): + t.Fatal("WaitEvent did not terminate on cancellation during the storm") + } +} + +func TestWaitEventStillRedrawsOnExposeDuringEventStorm(t *testing.T) { + var served atomic.Bool + session := stormSession(0) + session.pollEvent = func() (xgb.Event, xgb.Error) { + if served.CompareAndSwap(false, true) { + return xproto.ExposeEvent{}, nil + } + return xproto.ConfigureNotifyEvent{}, nil + } + + redraw, err := session.WaitEvent(context.Background()) + if err != nil { + t.Fatal(err) + } + if !redraw { + t.Fatal("an expose event must still trigger a redraw during a storm") + } +} + +func TestRoleBelowCanvasStaysEventDrivenDuringEventStorm(t *testing.T) { + // The fallback canvas has no refresh interval: an endless unrelated + // event stream must never produce refresh redraws, and only + // cancellation ends the wait. + session := stormSession(0) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + redraw, err := session.WaitEvent(ctx) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err=%v want clean cancellation without a refresh interval", err) + } + if redraw { + t.Fatal("the canvas must stay event-driven with no refresh redraws") + } +} + +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/contract_test.go b/internal/osd/contract_test.go new file mode 100644 index 0000000..a21bd00 --- /dev/null +++ b/internal/osd/contract_test.go @@ -0,0 +1,275 @@ +package osd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "git.piksar.eu/piotr/groundstation/internal/protocol" +) + +func TestTableSelectionRequiresAnIdentifier(t *testing.T) { + if (Table{}).Selected() { + t.Fatal("zero generation and hash mean no table is selected yet") + } + if !(Table{Gen: 3}).Selected() || !(Table{Hash: 9}).Selected() { + t.Fatal("either identifier alone selects a table") + } +} + +func TestCeilingFromChannelScalesTheKnobLinearly(t *testing.T) { + // The temporary operator maxPower ceiling maps the full CH6 channel + // range onto the 0..127 level domain. It is operator intent only. + cases := []struct { + channel int16 + want uint8 + }{ + {-1024, 0}, + {-1023, 0}, + {0, 63}, + {512, 95}, + {1023, 126}, + {1024, 127}, + } + for _, c := range cases { + if got := CeilingFromChannel(c.channel); got != c.want { + t.Fatalf("CeilingFromChannel(%d)=%d want %d", c.channel, got, c.want) + } + } + if CeilingFromChannel(-2000) != 0 || CeilingFromChannel(2000) != 127 { + t.Fatal("out-of-range channels must clamp to the level domain") + } +} + +func TestSnapshotCarriesFourDistinctContractValues(t *testing.T) { + // The four contract values must stay distinct: every field survives a + // roundtrip at its own offset, so no value can silently alias another + // (for example a mode/power collapse of CH6 ceiling and applied VTX + // state). + snapshot := Snapshot{ + Sequence: 7, + TimestampMS: 99, + Armed: true, + Video: true, + OpConfig: OperatorConfig{ + Table: Table{Gen: 3, Hash: 0xdeadbeefcafe}, + MaxPower: 95, + }, + Policy: GSPolicy{ + Pending: false, + Level: 42, + Reason: [8]byte{'a', 'd', 'a', 'p', 't'}, + TimestampMS: 1234, + }, + Ack: VTXAck{ + Pending: false, + Table: Table{Gen: 2, Hash: 0xfeed}, + Level: 40, + Lifecycle: VTXLifecycleApplied, + }, + Link: GSObservedLink{ + Known: true, + AgeMS: 250, + }, + PublishedMS: 555, + } + snapshot.Channels[5] = 100 + + got, err := Decode(Encode(snapshot)) + if err != nil { + t.Fatal(err) + } + if got != snapshot { + t.Fatalf("roundtrip lost contract state: %+v != %+v", got, snapshot) + } +} + +func TestSnapshotCarriesExplicitPendingStates(t *testing.T) { + // Until the policy, acknowledgment and link producers exist the + // snapshot must carry their explicit pending/unknown states, never + // fabricated levels or metrics. + snapshot := Snapshot{ + Armed: true, + OpConfig: OperatorConfig{ + MaxPower: 63, + }, + Policy: GSPolicy{Pending: true}, + Ack: VTXAck{Pending: true, Lifecycle: VTXLifecyclePending}, + Link: GSObservedLink{Known: false}, + } + + got, err := Decode(Encode(snapshot)) + if err != nil { + t.Fatal(err) + } + if !got.Policy.Pending || got.Policy.Level != 0 { + t.Fatalf("policy=%+v must stay explicitly pending", got.Policy) + } + if !got.Ack.Pending || got.Ack.Level != 0 || got.Ack.Lifecycle != VTXLifecyclePending { + t.Fatalf("ack=%+v must stay explicitly pending", got.Ack) + } + if got.Link.Known || got.Link.AgeMS != 0 { + t.Fatalf("link=%+v must stay explicitly unknown", got.Link) + } + if got.OpConfig != snapshot.OpConfig { + t.Fatalf("operator config=%+v must survive", got.OpConfig) + } +} + +func TestDecodeRejectsV1Snapshots(t *testing.T) { + // A v1-sized record cannot carry the contract; a v2-sized record that + // claims v1 must be rejected by version, not parsed loosely. + v1Shaped := make([]byte, Size) + v1Shaped[0], v1Shaped[1], v1Shaped[2] = Magic0, Magic1, Version-1 + if _, err := Decode(v1Shaped); err == nil { + t.Fatal("a v1-marked record must be rejected") + } +} + +func TestVTXLifecycleStatesAreDistinct(t *testing.T) { + if VTXLifecyclePending == VTXLifecycleApplied || + VTXLifecycleApplied == VTXLifecycleError || + VTXLifecyclePending == VTXLifecycleError { + t.Fatal("pending, applied and error must be distinct lifecycle states") + } +} + +func TestSnapshotDomainsAreEncodedInDisjointByteRanges(t *testing.T) { + // The four contract domains — operator configuration (table identity + // and ceiling), the requested policy level, the applied acknowledgment + // level and the observed link — must occupy disjoint byte ranges: a + // change to one domain may never alias another domain's bytes, so + // future extensions land behind a version bump instead of colliding. + base := Snapshot{ + Sequence: 1, + TimestampMS: 2, + Channels: [protocol.Channels]int16{}, + Armed: false, + Video: false, + OpConfig: OperatorConfig{ + Table: Table{Gen: 1, Hash: 1}, + MaxPower: 1, + }, + Policy: GSPolicy{Level: 1}, + Ack: VTXAck{ + Table: Table{Gen: 1}, + Level: 1, + Lifecycle: VTXLifecycleApplied, + }, + Link: GSObservedLink{Known: false}, + } + + deltas := map[string]struct { + rangeStart, rangeEnd int + mutate func(*Snapshot) + }{ + "operator-config": {opMaxPowerOffset, policyLevelOffset, func(s *Snapshot) { s.OpConfig.MaxPower = 2 }}, + "policy-level": {policyLevelOffset, ackTableGenOffset, func(s *Snapshot) { s.Policy.Level = 2 }}, + "ack-level": {ackTableGenOffset, linkAgeOffset, func(s *Snapshot) { s.Ack.Level = 2 }}, + "observed-link": {linkAgeOffset, crcOffset, func(s *Snapshot) { s.Link.AgeMS = 2 }}, + } + reference := Encode(base) + for name, delta := range deltas { + modified := base + delta.mutate(&modified) + encoded := Encode(modified) + if len(encoded) != len(reference) { + t.Fatalf("%s: encoded length changed", name) + } + for i := range encoded { + if encoded[i] == reference[i] { + continue + } + // The CRC covers the whole record and necessarily changes with + // any domain change; every payload byte must stay inside the + // domain's own range. + if i >= crcOffset { + continue + } + if i < delta.rangeStart || i >= delta.rangeEnd { + t.Fatalf("%s: byte %d changed outside the domain range [%d,%d)", + name, i, delta.rangeStart, delta.rangeEnd) + } + } + if _, err := Decode(encoded); err != nil { + t.Fatalf("%s: modified snapshot must still decode: %v", name, err) + } + } +} + +func TestDecodeRejectsCorruptionAtomically(t *testing.T) { + // Malformed input must never yield a partially decoded snapshot that + // could clear or corrupt one domain while keeping others: any flipped + // byte anywhere in the record rejects the whole snapshot, and the + // publisher keeps its last valid state until a new valid one arrives. + encoded := Encode(Snapshot{ + OpConfig: OperatorConfig{Table: Table{Gen: 5, Hash: 5}, MaxPower: 5}, + Policy: GSPolicy{Level: 5}, + Ack: VTXAck{Level: 5, Lifecycle: VTXLifecycleApplied}, + Link: GSObservedLink{Known: true, AgeMS: 5}, + }) + // Sample corruption positions across the whole record — magic, body + // regions and CRC — derived from the actual encoded length, so the + // atomicity contract holds across future version bumps and layout + // extensions without coupling to today's offsets. + positions := []int{ + 0, + len(encoded) / 4, + len(encoded) / 2, + 3 * len(encoded) / 4, + len(encoded) - 1, + } + for _, offset := range positions { + corrupt := append([]byte(nil), encoded...) + corrupt[offset] ^= 0xff + got, err := Decode(corrupt) + if err == nil { + t.Fatalf("flipped byte at %d must reject the record", offset) + } + if got != (Snapshot{}) { + t.Fatalf("flipped byte at %d must not emit a partial snapshot", offset) + } + } +} + +func TestFileCorruptionDoesNotClearLastValidSnapshot(t *testing.T) { + // A corrupted state file reports no snapshot for the moment, but the + // publisher retains its last valid snapshot and the next heartbeat + // rewrite restores exactly those values: corruption never clears + // last valid data, and nothing is fabricated in between. + path := filepath.Join(t.TempDir(), "osd-state.bin") + now := time.UnixMilli(10_000_000) + source := NewFileSource(path) + source.now = func() time.Time { return now } + + want := testSnapshot() + want.PublishedMS = now.UnixMilli() + if err := os.WriteFile(path, Encode(want), 0644); err != nil { + t.Fatal(err) + } + if _, state := source.Latest(); state != SnapshotFresh { + t.Fatalf("state=%v want fresh before corruption", state) + } + + corrupt := append([]byte(nil), Encode(want)...) + corrupt[len(corrupt)/2] ^= 0xff + if err := os.WriteFile(path, corrupt, 0644); err != nil { + t.Fatal(err) + } + if _, state := source.Latest(); state != SnapshotNone { + t.Fatalf("state=%v want none while the file is corrupt", state) + } + + // The publisher's heartbeat rewrites its retained last valid snapshot. + if err := os.WriteFile(path, Encode(want), 0644); err != nil { + t.Fatal(err) + } + got, state := source.Latest() + if state != SnapshotFresh { + t.Fatalf("state=%v want fresh after the heartbeat restore", state) + } + if got != want { + t.Fatalf("restored snapshot=%+v want the untouched last valid %+v", got, want) + } +} diff --git a/internal/osd/osd.go b/internal/osd/osd.go new file mode 100644 index 0000000..b190cbb --- /dev/null +++ b/internal/osd/osd.go @@ -0,0 +1,523 @@ +// 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, video and operator-configuration 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. +// +// The adaptive-link contract is modeled as four distinct values that must +// never be collapsed into one another: the operator configuration (selected +// power table plus the temporary CH6 maxPower ceiling), the ground-station +// policy (requested runtime level), the aircraft VTX acknowledgment (applied +// level and lifecycle) and the observed receiver link. Their producers — +// adaptive control, the VTX ack transport and WFB RX metric ingestion — do +// not exist yet and stay out of scope; until they arrive the snapshot carries +// explicit pending/unknown states instead of fabricated data. CH6 is operator +// intent only: it constrains what may be requested and is never proof of +// applied VTX power, and static configuration is never rendered or serialized +// as applied VTX state. +// +// Extension contract for future domains: the snapshot is an explicit, +// extensible foundation of independent domains — operator table identity, +// requested level, applied level, observed link, and flight telemetry +// (versioned WFB-derived battery/GPS/arm/flight-mode/attitude with source +// and freshness, future work). Telemetry domains land as new typed nested +// structs with their own explicit pending/unknown states, appended at fixed +// offsets before the CRC behind a version bump; the typed fields plus the +// versioned all-or-nothing codec are the extension seam, and no runtime +// placeholder fields exist before a domain is actually modeled. Domains are +// independent by construction: each occupies a disjoint byte range (pinned +// by contract tests), decoding is atomic — malformed or damaged records are +// rejected whole and can never clear one domain while keeping others — and +// the publisher retains its last valid snapshot, republishing it until a +// new valid snapshot replaces it. Flight telemetry stays independent of +// video and policy; battery, GPS and similar aircraft state must never be +// inferred from RC channels, and the only RC-derived flight state remains +// the already-defined CH5/AUX1 arm indication. +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 = 2 + + // 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, the four adaptive-link contract values and the CRC. + Size = 3 + 2 + 4 + protocol.Channels*2 + 1 + 8 + // 50: v2 header block + 1 + // operator maxPower ceiling + 4 + 8 + // operator table generation + hash + 1 + 8 + 8 + // policy level + reason + timestamp + 4 + 8 + 1 + 1 + // ack table generation + hash + level + lifecycle + 8 + // link freshness age + 2 // CRC + + // 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 + + // MaxVTXLevel is the upper bound of the VTX power level domain shared + // by the operator ceiling, the ground-station policy and the applied + // acknowledgment. + MaxVTXLevel uint8 = 127 +) + +// VTX acknowledgment lifecycle states. +const ( + // VTXLifecyclePending means no acknowledgment has arrived yet. + VTXLifecyclePending uint8 = 0 + // VTXLifecycleApplied means the VTX acknowledged applying the level. + VTXLifecycleApplied uint8 = 1 + // VTXLifecycleError means the acknowledgment reported a lifecycle error. + VTXLifecycleError uint8 = 2 +) + +// Snapshot flag bits in the encoded record. +const ( + flagArmed byte = 1 << iota + flagVideo + flagTableSelected + flagPolicyPending + flagAckPending + flagLinkKnown +) + +// Fixed field offsets of the encoded record. +const ( + publishedOffset = 42 + opMaxPowerOffset = 50 + opTableGenOffset = 51 + opTableHashOffset = 55 + policyLevelOffset = 63 + policyReasonOffset = 64 + policyTimeOffset = 72 + ackTableGenOffset = 80 + ackTableHashOffset = 84 + ackLevelOffset = 92 + ackLifecycleOffset = 93 + linkAgeOffset = 94 + crcOffset = Size - 2 +) + +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") +) + +// Table identifies a selectable VTX power table by generation and content +// hash. Zero generation and hash mean no table is selected yet. The +// identifiers are the operator's opaque selection of a table target — the +// table generator (future work) owns real identity — and a statically +// configured selection is operator intent: it is never applied VTX state. +type Table struct { + Gen uint32 + Hash uint64 +} + +// Selected reports whether a table has been selected at all. +func (t Table) Selected() bool { return t.Gen != 0 || t.Hash != 0 } + +// OperatorConfig is the operator's configuration intent: the selected power +// table plus the temporary CH6 maxPower ceiling. It is never proof of +// applied VTX power: the ceiling constrains what the system may request, +// while the applied state comes only from a VTX acknowledgment. +type OperatorConfig struct { + Table Table + MaxPower uint8 // maxPower ceiling in the 0..127 level domain +} + +// GSPolicy is the ground station's requested runtime VTX power level with +// the reason and time of the request. No policy producer exists yet (the +// adaptive controller is future work), so published policies stay Pending +// rather than fabricating a level. Within the 0..127 domain, level 0 is an +// explicit off / no-runtime-command request; only Pending marks the absent +// producer. +type GSPolicy struct { + Pending bool + Level uint8 // requested level, 0..127; 0 is explicit off + Reason [8]byte // producer-defined short reason tag + TimestampMS int64 +} + +// VTXAck is the aircraft VTX's acknowledgment: the table it accepted, the +// level it applied and its lifecycle/error state. No acknowledgment +// transport exists yet, so published acks stay Pending. Applied level 0 is +// an explicit applied-off state; only Pending marks the absent transport. +type VTXAck struct { + Pending bool + Table Table + Level uint8 // applied level, 0..127; 0 is explicit off + Lifecycle uint8 +} + +// GSObservedLink is the ground-station receiver's link observation with its +// freshness. WFB RX metrics are out of scope until that producer exists, so +// the observation stays explicitly UNKNOWN instead of carrying fabricated +// numbers. +type GSObservedLink struct { + Known bool // false = explicitly unknown + AgeMS int64 // freshness of the metrics at publication; 0 while unknown +} + +// Snapshot is one immutable flight state: the final RC channels, the arm +// state derived from the configured arm channel, the supervisor's +// decoded-video presence knowledge and the four adaptive-link contract +// values, plus the provenance of the RC frame it was built from. The display +// never renders from the Video 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 + Video bool + OpConfig OperatorConfig + Policy GSPolicy + Ack VTXAck + Link GSObservedLink + PublishedMS int64 +} + +// CeilingFromChannel maps the final CH6 channel onto the temporary operator +// maxPower ceiling: the full channel range -1024..1024 scales linearly onto +// the 0..127 level domain with clamping. The ceiling is operator intent +// only and never proof of applied VTX power. +func CeilingFromChannel(channel int16) uint8 { + if channel <= -1024 { + return 0 + } + if channel >= 1024 { + return MaxVTXLevel + } + return uint8((int32(channel) + 1024) * int32(MaxVTXLevel) / 2048) +} + +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.Video { + flags |= flagVideo + } + if snapshot.OpConfig.Table.Selected() { + flags |= flagTableSelected + } + if snapshot.Policy.Pending { + flags |= flagPolicyPending + } + if snapshot.Ack.Pending { + flags |= flagAckPending + } + if snapshot.Link.Known { + flags |= flagLinkKnown + } + buf[41] = flags + binary.LittleEndian.PutUint64(buf[publishedOffset:opMaxPowerOffset], uint64(snapshot.PublishedMS)) + buf[opMaxPowerOffset] = snapshot.OpConfig.MaxPower + binary.LittleEndian.PutUint32(buf[opTableGenOffset:opTableHashOffset], snapshot.OpConfig.Table.Gen) + binary.LittleEndian.PutUint64(buf[opTableHashOffset:policyLevelOffset], snapshot.OpConfig.Table.Hash) + buf[policyLevelOffset] = snapshot.Policy.Level + copy(buf[policyReasonOffset:policyTimeOffset], snapshot.Policy.Reason[:]) + binary.LittleEndian.PutUint64(buf[policyTimeOffset:ackTableGenOffset], uint64(snapshot.Policy.TimestampMS)) + binary.LittleEndian.PutUint32(buf[ackTableGenOffset:ackTableHashOffset], snapshot.Ack.Table.Gen) + binary.LittleEndian.PutUint64(buf[ackTableHashOffset:ackLevelOffset], snapshot.Ack.Table.Hash) + buf[ackLevelOffset] = snapshot.Ack.Level + buf[ackLifecycleOffset] = snapshot.Ack.Lifecycle + binary.LittleEndian.PutUint64(buf[linkAgeOffset:crcOffset], uint64(snapshot.Link.AgeMS)) + binary.LittleEndian.PutUint16(buf[crcOffset:], protocol.CRC16CCITT(buf[:crcOffset])) + 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[crcOffset:]), protocol.CRC16CCITT(buf[:crcOffset]); 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.Video = flags&flagVideo != 0 + snapshot.OpConfig.MaxPower = buf[opMaxPowerOffset] + snapshot.OpConfig.Table.Gen = binary.LittleEndian.Uint32(buf[opTableGenOffset:opTableHashOffset]) + snapshot.OpConfig.Table.Hash = binary.LittleEndian.Uint64(buf[opTableHashOffset:policyLevelOffset]) + snapshot.Policy.Level = buf[policyLevelOffset] + copy(snapshot.Policy.Reason[:], buf[policyReasonOffset:policyTimeOffset]) + snapshot.Policy.TimestampMS = int64(binary.LittleEndian.Uint64(buf[policyTimeOffset:ackTableGenOffset])) + snapshot.Ack.Table.Gen = binary.LittleEndian.Uint32(buf[ackTableGenOffset:ackTableHashOffset]) + snapshot.Ack.Table.Hash = binary.LittleEndian.Uint64(buf[ackTableHashOffset:ackLevelOffset]) + snapshot.Ack.Level = buf[ackLevelOffset] + snapshot.Ack.Lifecycle = buf[ackLifecycleOffset] + snapshot.Link.AgeMS = int64(binary.LittleEndian.Uint64(buf[linkAgeOffset:crcOffset])) + snapshot.Policy.Pending = flags&flagPolicyPending != 0 + snapshot.Ack.Pending = flags&flagAckPending != 0 + snapshot.Link.Known = flags&flagLinkKnown != 0 + snapshot.PublishedMS = int64(binary.LittleEndian.Uint64(buf[publishedOffset:opMaxPowerOffset])) + 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) +} + +// SnapshotState tells the display how to treat the latest snapshot. +type SnapshotState int + +const ( + // SnapshotNone means no snapshot has ever been decoded: the display + // renders explicit unknown state instead of last-known values. + SnapshotNone SnapshotState = iota + // SnapshotFresh means the snapshot is within the freshness window and + // its values are current. + SnapshotFresh + // SnapshotStale means the last successfully decoded snapshot is older + // than the freshness window: the display keeps showing its values + // marked as stale, never as current. + SnapshotStale +) + +// FileSource reads the supervisor-published snapshot from its state file. +// It is the display-side end of the snapshot contract: missing or corrupt +// records report none, and a record beyond the freshness window is reported +// as stale last-known data rather than hidden, so the display can mark it +// visibly instead of freezing it as current. +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 and its state. +func (s *FileSource) Latest() (Snapshot, SnapshotState) { + snapshot, err := DecodeFile(s.path) + if err != nil { + return Snapshot{}, SnapshotNone + } + if age := s.now().Sub(time.UnixMilli(snapshot.PublishedMS)); age > Freshness { + return snapshot, SnapshotStale + } + return snapshot, SnapshotFresh +} 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..56c0f55 --- /dev/null +++ b/internal/osd/snapshot_test.go @@ -0,0 +1,156 @@ +package osd + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +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.Video = true + snapshot.OpConfig = OperatorConfig{Table: Table{Gen: 1}, MaxPower: 95} + snapshot.Policy = GSPolicy{Pending: true} + snapshot.Ack = VTXAck{Pending: true} + snapshot.Link = GSObservedLink{Known: false} + snapshot.PublishedMS = time.Now().UnixMilli() + return snapshot +} + +func TestSnapshotSizeMatchesLayout(t *testing.T) { + // Header (magic, version, sequence, timestamp) + all 16 channels + + // flags + publish time + the four contract values + CRC must stay a + // small fixed record. + if Size != 50+1+12+17+14+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 TestFileSourceAgeIsNotResetByRewritingIdenticalBytes(t *testing.T) { + // Publisher heartbeats rewrite the last snapshot's identical bytes; the + // display's staleness must key on the snapshot's own publish time, so a + // rewrite can never present stale data as current. + path := filepath.Join(t.TempDir(), "osd-state.bin") + now := time.UnixMilli(10_000_000) + source := NewFileSource(path) + source.now = func() time.Time { return now } + + want := testSnapshot() + want.PublishedMS = now.UnixMilli() + if err := os.WriteFile(path, Encode(want), 0644); err != nil { + t.Fatal(err) + } + if _, state := source.Latest(); state != SnapshotFresh { + t.Fatalf("state=%v want fresh at publish time", state) + } + + // Heartbeat rewrite of identical bytes at a later wall-clock time. + if err := os.WriteFile(path, Encode(want), 0644); err != nil { + t.Fatal(err) + } + source.now = func() time.Time { return now.Add(2 * Freshness) } + got, state := source.Latest() + if state != SnapshotStale { + t.Fatalf("state=%v want stale despite the heartbeat rewrite", state) + } + if got != want { + t.Fatalf("last-known snapshot=%+v want %+v", got, want) + } +} + +func TestFileSourceReportsFreshStaleAndMissingStates(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 _, state := source.Latest(); state != SnapshotNone { + t.Fatalf("a missing state file must report state %v, got %v", SnapshotNone, state) + } + if err := os.WriteFile(path, []byte("garbage that is not a snapshot"), 0644); err != nil { + t.Fatal(err) + } + if _, state := source.Latest(); state != SnapshotNone { + t.Fatalf("a corrupt state file must report state %v, got %v", SnapshotNone, state) + } + + want := testSnapshot() + want.PublishedMS = fixed.UnixMilli() + if err := os.WriteFile(path, Encode(want), 0644); err != nil { + t.Fatal(err) + } + got, state := source.Latest() + if state != SnapshotFresh { + t.Fatalf("a fresh snapshot must report state %v, got %v", SnapshotFresh, state) + } + if got != want { + t.Fatalf("got=%+v want %+v", got, want) + } + + // The last successfully decoded snapshot must survive past the + // freshness window, marked stale instead of being hidden: the display + // shows last-known values as explicitly stale. + stale := want + stale.PublishedMS = fixed.Add(-Freshness - time.Second).UnixMilli() + if err := os.WriteFile(path, Encode(stale), 0644); err != nil { + t.Fatal(err) + } + got, state = source.Latest() + if state != SnapshotStale { + t.Fatalf("a snapshot beyond the freshness window must report state %v, got %v", SnapshotStale, state) + } + if got != stale { + t.Fatalf("last-known snapshot=%+v want %+v", got, stale) + } +}