231 lines
7.0 KiB
Go
231 lines
7.0 KiB
Go
// Package display owns the Ground Station kiosk layers. The kiosk runs
|
|
// independently of WFB, the transmitter and the RC video knob: it waits for
|
|
// its rendering target to appear, keeps its windows alive and stops only
|
|
// when told to. Each target drives two independent visual layers: a
|
|
// fullscreen fallback/menu canvas below video, visible while decoded pilot
|
|
// video is absent, and a transparent, input-transparent OSD overlay above
|
|
// video that renders deliberately minimal flight telemetry from
|
|
// supervisor-published OSD snapshots. 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, and the video pipeline is never moved into, coupled to or
|
|
// touched by this package. Each target (X11/Viture today, MIPI/touch later)
|
|
// provides its own openers and templates instead of one global layout.
|
|
package display
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"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)
|
|
}
|
|
|
|
// Reconnect-boundary diagnostic events.
|
|
const (
|
|
// EventOpenFailed marks a failed attempt to open the display target.
|
|
EventOpenFailed = "open_failed"
|
|
// EventSessionEnded marks a session broken by a target failure.
|
|
EventSessionEnded = "session_ended"
|
|
)
|
|
|
|
// Diagnostic is one structured record from a reconnect boundary of the
|
|
// kiosk loop. Exactly one record is emitted per failed open or broken
|
|
// session; cancellation and shutdown stay silent.
|
|
type Diagnostic struct {
|
|
// Target is the display target name, for example "x11".
|
|
Target string
|
|
// Layer is the display layer name within the target, for example
|
|
// "fallback-canvas"; empty for single-layer runs.
|
|
Layer string
|
|
// Event names the boundary: EventOpenFailed or EventSessionEnded.
|
|
Event string
|
|
// Err is the underlying error from Opener.Open or Session.WaitEvent.
|
|
Err error
|
|
// RetryDelay is the wait scheduled after this boundary.
|
|
RetryDelay time.Duration
|
|
}
|
|
|
|
// 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
|
|
// Target names the display target in diagnostics. Empty selects
|
|
// "unknown".
|
|
Target string
|
|
// Layer names the kiosk layer in diagnostics; empty for single-layer
|
|
// runs.
|
|
Layer string
|
|
// Diagnostic receives one structured record per reconnect boundary.
|
|
// Nil disables diagnostics. It runs on the loop goroutine and must not
|
|
// block.
|
|
Diagnostic func(Diagnostic)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (o Options) targetName() string {
|
|
if o.Target == "" {
|
|
return "unknown"
|
|
}
|
|
return o.Target
|
|
}
|
|
|
|
// diagnose emits one reconnect-boundary record. Cancellation is shutdown,
|
|
// not a failure, so it stays silent; so does a nil hook.
|
|
func (o Options) diagnose(ctx context.Context, event string, err error, retryDelay time.Duration) {
|
|
if o.Diagnostic == nil || err == nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
o.Diagnostic(Diagnostic{
|
|
Target: o.targetName(),
|
|
Layer: o.Layer,
|
|
Event: event,
|
|
Err: err,
|
|
RetryDelay: retryDelay,
|
|
})
|
|
}
|
|
|
|
// 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. Each
|
|
// reconnect boundary (failed open, broken session) emits one structured
|
|
// Diagnostic record through Options.Diagnostic, never through an error
|
|
// return; cancellation stays silent.
|
|
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 {
|
|
opts.diagnose(ctx, EventOpenFailed, err, delay)
|
|
if !sleep(ctx, delay) {
|
|
return nil
|
|
}
|
|
delay = nextDelay(delay, opts.MaxDelay)
|
|
continue
|
|
}
|
|
|
|
delay = opts.RetryDelay
|
|
canvas := session.Canvas()
|
|
template.Draw(canvas)
|
|
var sessionErr error
|
|
for ctx.Err() == nil {
|
|
var redraw bool
|
|
redraw, sessionErr = session.WaitEvent(ctx)
|
|
if sessionErr != nil {
|
|
break
|
|
}
|
|
if redraw {
|
|
template.Draw(canvas)
|
|
}
|
|
}
|
|
_ = session.Close()
|
|
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
opts.diagnose(ctx, EventSessionEnded, sessionErr, delay)
|
|
if !sleep(ctx, delay) {
|
|
return nil
|
|
}
|
|
delay = nextDelay(delay, opts.MaxDelay)
|
|
}
|
|
}
|
|
|
|
// Layer is one independent visual layer of a display target: its own
|
|
// connection, window role and template. A lost or degraded layer never
|
|
// affects the others, so a missing overlay cannot disturb the fallback
|
|
// canvas and neither can disturb pilot video.
|
|
type Layer struct {
|
|
// Name identifies the layer in diagnostics.
|
|
Name string
|
|
// Opener connects to the layer's own window on the target.
|
|
Opener Opener
|
|
// Template renders the layer's target-specific layout.
|
|
Template Template
|
|
// Options tune the layer's lifecycle loop. Zero values select the
|
|
// defaults.
|
|
Options Options
|
|
}
|
|
|
|
// RunLayers drives every layer of a display target until ctx is cancelled,
|
|
// each on its own connection with its own reconnect backoff. A layer whose
|
|
// target keeps failing keeps retrying and never stops the other layers, so
|
|
// the Ground Station supervisor stays up. RunLayers returns only after every
|
|
// layer has stopped.
|
|
func RunLayers(ctx context.Context, layers []Layer) {
|
|
var wg sync.WaitGroup
|
|
for _, layer := range layers {
|
|
wg.Add(1)
|
|
go func(layer Layer) {
|
|
defer wg.Done()
|
|
_ = Run(ctx, layer.Opener, layer.Template, layer.Options)
|
|
}(layer)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|