Add fullscreen Ground Station display foundation #7

Merged
piotr merged 2 commits from issue-1-fullscreen-display into main 2026-10-01 14:08:50 +02:00
15 changed files with 1308 additions and 78 deletions
+14 -3
View File
@@ -7,20 +7,31 @@ Orange Pi Zero 3W application for the custom OpenIPC/WFB ground station.
- receives framed GX12 RC channel data over the dedicated AUX1 UART at 115200 8N1;
- validates protocol version, frame length and CRC-16;
- logs final mixed channel values;
- can start the existing `fpv-video` launcher for the Viture primary display.
- starts an independent kiosk display process that maps a fullscreen black canvas on `DISPLAY=:0` before video, tolerates X11 downtime without restarting the supervisor, and stops deterministically on shutdown;
- video keeps rendering priority: the kiosk canvas stays below the `fpv-video` window, and future display targets (MIPI/touch) get their own target-specific templates instead of one global layout.
The first baseline is intentionally small. It does **not** yet control an AUX, manipulate the flight-control link, or replace the primary video pipeline.
## Run
```bash
# Hardware UART mode (device path will be confirmed during Orange Pi bring-up)
groundstation -serial /dev/ttyS1 -start-video
# Supervisor (YAML configuration, default /etc/groundstation/config.yml;
# hardware UART paths live in the config file)
groundstation -config /etc/groundstation/config.yml
# Test input mode
groundstation -stdin
# Kiosk canvas alone (the supervisor starts it; standalone for debugging)
groundstation-display -target x11
```
## Runtime dependencies
- an X11 session on `:0` owned by the `orangepi` desktop user (the kiosk waits for X11 to appear and reconnects after display-manager restarts);
- `wmctrl` for the fullscreen/below kiosk window state — without it the canvas still maps but stays a plain window (logged);
- `runuser` from util-linux (base install) for the unprivileged kiosk launch.
## Protocol
The v1 protocol is described in `AGENTS.md` once the repository policy file is approved. It carries 16 final EdgeTX channel outputs as a binary framed message with CRC.
+28 -52
View File
@@ -1,71 +1,47 @@
// Command groundstation-display renders the Ground Station kiosk canvas. It
// owns its display lifecycle independently of WFB, the transmitter and the
// video launcher: it waits for its target to appear, redraws as needed, and
// stops only when it receives SIGINT or SIGTERM.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/xproto"
"git.piksar.eu/piotr/groundstation/internal/display"
)
const (
width = 1920
height = 1080
)
// buildTarget selects the target-specific opener and template. Each target
// owns its own backend and layouts; there is no shared global template.
func buildTarget(name string) (display.Opener, display.Template, error) {
switch name {
case "x11":
return display.X11Opener{}, display.NewStatusTemplate("Brak polaczenia WFB"), nil
default:
return nil, nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
}
}
func main() {
conn, err := xgb.NewConn()
if err != nil {
log.Fatal(err)
}
defer conn.Close()
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
flag.Parse()
screen := xproto.Setup(conn).DefaultScreen(conn)
window, err := xproto.NewWindowId(conn)
opener, template, err := buildTarget(*target)
if err != nil {
log.Fatal(err)
}
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root, 0, 0, width, height, 0,
xproto.WindowClassInputOutput, screen.RootVisual, values,
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
xproto.MapWindow(conn, window)
conn.Sync()
// XFCE owns the actual fullscreen policy; use its existing wmctrl helper.
_ = exec.Command("wmctrl", "-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen").Run()
gc, err := xproto.NewGcontextId(conn)
if err != nil {
log.Fatal(err)
}
xproto.CreateGC(conn, gc, xproto.Drawable(window), xproto.GcForeground|xproto.GcBackground,
[]uint32{screen.WhitePixel, screen.BlackPixel})
font := xproto.Font(window)
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
xproto.ChangeGC(conn, gc, xproto.GcFont, []uint32{uint32(font)})
draw := func() {
xproto.ClearArea(conn, false, window, 0, 0, width, height)
message := "Brak polaczenia WFB"
xproto.ImageText8(conn, byte(len(message)), xproto.Drawable(window), gc, 32, 56, message)
conn.Sync()
}
draw()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
for {
event, err := conn.WaitForEvent()
if err != nil {
if os.IsTimeout(err) {
continue
}
return
}
if _, ok := event.(xproto.ExposeEvent); ok {
draw()
}
// Temporary X11 unavailability never exits this process; display.Run
// keeps retrying internally until the context is cancelled.
if err := display.Run(ctx, opener, template, display.Options{}); err != nil {
log.Fatalf("display: %v", err)
}
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"context"
"log"
"os"
"os/exec"
"syscall"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
)
// displaySupervisor keeps the independent kiosk display process running.
// X11 downtime never escalates here: the kiosk retries its target
// internally, and this loop only restarts the process if the kiosk itself
// dies. The Ground Station supervisor therefore stays up while X11 restarts.
type displaySupervisor struct {
launch func() (*exec.Cmd, error)
logger *log.Logger
// retryDelay is the first wait after a kiosk exit; it grows to maxDelay
// and resets after the kiosk stayed up for stableAfter.
retryDelay time.Duration
maxDelay time.Duration
stableAfter time.Duration
// stopGrace bounds the interrupt-to-kill window on shutdown.
stopGrace time.Duration
}
func newDisplaySupervisor(cfg config.Config, logger *log.Logger) *displaySupervisor {
return &displaySupervisor{
launch: func() (*exec.Cmd, error) { return startDisplayProcess(cfg.Display) },
logger: logger,
retryDelay: time.Second,
maxDelay: 30 * time.Second,
stableAfter: time.Minute,
stopGrace: 5 * time.Second,
}
}
func (s *displaySupervisor) run(ctx context.Context) {
delay := s.retryDelay
for ctx.Err() == nil {
cmd, err := s.launch()
if err == nil {
err = cmd.Start()
}
if err != nil {
s.logger.Printf("display start: %v; retrying", err)
if !sleepFor(ctx, delay) {
return
}
delay = nextBackoff(delay, s.maxDelay)
continue
}
started := time.Now()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if ctx.Err() != nil {
return
}
s.logger.Printf("display exited: %v; restarting", err)
case <-ctx.Done():
stopDisplayProcess(cmd, done, s.stopGrace)
return
}
if time.Since(started) >= s.stableAfter {
delay = s.retryDelay
}
if !sleepFor(ctx, delay) {
return
}
delay = nextBackoff(delay, s.maxDelay)
}
}
// stopDisplayProcess shuts the kiosk down deterministically: the interrupt
// goes to the whole kiosk process group (runuser plus the kiosk itself,
// which runuser does not forward signals to), waits for the grace window,
// then kills the group and reaps the direct child. The direct child exiting
// does not prove the group is empty — a kiosk that ignored the interrupt
// would outlive an exiting runuser — so the group receives a final SIGKILL
// sweep; for an already-empty group that is a harmless no-op.
func stopDisplayProcess(cmd *exec.Cmd, done <-chan error, grace time.Duration) {
signalGroup := func(sig syscall.Signal) {
_ = syscall.Kill(-cmd.Process.Pid, sig)
}
signalGroup(syscall.SIGINT)
select {
case <-done:
case <-time.After(grace):
signalGroup(syscall.SIGKILL)
<-done
}
signalGroup(syscall.SIGKILL)
}
// startDisplayProcess builds the kiosk launch command. It normally runs as
// the unprivileged desktop user against the session X server, in its own
// process group so shutdown can signal the whole kiosk tree.
func startDisplayProcess(cfg config.DisplayConfig) (*exec.Cmd, error) {
env := []string{
"DISPLAY=" + cfg.Display,
"XAUTHORITY=" + cfg.Xauthority,
}
var cmd *exec.Cmd
if cfg.User == "" {
cmd = exec.Command(cfg.Command)
// Configured values must precede the inherited environment: the
// child resolves the first occurrence of each variable.
cmd.Env = append(env, os.Environ()...)
} else {
args := []string{"-u", cfg.User, "--", "env"}
args = append(args, env...)
args = append(args, cfg.Command)
cmd = exec.Command("runuser", args...)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
return cmd, nil
}
func nextBackoff(delay, max time.Duration) time.Duration {
delay *= 2
if delay > max {
return max
}
return delay
}
func sleepFor(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
+341
View File
@@ -0,0 +1,341 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"runtime"
"strconv"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"git.piksar.eu/piotr/groundstation/internal/config"
)
func TestStartDisplayProcessWrapsCommandForDesktopUser(t *testing.T) {
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/usr/local/bin/groundstation-display",
User: "orangepi",
Display: ":0",
Xauthority: "/home/orangepi/.Xauthority",
})
if err != nil {
t.Fatal(err)
}
got := strings.Join(cmd.Args, " ")
want := "runuser -u orangepi -- env DISPLAY=:0 XAUTHORITY=/home/orangepi/.Xauthority /usr/local/bin/groundstation-display"
if got != want {
t.Fatalf("args=%q want %q", got, want)
}
}
func TestStartDisplayProcessRunsDirectlyWithoutUser(t *testing.T) {
t.Setenv("DISPLAY", ":9")
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/bin/kiosk",
Display: ":1",
})
if err != nil {
t.Fatal(err)
}
if strings.Join(cmd.Args, " ") != "/bin/kiosk" {
t.Fatalf("args=%v want [/bin/kiosk]", cmd.Args)
}
// The configured environment must precede the inherited environment so
// the child resolves the configured display, not a pre-existing one.
if len(cmd.Env) < 2 || cmd.Env[0] != "DISPLAY=:1" {
t.Fatalf("env %v must start with DISPLAY=:1 override", cmd.Env)
}
}
func TestStartDisplayProcessRunsInOwnProcessGroup(t *testing.T) {
for _, user := range []string{"", "orangepi"} {
cmd, err := startDisplayProcess(config.DisplayConfig{
Command: "/usr/local/bin/groundstation-display",
User: user,
Display: ":0",
})
if err != nil {
t.Fatal(err)
}
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
t.Fatalf("user %q: kiosk must start in its own process group", user)
}
}
}
func testSupervisor(launch func() (*exec.Cmd, error)) *displaySupervisor {
return &displaySupervisor{
launch: launch,
logger: log.New(io.Discard, "", 0),
retryDelay: time.Millisecond,
maxDelay: 5 * time.Millisecond,
stableAfter: time.Hour,
stopGrace: time.Second,
}
}
func TestDisplaySupervisorRestartsKioskAfterUnexpectedExit(t *testing.T) {
var starts int32
supervisor := testSupervisor(func() (*exec.Cmd, error) {
atomic.AddInt32(&starts, 1)
return exec.Command("true"), nil
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
waitForCondition(t, func() bool { return atomic.LoadInt32(&starts) >= 3 }, "kiosk restarts")
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
}
func TestDisplaySupervisorKeepsRunningWhenLaunchFails(t *testing.T) {
var attempts int32
supervisor := testSupervisor(func() (*exec.Cmd, error) {
atomic.AddInt32(&attempts, 1)
return nil, errors.New("binary missing")
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
waitForCondition(t, func() bool { return atomic.LoadInt32(&attempts) >= 3 }, "launch retries")
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
}
func waitForCondition(t *testing.T, condition func() bool, description string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timed out waiting for %s", description)
}
func TestDisplaySupervisorStopsRunningKioskOnShutdown(t *testing.T) {
var started atomic.Pointer[exec.Cmd]
pipes := make(chan io.ReadCloser, 1)
supervisor := testSupervisor(func() (*exec.Cmd, error) {
cmd := exec.Command("sh", "-c", "echo started; exec sleep 60")
// Mirrors startDisplayProcess: the kiosk runs in its own group.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
started.Store(cmd)
pipes <- pipe
return cmd, nil
})
supervisor.stopGrace = 200 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
supervisor.run(ctx)
close(done)
}()
// The child signals readiness over its own pipe, which proves Start ran
// without racing on cmd fields written by Start.
pipe := <-pipes
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("supervisor did not stop after cancel")
}
// After the supervisor goroutine has returned, its cmd usage is safely
// ordered; the process must be gone.
cmd := started.Load()
if err := cmd.Process.Signal(syscall.Signal(0)); err == nil {
t.Fatal("kiosk process must be gone after shutdown")
}
}
// liveGroupMembers returns the non-zombie members of a process group. It
// relies on procfs and callers must skip on non-Linux platforms.
func liveGroupMembers(t *testing.T, pgid int) []string {
t.Helper()
entries, err := os.ReadDir("/proc")
if err != nil {
t.Fatal(err)
}
var live []string
for _, entry := range entries {
name := entry.Name()
pid, err := strconv.Atoi(name)
if err != nil {
continue
}
data, err := os.ReadFile("/proc/" + name + "/stat")
if err != nil {
continue
}
closing := bytes.LastIndexByte(data, ')')
fields := strings.Fields(string(data[closing+1:]))
if len(fields) < 3 {
continue
}
if fields[0] != "Z" && fields[2] == strconv.Itoa(pgid) {
live = append(live, fmt.Sprintf("%d(%s)", pid, fields[0]))
}
}
return live
}
func waitForEmptyGroup(t *testing.T, pgid int) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for {
members := liveGroupMembers(t, pgid)
if len(members) == 0 {
return
}
if time.Now().After(deadline) {
t.Fatalf("process group %d still has live members: %v", pgid, members)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestStopDisplayProcessTerminatesWholeProcessGroup(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
cmd := exec.Command("sh", "-c", "echo started; sleep 60 & wait")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
stopDisplayProcess(cmd, done, time.Second)
// The interrupt must reach the whole group, including the sleep
// descendant that runuser-style children would otherwise hide behind.
waitForEmptyGroup(t, cmd.Process.Pid)
}
func TestStopDisplayProcessEscalatesToKillWhenInterruptIgnored(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
// Ignored dispositions are inherited by the whole process tree, so the
// group ignores SIGINT from birth without a trap-setup race.
signal.Ignore(syscall.SIGINT)
defer signal.Reset(syscall.SIGINT)
// The group leader ignores SIGINT and never exits, forcing the
// SIGKILL escalation.
cmd := exec.Command("sh", "-c", "echo started; while :; do :; done")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
grace := 200 * time.Millisecond
began := time.Now()
stopDisplayProcess(cmd, done, grace)
if elapsed := time.Since(began); elapsed < grace {
t.Fatalf("stop returned after %v, grace was %v", elapsed, grace)
}
waitForEmptyGroup(t, cmd.Process.Pid)
}
func TestStopDisplayProcessKillsGroupSurvivingDirectChildExit(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("procfs-based group check requires Linux")
}
// The tree ignores SIGINT via inheritance, so a group member can
// outlive the direct child exactly like a kiosk behind an exiting
// runuser; the stop must sweep it instead of trusting the child exit.
signal.Ignore(syscall.SIGINT)
defer signal.Reset(syscall.SIGINT)
cmd := exec.Command("sh", "-c", "echo started; sleep 60 & exit 0")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
pipe, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
greeting := make([]byte, len("started\n"))
if _, err := io.ReadFull(pipe, greeting); err != nil {
t.Fatalf("read readiness signal: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
stopDisplayProcess(cmd, done, 200*time.Millisecond)
waitForEmptyGroup(t, cmd.Process.Pid)
}
+28 -21
View File
@@ -8,9 +8,9 @@ import (
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
@@ -20,30 +20,45 @@ import (
)
func main() {
os.Exit(runMain())
}
func runMain() int {
configPath := flag.String("config", "/etc/groundstation/config.yml", "Ground Station YAML configuration")
stdin := flag.Bool("stdin", false, "read framed data from standard input")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("load config: %v", err)
log.Printf("load config: %v", err)
return 1
}
logger, closeLog, err := setupLogger(cfg)
if err != nil {
log.Fatal(err)
log.Print(err)
return 1
}
defer closeLog()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if display, err := startDisplay(); err != nil {
log.Printf("display start deferred: %v", err)
} else {
defer func() {
_ = display.Process.Signal(os.Interrupt)
_ = display.Wait()
// Shutdown order is deterministic: video stops first, then the kiosk
// display. The defers below run in reverse registration order.
var displayWG sync.WaitGroup
defer displayWG.Wait()
displayCtx, stopDisplay := context.WithCancel(ctx)
defer stopDisplay()
// The kiosk canvas starts before the video launcher so it is already
// mapped on DISPLAY=:0 when video appears.
if cfg.Display.Enabled {
displayWG.Add(1)
go func() {
defer displayWG.Done()
newDisplaySupervisor(cfg, logger).run(displayCtx)
}()
}
@@ -55,9 +70,10 @@ func main() {
if *stdin {
if err := run(ctx, os.Stdin, logger, video, cfg); err != nil && !errors.Is(err, context.Canceled) {
log.Fatal(err)
logger.Printf("stdin run: %v", err)
return 1
}
return
return 0
}
for ctx.Err() == nil {
@@ -78,16 +94,7 @@ func main() {
}
wait(ctx, time.Second)
}
}
func startDisplay() (*exec.Cmd, error) {
cmd := exec.Command("runuser", "-u", "orangepi", "--", "env",
"DISPLAY=:0", "XAUTHORITY=/home/orangepi/.Xauthority", "/usr/local/bin/groundstation-display")
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil {
return nil, err
}
return cmd, nil
return 0
}
func setupLogger(cfg config.Config) (*log.Logger, func(), error) {
+9
View File
@@ -1,3 +1,12 @@
display:
enabled: true
# Independent kiosk canvas process. It waits for X11 itself, keeps the
# fullscreen black canvas below video, and stops only on shutdown.
command: /usr/local/bin/groundstation-display
user: orangepi
display: :0
xauthority: /home/orangepi/.Xauthority
serial:
path: /dev/ttyS0
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Ground Station RC and video supervisor
Description=Ground Station RC, kiosk display and video supervisor
Wants=graphical.target
After=graphical.target display-manager.service
+24 -1
View File
@@ -6,8 +6,18 @@ import (
"gopkg.in/yaml.v3"
)
// DisplayConfig configures the independent kiosk canvas process.
type DisplayConfig struct {
Enabled bool `yaml:"enabled"`
Command string `yaml:"command"`
User string `yaml:"user"`
Display string `yaml:"display"`
Xauthority string `yaml:"xauthority"`
}
type Config struct {
Serial struct {
Display DisplayConfig `yaml:"display"`
Serial struct {
Path string `yaml:"path"`
} `yaml:"serial"`
Video struct {
@@ -24,6 +34,7 @@ type Config struct {
func Default() Config {
var c Config
c.Display = DefaultDisplay()
c.Serial.Path = "/dev/ttyS0"
c.Video.Enabled = true
c.Video.AuxIndex = 5 // AUX2, zero-based
@@ -33,6 +44,18 @@ func Default() Config {
return c
}
// DefaultDisplay starts the kiosk canvas before video on the desktop X
// server owned by the unprivileged display user.
func DefaultDisplay() DisplayConfig {
return DisplayConfig{
Enabled: true,
Command: "/usr/local/bin/groundstation-display",
User: "orangepi",
Display: ":0",
Xauthority: "/home/orangepi/.Xauthority",
}
}
func Load(path string) (Config, error) {
config := Default()
data, err := os.ReadFile(path)
+50
View File
@@ -0,0 +1,50 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigStartsKioskDisplay(t *testing.T) {
cfg := Default()
if !cfg.Display.Enabled {
t.Fatal("kiosk display must be enabled by default")
}
if cfg.Display.Command != "/usr/local/bin/groundstation-display" {
t.Fatalf("command=%q", cfg.Display.Command)
}
if cfg.Display.User != "orangepi" {
t.Fatalf("user=%q", cfg.Display.User)
}
if cfg.Display.Display != ":0" {
t.Fatalf("display=%q want :0", cfg.Display.Display)
}
if cfg.Display.Xauthority != "/home/orangepi/.Xauthority" {
t.Fatalf("xauthority=%q", cfg.Display.Xauthority)
}
}
func TestLoadOverridesDisplaySettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml")
body := "display:\n enabled: false\n command: /bin/kiosk\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.Display.Enabled {
t.Fatal("display override must disable the kiosk")
}
if cfg.Display.Command != "/bin/kiosk" {
t.Fatalf("command=%q want /bin/kiosk", cfg.Display.Command)
}
if cfg.Display.Display != ":0" {
t.Fatalf("display=%q want default :0", cfg.Display.Display)
}
}
+123
View File
@@ -0,0 +1,123 @@
// Package display owns the Ground Station kiosk canvas lifecycle. The kiosk
// runs independently of WFB, the transmitter and the RC video knob: it waits
// for its rendering target to appear, keeps a fullscreen black canvas alive
// and stops only when told to. Each target (X11/Viture today, MIPI/touch
// later) provides its own opener and templates instead of one global layout.
package display
import (
"context"
"time"
)
// Canvas is the minimal drawing surface a Template renders onto.
type Canvas interface {
Size() (width, height int)
Clear()
Text(x, y int, message string)
Flush()
}
// Template renders one target-specific layout. Templates belong to a display
// target; there is no shared global layout across targets.
type Template interface {
Draw(canvas Canvas)
}
// Session is one live connection to a display target.
type Session interface {
Canvas() Canvas
// WaitEvent blocks until the target asks for a refresh, the context is
// done, or the session fails. redraw reports whether the template should
// be drawn again.
WaitEvent(ctx context.Context) (redraw bool, err error)
Close() error
}
// Opener connects to a display target. Implementations are called repeatedly
// while the target is unavailable and must stay safe for that.
type Opener interface {
Open(ctx context.Context) (Session, error)
}
// Options tune the kiosk lifecycle loop. Zero values select the defaults.
type Options struct {
// RetryDelay is the first wait after a failed connect or lost session.
RetryDelay time.Duration
// MaxDelay bounds the exponential reconnect backoff.
MaxDelay time.Duration
}
func (o Options) withDefaults() Options {
if o.RetryDelay <= 0 {
o.RetryDelay = 500 * time.Millisecond
}
if o.MaxDelay <= 0 {
o.MaxDelay = 5 * time.Second
}
return o
}
// Run owns the kiosk display lifecycle until ctx is cancelled. Temporary
// unavailability of the target, for example X11 not being up yet, never
// returns an error: Run retries with bounded backoff and only stops when the
// context is cancelled, so the Ground Station supervisor stays up.
func Run(ctx context.Context, opener Opener, template Template, options Options) error {
opts := options.withDefaults()
delay := opts.RetryDelay
for {
if ctx.Err() != nil {
return nil
}
session, err := opener.Open(ctx)
if err != nil {
if !sleep(ctx, delay) {
return nil
}
delay = nextDelay(delay, opts.MaxDelay)
continue
}
delay = opts.RetryDelay
canvas := session.Canvas()
template.Draw(canvas)
for ctx.Err() == nil {
redraw, err := session.WaitEvent(ctx)
if err != nil {
break
}
if redraw {
template.Draw(canvas)
}
}
_ = session.Close()
if ctx.Err() != nil {
return nil
}
if !sleep(ctx, delay) {
return nil
}
delay = nextDelay(delay, opts.MaxDelay)
}
}
func nextDelay(delay, max time.Duration) time.Duration {
delay *= 2
if delay > max {
return max
}
return delay
}
func sleep(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
+261
View File
@@ -0,0 +1,261 @@
package display
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeCanvas struct {
mu sync.Mutex
width, height int
clears int
texts []string
flushes int
}
func (c *fakeCanvas) Size() (int, int) { return c.width, c.height }
func (c *fakeCanvas) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.clears++
}
func (c *fakeCanvas) Text(_, _ int, message string) {
c.mu.Lock()
defer c.mu.Unlock()
c.texts = append(c.texts, message)
}
func (c *fakeCanvas) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
c.flushes++
}
func (c *fakeCanvas) lineCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.texts)
}
func (c *fakeCanvas) lines() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.texts...)
}
func (c *fakeCanvas) drawCounts() (clears, flushes int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.clears, c.flushes
}
type eventResult struct {
redraw bool
err error
}
type fakeSession struct {
canvas *fakeCanvas
events chan eventResult
mu sync.Mutex
closed int
}
func newFakeSession() *fakeSession {
return &fakeSession{
canvas: &fakeCanvas{width: 800, height: 600},
events: make(chan eventResult, 8),
}
}
func (s *fakeSession) Canvas() Canvas { return s.canvas }
func (s *fakeSession) WaitEvent(ctx context.Context) (bool, error) {
select {
case <-ctx.Done():
return false, ctx.Err()
case result := <-s.events:
return result.redraw, result.err
}
}
func (s *fakeSession) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
s.closed++
return nil
}
func (s *fakeSession) closeCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
type fakeOpener struct {
open func(attempt int) (Session, error)
}
func (o fakeOpener) Open(context.Context) (Session, error) {
return o.open(0)
}
func waitFor(t *testing.T, condition func() bool, description string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timed out waiting for %s", description)
}
func tinyOptions() Options {
return Options{RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}
}
func TestRunDrawsTemplateWhenTargetConnects(t *testing.T) {
session := newFakeSession()
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return session.canvas.lineCount() > 0 }, "initial template draw")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
if clears, flushes := session.canvas.drawCounts(); clears == 0 || flushes == 0 {
t.Fatalf("template must clear and flush the canvas (clears=%d flushes=%d)", clears, flushes)
}
if session.closeCount() != 1 {
t.Fatalf("session closed %d times, want 1", session.closeCount())
}
}
func TestRunKeepsRetryingWhileTargetUnavailable(t *testing.T) {
session := newFakeSession()
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
if atomic.AddInt32(&opens, 1) <= 2 {
return nil, errors.New("x11 down")
}
return session, nil
}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool {
return atomic.LoadInt32(&opens) >= 3 && session.canvas.lineCount() > 0
}, "connect after retries")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
}
func TestRunRedrawsTemplateOnEvent(t *testing.T) {
session := newFakeSession()
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return session.canvas.lineCount() == 1 }, "initial template draw")
session.events <- eventResult{redraw: true}
waitFor(t, func() bool { return session.canvas.lineCount() == 2 }, "redraw on event")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
}
func TestRunReconnectsAfterSessionFailure(t *testing.T) {
first := newFakeSession()
second := newFakeSession()
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
if atomic.AddInt32(&opens, 1) == 1 {
return first, nil
}
return second, nil
}}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
}()
waitFor(t, func() bool { return first.canvas.lineCount() == 1 }, "draw on first session")
first.events <- eventResult{err: errors.New("connection lost")}
waitFor(t, func() bool { return second.canvas.lineCount() == 1 }, "draw on reconnected session")
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run did not stop after cancel")
}
if first.closeCount() != 1 {
t.Fatalf("failed session closed %d times, want 1", first.closeCount())
}
}
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
atomic.AddInt32(&opens, 1)
return nil, errors.New("must not be called")
}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions()); err != nil {
t.Fatalf("run: %v", err)
}
if atomic.LoadInt32(&opens) != 0 {
t.Fatalf("opener called %d times after cancellation", opens)
}
}
+23
View File
@@ -0,0 +1,23 @@
package display
// StatusTemplate is the fallback kiosk layout: a cleared black canvas with
// status lines. It is the "no pilot video" state of a display target.
type StatusTemplate struct {
lines []string
}
// NewStatusTemplate builds a template that draws the given status lines.
func NewStatusTemplate(lines ...string) StatusTemplate {
return StatusTemplate{lines: lines}
}
func (t StatusTemplate) Draw(canvas Canvas) {
canvas.Clear()
_, height := canvas.Size()
y := height / 2
for _, line := range t.lines {
canvas.Text(32, y, line)
y += 32
}
canvas.Flush()
}
+29
View File
@@ -0,0 +1,29 @@
package display
import "testing"
func TestStatusTemplateDrawsBlackCanvasWithLine(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewStatusTemplate("Brak polaczenia WFB").Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
lines := canvas.lines()
if len(lines) != 1 || lines[0] != "Brak polaczenia WFB" {
t.Fatalf("lines=%v want [Brak polaczenia WFB]", lines)
}
}
func TestStatusTemplateDrawsEveryProvidedLine(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewStatusTemplate("first", "second").Draw(canvas)
lines := canvas.lines()
if len(lines) != 2 || lines[0] != "first" || lines[1] != "second" {
t.Fatalf("lines=%v want [first second]", lines)
}
}
+186
View File
@@ -0,0 +1,186 @@
package display
import (
"context"
"fmt"
"log"
"os/exec"
"time"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/xproto"
)
// X11Opener connects to an X server (selected by DISPLAY in the environment)
// and creates the fullscreen kiosk canvas. It is the display target for the
// Viture screen; future targets such as MIPI/touch provide their own Opener
// and templates.
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
// Fullscreen applies the fullscreen/below window state. It defaults to
// shelling out to wmctrl; tests may replace it. A failure is logged and
// the canvas degrades to a plain window.
Fullscreen func(args []string) error
}
func (o X11Opener) applyFullscreen(args []string) error {
if o.Fullscreen != nil {
return o.Fullscreen(args)
}
return exec.Command("wmctrl", args...).Run()
}
func (o X11Opener) pollInterval() time.Duration {
if o.PollInterval > 0 {
return o.PollInterval
}
return 100 * time.Millisecond
}
// Open connects to the X server and maps the kiosk window. Failure is
// expected while the display manager is starting; the caller retries.
func (o X11Opener) Open(ctx context.Context) (Session, error) {
conn, err := xgb.NewConn()
if err != nil {
return nil, fmt.Errorf("connect X11: %w", err)
}
screen := xproto.Setup(conn).DefaultScreen(conn)
window, err := xproto.NewWindowId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate window: %w", err)
}
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
xproto.WindowClassInputOutput, screen.RootVisual, values,
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
xproto.MapWindow(conn, window)
// Round trip confirms the window is mapped and the server is alive.
if err := xproto.GetInputFocus(conn).Check(); err != nil {
conn.Close()
return nil, fmt.Errorf("map kiosk window: %w", err)
}
// Fullscreen on the root screen, but kept below other windows: pilot
// video owns the rendering priority whenever it is available. The kiosk
// never raises itself, so video always stays on top. A missing wmctrl
// only degrades to a plain window and must not kill the kiosk.
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window))); err != nil {
log.Printf("kiosk fullscreen state: %v (canvas stays a plain window)", err)
}
gc, err := xproto.NewGcontextId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate graphics context: %w", err)
}
font, err := xproto.NewFontId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate font: %w", err)
}
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
xproto.CreateGC(conn, gc, xproto.Drawable(window),
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
return &x11Session{
conn: conn,
window: window,
gc: gc,
width: int(screen.WidthInPixels),
height: int(screen.HeightInPixels),
black: screen.BlackPixel,
white: screen.WhitePixel,
poll: o.pollInterval(),
}, nil
}
func x11FullscreenArgs(window uint32) []string {
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
}
// x11Session implements both Session and Canvas for one X11 connection.
type x11Session struct {
conn *xgb.Conn
window xproto.Window
gc xproto.Gcontext
width int
height int
black uint32
white uint32
poll time.Duration
}
func (s *x11Session) Canvas() Canvas { return s }
func (s *x11Session) Size() (int, int) { return s.width, s.height }
func (s *x11Session) Clear() {
// 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))
}
func (s *x11Session) Text(x, y int, message string) {
if len(message) > 255 {
message = message[:255]
}
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
int16(x), int16(y), message)
}
func (s *x11Session) Flush() {
s.conn.Sync()
}
func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
ticker := time.NewTicker(s.poll)
defer ticker.Stop()
for {
for {
event, err := s.conn.PollForEvent()
if err != nil {
return false, err
}
if event == nil {
break
}
if x11WantsRedraw(event) {
return true, nil
}
}
// Round trip keeps the connection liveness visible even without
// incoming events.
if err := xproto.GetInputFocus(s.conn).Check(); err != nil {
return false, err
}
select {
case <-ctx.Done():
return false, ctx.Err()
case <-ticker.C:
}
}
}
func x11WantsRedraw(event xgb.Event) bool {
switch event.(type) {
case xproto.ExposeEvent:
return true
default:
return false
}
}
func (s *x11Session) Close() error {
// Closing the connection makes the X server destroy the kiosk window.
s.conn.Close()
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package display
import (
"errors"
"strings"
"testing"
"github.com/BurntSushi/xgb/xproto"
)
func TestX11FullscreenArgsKeepCanvasBelowVideo(t *testing.T) {
args := x11FullscreenArgs(0x1a2b)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "add,fullscreen,below") {
t.Fatalf("args %v must request fullscreen with below stacking", args)
}
if !strings.Contains(joined, "0x00001a2b") {
t.Fatalf("args %v must reference the kiosk window id", args)
}
}
func TestX11SessionRedrawsOnlyOnExpose(t *testing.T) {
if !x11WantsRedraw(xproto.ExposeEvent{}) {
t.Fatal("expose events must trigger a redraw")
}
if x11WantsRedraw(xproto.MapNotifyEvent{}) {
t.Fatal("map notify events must not trigger a redraw")
}
}
func TestX11OpenerAppliesFullscreenArgsThroughHook(t *testing.T) {
var got [][]string
opener := X11Opener{Fullscreen: func(args []string) error {
got = append(got, args)
return errors.New("wmctrl missing")
}}
if err := opener.applyFullscreen(x11FullscreenArgs(0x42)); err == nil {
t.Fatal("hook error must propagate for logging")
}
if len(got) != 1 || !strings.Contains(strings.Join(got[0], " "), "add,fullscreen,below") {
t.Fatalf("hook args %v must request fullscreen with below stacking", got)
}
}