feat: independent fullscreen kiosk display foundation (#1)

Kiosk process owns its display lifecycle: waits for X11, keeps the
fullscreen black canvas below video, stops deterministically on signal.
Supervisor starts it before video, restarts only on process death, and
shuts the whole process group down; X11 downtime never restarts the
supervisor. Per-target opener/template abstractions for future MIPI.
This commit is contained in:
2026-09-30 23:12:06 +02:00
parent 0360f23f0a
commit a31bed8aeb
15 changed files with 1266 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()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 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()
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)
}
}
+142
View File
@@ -0,0 +1,142 @@
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.
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
}
}
// 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
}
}
+303
View File
@@ -0,0 +1,303 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"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")
}
// The whole group ignores SIGINT, forcing the SIGKILL escalation.
cmd := exec.Command("sh", "-c", "echo started; trap '' INT; 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)
}
+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
+23
View File
@@ -6,7 +6,17 @@ 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 {
Display DisplayConfig `yaml:"display"`
Serial struct {
Path string `yaml:"path"`
} `yaml:"serial"`
@@ -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)
}
}