diff --git a/README.md b/README.md index 263ce7b..3bcd60f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/groundstation-display/main.go b/cmd/groundstation-display/main.go index 428f50f..5290332 100644 --- a/cmd/groundstation-display/main.go +++ b/cmd/groundstation-display/main.go @@ -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) } } diff --git a/cmd/groundstation/display.go b/cmd/groundstation/display.go new file mode 100644 index 0000000..1c68117 --- /dev/null +++ b/cmd/groundstation/display.go @@ -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 + } +} diff --git a/cmd/groundstation/display_test.go b/cmd/groundstation/display_test.go new file mode 100644 index 0000000..01f3a33 --- /dev/null +++ b/cmd/groundstation/display_test.go @@ -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) +} diff --git a/cmd/groundstation/main.go b/cmd/groundstation/main.go index 8892685..e41b258 100644 --- a/cmd/groundstation/main.go +++ b/cmd/groundstation/main.go @@ -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) { diff --git a/deploy/config.yml b/deploy/config.yml index cff1bf3..b0d1367 100644 --- a/deploy/config.yml +++ b/deploy/config.yml @@ -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 diff --git a/deploy/groundstation.service b/deploy/groundstation.service index 7433640..1aeb0aa 100644 --- a/deploy/groundstation.service +++ b/deploy/groundstation.service @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 0fd2c32..a1874c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..83777b5 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/display/display.go b/internal/display/display.go new file mode 100644 index 0000000..348a7b8 --- /dev/null +++ b/internal/display/display.go @@ -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 + } +} diff --git a/internal/display/display_test.go b/internal/display/display_test.go new file mode 100644 index 0000000..434a04a --- /dev/null +++ b/internal/display/display_test.go @@ -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) + } +} diff --git a/internal/display/status.go b/internal/display/status.go new file mode 100644 index 0000000..74e0ea9 --- /dev/null +++ b/internal/display/status.go @@ -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() +} diff --git a/internal/display/status_test.go b/internal/display/status_test.go new file mode 100644 index 0000000..8612504 --- /dev/null +++ b/internal/display/status_test.go @@ -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) + } +} diff --git a/internal/display/x11.go b/internal/display/x11.go new file mode 100644 index 0000000..2bc3f83 --- /dev/null +++ b/internal/display/x11.go @@ -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 +} diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go new file mode 100644 index 0000000..0156444 --- /dev/null +++ b/internal/display/x11_test.go @@ -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) + } +}