// 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, reserved as the future flight OSD rendering surface. 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 } }