Files
groundstation/internal/display/display.go
T
piotr b169a64ad5
Build Ground Station / build (push) Successful in 44s
feat: show no-WFB kiosk fallback only while video absent (#2)
2026-10-01 14:40:24 +02:00

129 lines
3.4 KiB
Go

// 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. While decoded pilot video is absent the
// canvas shows the fallback; when video output is presented the video window
// takes rendering priority and the fallback disappears, returning when video
// ends. Presence is observed from the video output itself on the display
// server, never from WFB or launcher processes. 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
}
}