Ground Station OSD data model and always-visible overlay renderer #15
@@ -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;
|
||||
|
||||
@@ -12,18 +12,26 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/display"
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
)
|
||||
|
||||
// osdRefresh is the OSD overlay's redraw cadence, deliberately slower than
|
||||
// the video: newly published flight snapshots appear without waiting for X
|
||||
// events while the video window keeps rendering priority.
|
||||
const osdRefresh = time.Second
|
||||
|
||||
// buildTarget selects the target-specific layers. Each target owns its own
|
||||
// backend and layouts; there is no shared global template. The Viture target
|
||||
// builds two independent layers — the fullscreen fallback/menu canvas below
|
||||
// video and the transparent OSD overlay above it — wired to one shared video
|
||||
// presence signal: the sessions observe the video output lifecycle on the
|
||||
// display server and the templates draw or hide from it. WFB and video
|
||||
// decode never move into this process.
|
||||
func buildTarget(name string) ([]display.Layer, error) {
|
||||
// display server and the templates draw or hide from it. The OSD layer also
|
||||
// consumes the supervisor-published flight snapshots and refreshes on its
|
||||
// own slower cadence. WFB and video decode never move into this process.
|
||||
func buildTarget(name string, osdState string) ([]display.Layer, error) {
|
||||
switch name {
|
||||
case "x11":
|
||||
video := display.NewVideoPresence()
|
||||
@@ -34,9 +42,13 @@ func buildTarget(name string) ([]display.Layer, error) {
|
||||
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
|
||||
},
|
||||
{
|
||||
Name: "osd-overlay",
|
||||
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
|
||||
Template: display.NewOSDTemplate(video),
|
||||
Name: "osd-overlay",
|
||||
Opener: display.X11Opener{
|
||||
Video: video,
|
||||
Role: display.RoleAbove,
|
||||
RefreshInterval: osdRefresh,
|
||||
},
|
||||
Template: display.NewOSDTemplate(video, osd.NewFileSource(osdState)),
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
@@ -68,9 +80,11 @@ func formatDiagnostic(d display.Diagnostic) string {
|
||||
|
||||
func main() {
|
||||
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
|
||||
osdState := flag.String("osd-state", osd.DefaultStatePath,
|
||||
"OSD snapshot state file published by the groundstation supervisor")
|
||||
flag.Parse()
|
||||
|
||||
layers, err := buildTarget(*target)
|
||||
layers, err := buildTarget(*target, *osdState)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/display"
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
)
|
||||
|
||||
type recordingCanvas struct {
|
||||
@@ -22,16 +24,16 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
|
||||
func (c *recordingCanvas) Flush() { c.flushes++ }
|
||||
|
||||
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
|
||||
layers, err := buildTarget("x11")
|
||||
layers, err := buildTarget("x11", filepath.Join(t.TempDir(), "osd-state.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(layers) != 2 {
|
||||
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
|
||||
}
|
||||
canvas, osd := layers[0], layers[1]
|
||||
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
|
||||
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
|
||||
canvas, osdLayer := layers[0], layers[1]
|
||||
if canvas.Name != "fallback-canvas" || osdLayer.Name != "osd-overlay" {
|
||||
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osdLayer.Name)
|
||||
}
|
||||
|
||||
canvasOpener, ok := canvas.Opener.(display.X11Opener)
|
||||
@@ -41,13 +43,21 @@ func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
|
||||
if canvasOpener.Role != display.RoleBelow {
|
||||
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
|
||||
}
|
||||
osdOpener, ok := osd.Opener.(display.X11Opener)
|
||||
osdOpener, ok := osdLayer.Opener.(display.X11Opener)
|
||||
if !ok {
|
||||
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
|
||||
t.Fatalf("OSD opener %T must be the X11 target", osdLayer.Opener)
|
||||
}
|
||||
if osdOpener.Role != display.RoleAbove {
|
||||
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
|
||||
}
|
||||
// The OSD refreshes on its own slower cadence; the canvas stays purely
|
||||
// event-driven.
|
||||
if osdOpener.RefreshInterval <= 0 {
|
||||
t.Fatal("the OSD overlay must redraw on its own refresh interval")
|
||||
}
|
||||
if canvasOpener.RefreshInterval != 0 {
|
||||
t.Fatal("the fallback canvas must keep its event-driven redraws")
|
||||
}
|
||||
|
||||
// Both layers must share one presence signal so the video lifecycle
|
||||
// observed by either session drives both templates.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/config"
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
"git.piksar.eu/piotr/groundstation/internal/protocol"
|
||||
"git.piksar.eu/piotr/groundstation/internal/serialport"
|
||||
)
|
||||
@@ -68,8 +69,21 @@ func runMain() int {
|
||||
defer func() { _ = video.Stop() }()
|
||||
}
|
||||
|
||||
// The flight OSD snapshot publisher survives UART reconnects: the
|
||||
// display keeps seeing the last state, refreshed as a heartbeat, and
|
||||
// the state file is removed on shutdown so nothing stale renders.
|
||||
var publisher *osd.Publisher
|
||||
if cfg.OSD.StatePath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.OSD.StatePath), 0755); err != nil {
|
||||
logger.Printf("osd state dir: %v", err)
|
||||
return 1
|
||||
}
|
||||
publisher = osd.NewFilePublisher(cfg.OSD.StatePath, logger)
|
||||
defer publisher.Close()
|
||||
}
|
||||
|
||||
if *stdin {
|
||||
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err := run(ctx, os.Stdin, logger, video, cfg, publisher); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Printf("stdin run: %v", err)
|
||||
return 1
|
||||
}
|
||||
@@ -85,7 +99,7 @@ func runMain() int {
|
||||
}
|
||||
|
||||
logger.Printf("UART connected: %s", cfg.Serial.Path)
|
||||
err = run(ctx, port, logger, video, cfg)
|
||||
err = run(ctx, port, logger, video, cfg, publisher)
|
||||
_ = port.Close()
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, io.EOF) {
|
||||
logger.Printf("UART disconnected: %v", err)
|
||||
@@ -121,7 +135,7 @@ func wait(ctx context.Context, duration time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config) error {
|
||||
func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoController, cfg config.Config, publisher *osd.Publisher) error {
|
||||
buf := make([]byte, protocol.RCSize)
|
||||
var previous *protocol.RCFrame
|
||||
for {
|
||||
@@ -134,11 +148,18 @@ func run(ctx context.Context, input io.Reader, logger *log.Logger, video *videoC
|
||||
continue
|
||||
}
|
||||
|
||||
active := frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold
|
||||
if video != nil {
|
||||
if err := video.Set(frame.Channels[cfg.Video.AuxIndex] > cfg.Video.Threshold); err != nil {
|
||||
if err := video.Set(active); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Publish the OSD snapshot after the video decision so the
|
||||
// snapshot carries the launcher presence that resulted from it.
|
||||
// Publish never blocks: the UART loop only stores and wakes.
|
||||
if publisher != nil {
|
||||
publisher.Publish(newSnapshot(frame, cfg, video))
|
||||
}
|
||||
|
||||
if previous == nil || previous.Channels != frame.Channels {
|
||||
logger.Printf("RC seq=%d t=%d channels=%v", frame.Sequence, frame.Timestamp, frame.Channels)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user