6 Commits
20 changed files with 2766 additions and 69 deletions
+28 -2
View File
@@ -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
<path>` 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;
+21 -7
View File
@@ -12,18 +12,26 @@ import (
"os"
"os/signal"
"syscall"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
// osdRefresh is the OSD overlay's redraw cadence, deliberately slower than
// the video: newly published flight snapshots appear without waiting for X
// events while the video window keeps rendering priority.
const osdRefresh = time.Second
// buildTarget selects the target-specific layers. Each target owns its own
// backend and layouts; there is no shared global template. The Viture target
// builds two independent layers — the fullscreen fallback/menu canvas below
// video and the transparent OSD overlay above it — wired to one shared video
// presence signal: the sessions observe the video output lifecycle on the
// display server and the templates draw or hide from it. WFB and video
// decode never move into this process.
func buildTarget(name string) ([]display.Layer, error) {
// display server and the templates draw or hide from it. The OSD layer also
// consumes the supervisor-published flight snapshots and refreshes on its
// own slower cadence. WFB and video decode never move into this process.
func buildTarget(name string, osdState string) ([]display.Layer, error) {
switch name {
case "x11":
video := display.NewVideoPresence()
@@ -34,9 +42,13 @@ func buildTarget(name string) ([]display.Layer, error) {
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
},
{
Name: "osd-overlay",
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
Template: display.NewOSDTemplate(video),
Name: "osd-overlay",
Opener: display.X11Opener{
Video: video,
Role: display.RoleAbove,
RefreshInterval: osdRefresh,
},
Template: display.NewOSDTemplate(video, osd.NewFileSource(osdState)),
},
}, nil
default:
@@ -68,9 +80,11 @@ func formatDiagnostic(d display.Diagnostic) string {
func main() {
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
osdState := flag.String("osd-state", osd.DefaultStatePath,
"OSD snapshot state file published by the groundstation supervisor")
flag.Parse()
layers, err := buildTarget(*target)
layers, err := buildTarget(*target, *osdState)
if err != nil {
log.Fatal(err)
}
+75 -13
View File
@@ -2,10 +2,12 @@ package main
import (
"errors"
"path/filepath"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/display"
"git.piksar.eu/piotr/groundstation/internal/osd"
)
type recordingCanvas struct {
@@ -22,16 +24,16 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
func (c *recordingCanvas) Flush() { c.flushes++ }
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
layers, err := buildTarget("x11")
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
if err != nil {
t.Fatal(err)
}
if len(layers) != 2 {
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
}
canvas, osd := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
canvas, osdLayer := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osdLayer.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osdLayer.Name)
}
canvasOpener, ok := canvas.Opener.(display.X11Opener)
@@ -41,13 +43,21 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
if canvasOpener.Role != display.RoleBelow {
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
}
osdOpener, ok := osd.Opener.(display.X11Opener)
osdOpener, ok := osdLayer.Opener.(display.X11Opener)
if !ok {
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
t.Fatalf("OSD opener %T must be the X11 target", osdLayer.Opener)
}
if osdOpener.Role != display.RoleAbove {
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
}
// The OSD refreshes on its own slower cadence; the canvas stays purely
// event-driven.
if osdOpener.RefreshInterval <= 0 {
t.Fatal("the OSD overlay must redraw on its own refresh interval")
}
if canvasOpener.RefreshInterval != 0 {
t.Fatal("the fallback canvas must keep its event-driven redraws")
}
// Both layers must share one presence signal so the video lifecycle
// observed by either session drives both templates.
@@ -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)
}
+25 -4
View File
@@ -15,6 +15,7 @@ import (
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"git.piksar.eu/piotr/groundstation/internal/serialport"
)
@@ -68,8 +69,21 @@ func runMain() int {
defer func() { _ = video.Stop() }()
}
// The flight OSD snapshot publisher survives UART reconnects: the
// display keeps seeing the last state, refreshed as a heartbeat, and
// the state file is removed on shutdown so nothing stale renders.
var publisher *osd.Publisher
if cfg.OSD.StatePath != "" {
if err := os.MkdirAll(filepath.Dir(cfg.OSD.StatePath), 0755); err != nil {
logger.Printf("osd state dir: %v", err)
return 1
}
publisher = osd.NewFilePublisher(cfg.OSD.StatePath, logger)
defer publisher.Close()
}
if *stdin {
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
if err := run(ctx, os.Stdin, logger, video, cfg, publisher); err != nil && !errors.Is(err, context.Canceled) {
logger.Printf("stdin run: %v", err)
return 1
}
@@ -85,7 +99,7 @@ func runMain() int {
}
logger.Printf("UART connected: %s", cfg.Serial.Path)
err = run(ctx, port, logger, video, cfg)
err = run(ctx, port, logger, video, cfg, publisher)
_ = port.Close()
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
logger.Printf("UART disconnected: %v", err)
@@ -121,7 +135,7 @@ func wait(ctx context.Context, duration time.Duration) {
}
}
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config) error {
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config, publisher *osd.Publisher) error {
buf := make([]byte, protocol.RCSize)
var previous *protocol.RCFrame
for {
@@ -134,11 +148,18 @@ func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoC
continue
}
active := frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold
if video != nil {
if err := video.Set(frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold); err != nil {
if err := video.Set(active); err != nil {
return err
}
}
// Publish the OSD snapshot after the video decision so the
// snapshot carries the launcher presence that resulted from it.
// Publish never blocks: the UART loop only stores and wakes.
if publisher != nil {
publisher.Publish(newSnapshot(frame, cfg, video))
}
if previous == nil || previous.Channels != frame.Channels {
logger.Printf("RC seq=%d t=%d channels=%v", frame.Sequence, frame.Timestamp, frame.Channels)
+38
View File
@@ -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
}
+207
View File
@@ -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")
}
}
+16
View File
@@ -51,6 +51,22 @@ func (v *videoController) Set(active bool) error {
return nil
}
// Running reports whether the video launcher is currently alive. It is the
// supervisor's decoded-video presence for the OSD snapshot: the launcher is
// the only video decoder this process knows about. A launcher that exited
// on its own stays recorded until the next Set; its pending wait result
// proves it is no longer alive without consuming the signal the stop path
// relies on. Reading this never signals or waits on the launcher, so it
// never disturbs the video path.
func (v *videoController) Running() bool {
v.mu.Lock()
defer v.mu.Unlock()
if v.cmd == nil {
return false
}
return len(v.done) == 0
}
func (v *videoController) Stop() error {
return v.Set(false)
}
+16
View File
@@ -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
+46 -2
View File
@@ -1,8 +1,11 @@
package config
import (
"fmt"
"os"
"git.piksar.eu/piotr/groundstation/internal/osd"
"git.piksar.eu/piotr/groundstation/internal/protocol"
"gopkg.in/yaml.v3"
)
@@ -15,6 +18,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
}
+66
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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
+2 -2
View File
@@ -254,7 +254,7 @@ func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
{
Name: "osd-overlay",
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
@@ -296,7 +296,7 @@ func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
{
Name: "alive",
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
},
}
+107 -13
View File
@@ -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)
}
+197 -13
View File
@@ -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)
}
}
}
}
+377 -12
View File
@@ -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
}
+404
View File
@@ -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")
}
}
+275
View File
@@ -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)
}
}
+523
View File
@@ -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
}
+183
View File
@@ -0,0 +1,183 @@
package osd
import (
"bytes"
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestPublisherKeepsOnlyTheLatestSnapshot(t *testing.T) {
publisher := NewPublisher()
if _, ok := publisher.Latest(); ok {
t.Fatal("a publisher without snapshots must report none")
}
first := testSnapshot()
publisher.Publish(first)
got, ok := publisher.Latest()
if !ok {
t.Fatal("a published snapshot must be readable")
}
if got.Sequence != first.Sequence || got.PublishedMS == 0 {
t.Fatalf("latest=%+v must carry the published state with a publish timestamp", got)
}
second := first
second.Sequence = 7
second.Video = false
publisher.Publish(second)
got, _ = publisher.Latest()
if got.Sequence != 7 || got.Video {
t.Fatalf("latest=%+v must be the most recent snapshot", got)
}
}
func TestPublishNeverBlocksOnASlowWriter(t *testing.T) {
release := make(chan struct{})
var writes atomic.Int32
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"), func(string, []byte) error {
writes.Add(1)
<-release
return errors.New("unreachable")
}, Heartbeat, time.Millisecond, nil)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
snapshot := testSnapshot()
snapshot.Sequence = uint16(i)
publisher.Publish(snapshot)
}
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("publish must never wait for the state file writer")
}
close(release)
}
func TestFilePublisherPublishesSnapshotsToTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "run", "osd-state.bin")
publisher := NewFilePublisher(path, nil)
defer publisher.Close()
want := testSnapshot()
publisher.Publish(want)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if got, err := DecodeFile(path); err == nil && got == want {
if info, statErr := os.Stat(path); statErr != nil || info.Mode().Perm() != 0644 {
t.Fatalf("state file mode=%v want 0644 for the unprivileged display user", info.Mode())
}
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the published snapshot never reached the state file")
}
func TestFilePublisherRewritesUnchangedStateAsHeartbeat(t *testing.T) {
var writes sync.Mutex
count := 0
publisher := newFilePublisher(filepath.Join(t.TempDir(), "osd-state.bin"),
func(path string, data []byte) error {
if err := os.WriteFile(path, data, 0644); err != nil {
return err
}
writes.Lock()
count++
writes.Unlock()
return nil
}, 10*time.Millisecond, 5*time.Millisecond, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
writes.Lock()
got := count
writes.Unlock()
if got >= 3 {
publisher.Close()
return
}
time.Sleep(time.Millisecond)
}
publisher.Close()
t.Fatal("an unchanged snapshot must keep being republished so it stays fresh")
}
func TestFilePublisherCloseRemovesTheStateFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := NewFilePublisher(path, nil)
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
break
}
time.Sleep(time.Millisecond)
}
publisher.Close()
publisher.Close() // must be idempotent
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("state file stat=%v want removed after close", err)
}
}
func TestFilePublisherKeepsRetryingAfterAWriteFailure(t *testing.T) {
var attempts atomic.Int32
path := filepath.Join(t.TempDir(), "osd-state.bin")
publisher := newFilePublisher(path, func(path string, data []byte) error {
if attempts.Add(1) == 1 {
return errors.New("transient write failure")
}
return os.WriteFile(path, data, 0644)
}, time.Second, 5*time.Millisecond, log.New(os.Stderr, "", 0))
defer publisher.Close()
publisher.Publish(testSnapshot())
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := DecodeFile(path); err == nil {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("a failed state write must be retried, not dropped")
}
func TestFilePublisherThrottlesPersistentFailureLogs(t *testing.T) {
var logs sync.Mutex
var buffer bytes.Buffer
publisher := newFilePublisher(filepath.Join(t.TempDir(), "missing-dir", "osd-state.bin"),
func(string, []byte) error {
return errors.New("state path is broken")
}, 20*time.Millisecond, 5*time.Millisecond, log.New(&buffer, "", 0))
publisher.Publish(testSnapshot())
time.Sleep(150 * time.Millisecond)
publisher.Close()
logs.Lock()
defer logs.Unlock()
lines := strings.Count(buffer.String(), "\n")
// The write retries on every tick but must log at most once per
// heartbeat: about 150ms/20ms plus the first line, never per attempt.
if lines == 0 || lines > 8 {
t.Fatalf("failure log lines=%d want throttled reporting (1..8)", lines)
}
}
+156
View File
@@ -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)
}
}