Add fullscreen Ground Station display foundation #7
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user