media-operator
Go
3151 of 3640 statements, 86.6%.
album.go 93.8%
1package main23// An album is one item. The presentation block declares an item's shape,4// the way a video item's hint does: a directory item marked as an album5// becomes one written timeline, and a file item passes through as itself.6// The expansion runs in the shim, because the playback pod is the one7// process that mounts the media, so it is the one process that can read an8// album's files.910import (11 "encoding/json"12 "fmt"13 "os"14 "path/filepath"1516 "github.com/liken-sh/media-operator/edl"17)1819// The two block values that mark an album. An item needs both, because the20// music type alone is a standalone track, and the hint names the shape the21// way movie and series name a video's.22const (23 presentationTypeMusic = "music"24 presentationHintAlbum = "album"25)2627// Where the shim writes an album's timeline. It is the volume the playback28// pod already shares between mpv and the command sidecar, so mpv loads the29// timeline and the sidecar reads it back for the album's cover. It is a30// variable rather than a constant so a test writes under its own directory.31var albumTimelineDir = ipcMountPath3233// expandItems turns the resolved playlist into the entries mpv loads. An34// album directory becomes the one timeline written for it, and every other35// item is the path the resolver already settled.36func expandItems(items []string, blocks []json.RawMessage) ([]string, error) {37 entries := make([]string, len(items))38 for index, item := range items {39 entries[index] = item40 if !isAlbum(presentationAt(blocks, index)) {41 continue42 }43 timeline, err := writeAlbumTimeline(item, index)44 if err != nil {45 return nil, err46 }47 entries[index] = timeline48 }49 return entries, nil50}5152// writeAlbumTimeline reads one album folder and writes its timeline. An53// album with no audio files fails the run, because a spec that marks a54// folder as an album and points at an empty one states a wrong fact, and55// silence would hide it.56func writeAlbumTimeline(dir string, index int) (string, error) {57 files, err := edl.AlbumFiles(dir)58 if err != nil {59 return "", fmt.Errorf("read the album %q: %w", dir, err)60 }61 if len(files) == 0 {62 return "", fmt.Errorf("the album %q holds no audio files", dir)63 }64 path := filepath.Join(albumTimelineDir, fmt.Sprintf("album-%d.edl", index+1))65 if err := os.WriteFile(path, []byte(edl.Timeline(files)), 0o644); err != nil {66 return "", fmt.Errorf("write the timeline for %q: %w", dir, err)67 }68 return path, nil69}7071// isAlbum reads one block as the mark of an album: the music type and the72// album hint together.73func isAlbum(presentation Presentation) bool {74 return presentation.Type == presentationTypeMusic && presentation.Hint == presentationHintAlbum75}7677// allMusic says whether every item of the run is music. The answer decides78// --vid=no, and the flag is global to the run, so one film in the list must79// keep video on for the whole of it.80func allMusic(blocks []json.RawMessage, items int) bool {81 if items == 0 {82 return false83 }84 for index := range items {85 if presentationAt(blocks, index).Type != presentationTypeMusic {86 return false87 }88 }89 return true90}9192// presentationAt reads one item's block. An item the pod baked no block for93// reads as an empty block, so a Play that declares nothing keeps the94// player's own defaults.95func presentationAt(blocks []json.RawMessage, index int) Presentation {96 var presentation Presentation97 if index < 0 || index >= len(blocks) {98 return presentation99 }100 if err := json.Unmarshal(blocks[index], &presentation); err != nil {101 return Presentation{}102 }103 return presentation104}
api.go 100.0%
1package main23// The wire types are hand-written, the way liken and the sibling4// operators write theirs. The Kubernetes API is HTTPS that serves5// JSON, and importing client-go for a dozen structs brings informers,6// work queues, and a release cadence this program does not use. Each7// type carries only the fields this operator reads or writes; the8// API server fills in the rest.910import "encoding/json"1112// The group this operator serves, and the two Kubernetes groups it13// writes into: claims live under resource.k8s.io and pods under the14// core group, because the objects a Play becomes are ordinary15// Kubernetes objects any tool can read.16const (17 mediaAPIVersion = "media.liken.sh/v1alpha1"18 claimAPIVersion = "resource.k8s.io/v1"19 podAPIVersion = "v1"20)2122// ObjectMeta carries what this operator reads or writes: name and23// namespace for the URL, resourceVersion for the conditional write, uid24// with ownerReferences for garbage collection, and labels so a watch25// selects the operator's own playback pods.26//27// Annotations carry the template hash the operator stamps on a standing28// claim and a standing pod, which is how a pass tells a live object from29// the object it would build now. deletionTimestamp is set by the API30// server on an object that is on its way out, and a standing pair with31// one set is left alone until the delete completes.32type ObjectMeta struct {33 Name string `json:"name,omitempty"`34 Namespace string `json:"namespace,omitempty"`35 UID string `json:"uid,omitempty"`36 ResourceVersion string `json:"resourceVersion,omitempty"`37 Labels map[string]string `json:"labels,omitempty"`38 Annotations map[string]string `json:"annotations,omitempty"`39 DeletionTimestamp string `json:"deletionTimestamp,omitempty"`40 OwnerReferences []OwnerReference `json:"ownerReferences,omitempty"`41}4243// An ownerReference ties an object's life to its owner's: the44// garbage collector deletes the owned object when the owner goes,45// which is this operator's whole teardown. Controller is true46// because exactly one thing manages each pod and claim; there is no47// blockOwnerDeletion, because nothing here needs the owner to wait.48type OwnerReference struct {49 APIVersion string `json:"apiVersion"`50 Kind string `json:"kind"`51 Name string `json:"name"`52 UID string `json:"uid"`53 Controller bool `json:"controller"`54}5556// A list's own resourceVersion is the revision of the whole57// collection, which is what a watch resumes from.58type ListMeta struct {59 ResourceVersion string `json:"resourceVersion,omitempty"`60}6162// A Player is equipment, not a running thing. It holds no claims of63// its own. The operator reads the spec and writes the status: the64// spec is the equipment a person declared, and the status is what65// plays on it now.66type Player struct {67 APIVersion string `json:"apiVersion,omitempty"`68 Kind string `json:"kind,omitempty"`69 Metadata ObjectMeta `json:"metadata"`70 Spec PlayerSpec `json:"spec"`71 Status PlayerStatus `json:"status"`72}7374// The status the operator writes onto a Player: whether it plays75// anything now, and the name of the Play that does. Both are76// omitempty, so an idle Player with no activity word yet shows blank77// rather than a zero.78type PlayerStatus struct {79 Activity string `json:"activity,omitempty"`80 Play string `json:"play,omitempty"`8182 // Panel is what the screen's Display last observed, empty83 // until a Display carries an observation.84 Panel string `json:"panel,omitempty"`8586 // Idle is the resolved idle screen controller and, where a87 // controller draws, the standing claim a delegate references and the88 // requests that claim carries. It is nil for a Player that drives no89 // screen and for a cluster that names no display-draw class.90 Idle *PlayerIdleStatus `json:"idle,omitempty"`91}9293// PlayerIdleStatus is what a delegate wires its client from. Controller94// is the resolved name, always set. Claim is the standing claim in the95// Player's namespace, which the delegate's pod references by name.96// Requests are the claim's request names in claim order, one per97// resources.claims entry the delegate's container states.98// FadeAfterSeconds and OffAfterSeconds are the resolved windows, always99// written, because zero is a policy and an absent field is not one.100// Under media.liken.sh/none the block carries the controller alone.101// standing claim in the Player's namespace, which the delegate's pod102// references by name. Requests are the claim's request names in claim103// order, one per resources.claims entry the delegate's container states.104// FadeAfterSeconds and OffAfterSeconds are the two resolved windows, and105// both are always written, because zero is a policy and an absent field106// is not one. Under media.liken.sh/none the block carries the controller107// alone.108type PlayerIdleStatus struct {109 Controller string `json:"controller"`110 Claim string `json:"claim,omitempty"`111 Requests []string `json:"requests,omitempty"`112113 // The two windows the operator resolved, in seconds. Zero on the114 // fade means the screen never fades on its own, and zero on the off115 // window leaves the panel lit. Neither has omitempty, so a client116 // reads a number for each.117 FadeAfterSeconds int64 `json:"fadeAfterSeconds"`118 OffAfterSeconds int64 `json:"offAfterSeconds"`119120 // Bus is what a delegate's client reads to join the unit on the121 // bus. It is present under every controller but122 // media.liken.sh/none.123 Bus *PlayerIdleBus `json:"bus,omitempty"`124}125126// PlayerIdleBus names the broker and every topic a delegate's client127// reads or writes. The broker and the topic base are this operator's128// configuration, so the status carries the built topics and a client129// derives none. VolumeTopic is empty for a unit with no sinks, which is130// the speaker gate: the client subscribes to no level, draws none, and131// publishes none. Remotes has one entry per spec.remotes entry, in spec132// order, because that position is the index a focus moment carries.133type PlayerIdleBus struct {134 Address string `json:"address"`135 StatusTopic string `json:"statusTopic"`136 VolumeTopic string `json:"volumeTopic,omitempty"`137 CommandsTopic string `json:"commandsTopic"`138 PanelTopic string `json:"panelTopic"`139 Remotes []PlayerIdleRemote `json:"remotes,omitempty"`140}141142// PlayerIdleRemote is one of the unit's controllers as a client reads143// it: the topic its presses arrive on and the topic its focus mark is144// on. The client gates every press on the mark naming this Player, and145// the cycle topic is the focus topic plus /cycle.146type PlayerIdleRemote struct {147 Events string `json:"events"`148 Focus string `json:"focus"`149}150151// The three panel states, each folded from the Display's152// observed values and not from what the media layer asked for.153const (154 panelOn = "On"155 panelBacklightOff = "BacklightOff"156 panelOff = "Off"157)158159// A Player's activity is the coarse state a person scans for: it160// plays a Play now, a Play is starting on it, or it is free. The161// Play name beside it says which run, so the two columns together162// read as one sentence.163const (164 playerPlaying = "Playing"165 playerStarting = "Starting"166 playerIdle = "Idle"167)168169type PlayerList struct {170 Metadata ListMeta `json:"metadata"`171 Items []Player `json:"items"`172}173174// The three device roles. Display and render are single because one175// pod drives one screen through one GPU; sinks is a list because a176// unit plays through however many outputs it has. The CRD requires a177// display or at least one sink.178//179// Remotes names the controllers the unit owns. Each entry names a180// Remote in the same namespace, and the Play's command sidecar reads181// the events topic of each one, so a unit's controllers belong to its182// spec beside its display and its sinks.183type PlayerSpec struct {184 Zone string `json:"zone,omitempty"`185186 // The human name of this unit, the one the idle screen and later187 // ambient surfaces show in place of the object name. It is the188 // household's word for the unit, such as Studio Lab. Unset, the idle189 // screen falls back to the Player's object name.190 DisplayName string `json:"displayName,omitempty"`191192 Display *PlayerDevice `json:"display,omitempty"`193 Sinks []PlayerDevice `json:"sinks,omitempty"`194 Render *PlayerDevice `json:"render,omitempty"`195 Remotes []PlayerRemote `json:"remotes,omitempty"`196197 // The per-Player override of the audio and subtitle language preferences.198 // A nil list, or an empty Subtitles, means this Player states nothing, so199 // resolution reads the default MediaPreferences instead.200 AudioLanguages []string `json:"audioLanguages,omitempty"`201 SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`202 Subtitles string `json:"subtitles,omitempty"`203204 // Idle is this unit's idle screen policy. Resolution reads it field205 // by field over the default MediaPreferences, so a Player states206 // only what differs from the household.207 Idle *IdlePolicy `json:"idle,omitempty"`208}209210// IdlePolicy is what the idle screen does while nothing plays. The same211// block is the override on a Player and the default on the household's212// MediaPreferences, so the two tiers resolve field by field.213type IdlePolicy struct {214 // Image is the container image the idle screen runs. The image215 // starts with its own entrypoint and reads the unit's state off the216 // bus. Empty defers to the next tier.217 // A tier that states none runs the idle client at the operator's218 // own version.219 Image string `json:"image,omitempty"`220221 // FadeAfterSeconds is the quiet stretch before the idle screen222 // fades to black. Zero disables the automatic fade. A pointer,223 // because zero and absent differ: absent defers to the next tier.224 FadeAfterSeconds *int64 `json:"fadeAfterSeconds,omitempty"`225226 // OffAfterSeconds is the quiet stretch before the panel itself227 // goes dark. Zero or absent means it never does. A pointer for the228 // same reason the fade window is one.229 OffAfterSeconds *int64 `json:"offAfterSeconds,omitempty"`230231 // OffMode is which override the operator applies at the off232 // window, the backlight or the panel's power. Empty defers to the233 // next tier.234 OffMode string `json:"offMode,omitempty"`235236 // Controller names the operator that draws this unit's idle237 // screen, a domain-qualified name the way a GatewayClass names its238 // controllerName. Empty defers to the next tier, and where no tier239 // states one the built-in is media.liken.sh/idle-screen, this240 // operator's own name.241 Controller string `json:"controller,omitempty"`242}243244// The two ways a panel goes dark, and the two override blocks they245// become. A backlight at zero still answers DDC. Power off stops246// some panels from answering DDC at all, so a Player states it only247// for a panel the drill proved wakes.248const (249 offModeBacklight = "backlight"250 offModePower = "power"251)252253// One controller the Player owns. Name is the Remote in the same254// namespace. A controller maps one way on every unit, as a device255// does under hwdb, so the entry carries no Keymap of its own.256type PlayerRemote struct {257 Name string `json:"name"`258259 // The human name of this controller, the one the idle screen shows in260 // its parts list, such as Studio Dualsense Controller. Unset, the idle261 // screen falls back to Name, the Remote this entry references.262 DisplayName string `json:"displayName,omitempty"`263}264265// One device selection. The three fields become a DeviceClass name,266// a CEL selector, and an opaque config block on the claim.267type PlayerDevice struct {268 Class string `json:"class"`269270 // The human name of this selection, the one the idle screen shows in271 // its parts list, such as Portable Screen or Built-in Speakers. Unset,272 // the idle screen falls back to the DeviceClass name, which says what273 // the selection is.274 DisplayName string `json:"displayName,omitempty"`275276 Selector string `json:"selector,omitempty"`277 Parameters *DeviceParameters `json:"parameters,omitempty"`278}279280// Values is raw JSON because the driver defines the parameters and281// this operator carries them onto the claim unread.282type DeviceParameters struct {283 Driver string `json:"driver"`284 Values json.RawMessage `json:"values,omitempty"`285}286287// A Play is one run of media on a Player, with a lifecycle analogous288// to a Job: it runs once to completion, and it stays for its status289// until its ttlSecondsAfterFinished passes or a person deletes it.290type Play struct {291 APIVersion string `json:"apiVersion,omitempty"`292 Kind string `json:"kind,omitempty"`293 Metadata ObjectMeta `json:"metadata"`294 Spec PlaySpec `json:"spec"`295 Status PlayStatus `json:"status"`296}297298// A Play names the players it runs on and the items to play in order,299// and Start is where in the first item the run begins. Players is a300// list of one today, because the carriage layer will let one Play301// reach several players in sync, and a list that grows a second302// element changes no field name when it does.303type PlaySpec struct {304 Players []string `json:"players"`305 Items []PlayItem `json:"items"`306 Start string `json:"start,omitempty"`307 // The seconds one trickplay tile covers, as a Go duration like 10s.308 // Jellyfin writes no manifest beside the sheets, and the last sheet is309 // padded, so the tile count cannot be read back. The Play declares the310 // interval, and it defaults to 10s when this is empty.311 TrickplayInterval string `json:"trickplayInterval,omitempty"`312313 // How long a Finished Play stands before the operator deletes it, in314 // seconds. The field is a pointer because zero and absent mean315 // different things: zero deletes the Play on the pass that sees it316 // finished, and absent takes defaultTTLSecondsAfterFinished. A plain317 // int64 would read an unset field as zero and delete every Play at318 // once.319 //320 // The window is the Play's own affair and not operator configuration,321 // because whoever creates a Play knows how long the record is worth322 // keeping: a library app sets the window its continue-watching feature323 // reads, and two apps on one cluster choose differently.324 TTLSecondsAfterFinished *int64 `json:"ttlSecondsAfterFinished,omitempty"`325326 // The per-Play override of the language preferences, the most specific tier.327 // A nil list, or an empty Subtitles, means this Play states nothing, so328 // resolution reads the Player next.329 AudioLanguages []string `json:"audioLanguages,omitempty"`330 SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`331 Subtitles string `json:"subtitles,omitempty"`332333 // The level this run starts at. The operator writes it through to334 // the unit's volume topic before it creates the pod, so the335 // override becomes the Player's state and everything after it is336 // the ordinary path. Absent, the run starts at whatever the topic337 // already holds.338 Volume *PlayVolume `json:"volume,omitempty"`339}340341// PlayVolume is a Play's starting level, its muted flag, or both.342// Each field is a pointer because absent and zero differ: an absent343// level carries nothing and leaves the unit where it stands, and a344// level of zero is silence the Play asked for.345type PlayVolume struct {346 Level *int `json:"level,omitempty"`347 Muted *bool `json:"muted,omitempty"`348}349350// A PlayItem is one entry in the list: the media URI and an optional351// Presentation. The block is optional because a loose file needs only352// its URI, and the display falls back to what mpv reads from the file.353type PlayItem struct {354 URI string `json:"uri"`355 Presentation *Presentation `json:"presentation,omitempty"`356}357358// A Presentation declares what mpv cannot read from the file, so the359// display renders an item the way the library that fed liken describes360// it, not the way a container's tags happen to read.361//362// It carries the text fields and two art references, the logo and the363// trickplay directory. The resolver rewrites each the way it rewrites the364// media URI, so an nfs reference or a claim reference shares the media's365// mount and an https reference stays a URL.366type Presentation struct {367 Type string `json:"type,omitempty"`368 Hint string `json:"hint,omitempty"`369 Title string `json:"title,omitempty"`370 Series string `json:"series,omitempty"`371 Season int `json:"season,omitempty"`372 Episode int `json:"episode,omitempty"`373 EpisodeTitle string `json:"episodeTitle,omitempty"`374 Year int `json:"year,omitempty"`375 Date string `json:"date,omitempty"`376 Logo string `json:"logo,omitempty"`377378 // The two music text fields. An album states them so the display draws379 // the words the Play resolved, and the display reads no tags of its380 // own.381 Artist string `json:"artist,omitempty"`382 Album string `json:"album,omitempty"`383384 // A reference to the item's cover image, resolved the way the logo is.385 // It is the first tier of the art the music layout draws, and the386 // picture inside the file and a cover beside it follow.387 Art string `json:"art,omitempty"`388389 // A reference to the item's X.trickplay directory, resolved the way the390 // logo is. The display shows a tile from its sprite sheets on the scrub391 // cursor.392 Trickplay string `json:"trickplay,omitempty"`393}394395// The status the operator alone writes: the phase, the activity396// word, the paused flag, the item counting from 1, the playhead, the397// pod's name, and the message that says why when a word is not398// enough. Every field is omitempty so a column with nothing to say399// shows blank in kubectl rather than a zero.400type PlayStatus struct {401 Phase string `json:"phase,omitempty"`402 Activity string `json:"activity,omitempty"`403 Paused bool `json:"paused,omitempty"`404 Item int `json:"item,omitempty"`405 Position string `json:"position,omitempty"`406 Duration string `json:"duration,omitempty"`407 Pod string `json:"pod,omitempty"`408 Message string `json:"message,omitempty"`409410 // When the operator first read this run's phase as Finished, in RFC411 // 3339. The time-to-live after finishing counts from here and not from412 // the Play's creation, so the window measures the end of the film. It413 // lives on the status rather than in the operator's memory, so an414 // operator that restarts reads the clock back from the API server.415 FinishedAt string `json:"finishedAt,omitempty"`416417 // The preferences this run resolved, the console-parity record of what the418 // three tiers settled on.419 AudioLanguages []string `json:"audioLanguages,omitempty"`420 SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`421 Subtitles string `json:"subtitles,omitempty"`422423 // The language of the audio track and the subtitle track mpv selected, so a424 // code that matched no track shows plainly.425 AudioLanguage string `json:"audioLanguage,omitempty"`426 SubtitleLanguage string `json:"subtitleLanguage,omitempty"`427}428429// The four phases, in the words Jobs and Pods use so nobody learns a430// new vocabulary. Finished and Failed are terminal: a phase moves431// forward only.432const (433 phasePending = "Pending"434 phaseRunning = "Running"435 phaseFinished = "Finished"436 phaseFailed = "Failed"437)438439// The activity is the one word a person reads to know what the Play440// is doing right now. The phase is the lifecycle, which never goes441// backward; the activity folds the paused flag into that lifecycle,442// so a paused run reads Paused where the phase still reads Running.443const (444 activityStarting = "Starting"445 activityPlaying = "Playing"446 activityPaused = "Paused"447 activityFinished = "Finished"448 activityFailed = "Failed"449)450451// playActivity is the phase and the paused flag folded into one452// word. A Play with no phase yet has no activity either, so an453// unwritten status stays blank.454func playActivity(phase string, paused bool) string {455 switch phase {456 case phasePending:457 return activityStarting458 case phaseRunning:459 if paused {460 return activityPaused461 }462 return activityPlaying463 case phaseFinished:464 return activityFinished465 case phaseFailed:466 return activityFailed467 }468 return ""469}470471// A Play in a terminal phase gets no further reconcile. Its pod and472// claim stay for reading until the Play is deleted, and the garbage473// collector tears them down then.474func terminalPhase(phase string) bool {475 return phase == phaseFinished || phase == phaseFailed476}477478// finishedPhase reports the one terminal phase the pass acts on. Only a479// Finished Play is done, so the pass skips it. A Failed Play resumes, so the480// pass reconciles it. terminalPhase still counts both, for the focus rule481// that a crashed run holds no controller.482func finishedPhase(phase string) bool {483 return phase == phaseFinished484}485486type PlayList struct {487 Metadata ListMeta `json:"metadata"`488 Items []Play `json:"items"`489}490491// The three subtitle modes a preference tier may state.492const (493 subtitlesOn = "on"494 subtitlesOff = "off"495 subtitlesAuto = "auto"496)497498// mediaPreferencesName is the one name a MediaPreferences may take. The CRD499// pins it with a CEL rule, so a second default is rejected at apply, and this500// operator reads the singleton by this name.501const mediaPreferencesName = "default"502503// MediaPreferences is the cluster-scoped household default for audio and504// subtitle languages, the lowest of the three tiers a Play resolves.505type MediaPreferences struct {506 APIVersion string `json:"apiVersion,omitempty"`507 Kind string `json:"kind,omitempty"`508 Metadata ObjectMeta `json:"metadata"`509 Spec MediaPreferencesSpec `json:"spec"`510}511512// MediaPreferencesSpec holds the default language fields. Resolution reads513// each field only when no more specific tier states it.514type MediaPreferencesSpec struct {515 AudioLanguages []string `json:"audioLanguages,omitempty"`516 SubtitleLanguages []string `json:"subtitleLanguages,omitempty"`517 Subtitles string `json:"subtitles,omitempty"`518519 // The household wall-clock zone, an IANA name like America/New_York. One520 // per cluster, with no per-Play or per-Player override. The player pod521 // reads it as TZ, so the display clock shows local time.522 TimeZone string `json:"timeZone,omitempty"`523524 // Idle is the household default idle screen policy, read for each525 // field a Player's own block leaves unset.526 Idle *IdlePolicy `json:"idle,omitempty"`527}528529type MediaPreferencesList struct {530 Metadata ListMeta `json:"metadata"`531 Items []MediaPreferences `json:"items"`532}533534// A Remote is one physical controller: the device it is and, where535// its model needs one, the Keymap for its model. The operator reads the536// spec to build the standing pod and to hand each Play's command537// sidecar its topics, and writes the status to report which unit the538// controller drives now.539type Remote struct {540 APIVersion string `json:"apiVersion,omitempty"`541 Kind string `json:"kind,omitempty"`542 Metadata ObjectMeta `json:"metadata"`543 Spec RemoteSpec `json:"spec"`544 Status RemoteStatus `json:"status"`545}546547// RemoteStatus is what the operator reports on a Remote. Player is the548// Player the controller's retained focus mark names, so kubectl answers549// which unit a press reaches without a read of the bus. It is empty while550// no Player in the namespace lists the Remote.551//552// Unbound is the gap: every code the controller declares that its553// Keymap does not bind. It is empty when the Keymap binds every554// declared code, and absent while no standing pod has reported.555//556// Peripheral names the bluetooth-operator's Peripheral for the device557// this Remote's standing claim allocated. The name is the device's558// address in the lowercase dashed form, which is the device name the559// allocation result carries. It is empty while the claim carries no560// allocation, and for a controller some other driver publishes. The561// Peripheral is where a person reads the controller's link and its562// charge.563type RemoteStatus struct {564 Player string `json:"player,omitempty"`565 Peripheral string `json:"peripheral,omitempty"`566 Unbound []UnboundCode `json:"unbound,omitempty"`567}568569// UnboundCode is one code the controller declares and the Keymap570// leaves unbound: the raw evdev code, its name where the kernel gives571// it one, and which event type carries it.572type UnboundCode struct {573 Code uint16 `json:"code"`574 Name string `json:"name,omitempty"`575 Type string `json:"type"`576}577578// A Remote holds its device selector and the Keymap for its model,579// and it names no player. A Player names the Remotes it owns through580// spec.remotes, so the unit that owns a controller is the one that581// lists it.582//583// Discovery is the teaching mode: the standing pod keeps every node584// the claim delivered and logs each event the way a Keymap names it.585// Turning it on or off replaces the standing pod.586type RemoteSpec struct {587 Device RemoteDevice `json:"device"`588 Keymap string `json:"keymap,omitempty"`589 Discovery bool `json:"discovery,omitempty"`590}591592// The controller is selected the way a Player's display is, by a593// DeviceClass and a CEL expression. There is no parameters field,594// because nothing prepares an input device the way a codec prepares595// a sink.596type RemoteDevice struct {597 Class string `json:"class"`598 Selector string `json:"selector,omitempty"`599}600601type RemoteList struct {602 Metadata ListMeta `json:"metadata"`603 Items []Remote `json:"items"`604}605606// A Keymap is one controller model's table from its odd controls to607// the kernel key names they should report, written once per model and608// shared by every Remote of that model. A model whose kernel names are609// already right needs no Keymap at all.610type Keymap struct {611 APIVersion string `json:"apiVersion,omitempty"`612 Kind string `json:"kind,omitempty"`613 Metadata ObjectMeta `json:"metadata"`614 Spec KeymapSpec `json:"spec"`615}616617// Buttons and axes are separate lists because they bind differently:618// a button is a press, and an axis entry names a direction as well.619// The CRD requires at least one entry across the two.620type KeymapSpec struct {621 Buttons []KeymapButton `json:"buttons,omitempty"`622 Axes []KeymapAxis `json:"axes,omitempty"`623}624625// KeymapList is the cluster-scoped collection the operator lists and626// watches, so a Keymap edit wakes the loop that recompiles and627// republishes it.628type KeymapList struct {629 Metadata ListMeta `json:"metadata"`630 Items []Keymap `json:"items"`631}632633// A KeymapRepeat makes a row repeat while the control is held. The634// standing remote pod publishes the press, waits the delay, then635// publishes value 2 every interval until the release. The delay and636// the interval are durations, like 400ms or 1s, and each takes a637// default when it is empty. A control with no block reports only what638// the kernel reports.639type KeymapRepeat struct {640 Delay string `json:"delay,omitempty"`641 Interval string `json:"interval,omitempty"`642}643644// Press is the control, an evdev key name out of buttonCodes. Key is645// the KEY_* name the controller reports instead, or none to drop the646// control. Both sides are the kernel's names, the way an hwdb entry is647// written.648type KeymapButton struct {649 Press string `json:"press"`650 Key string `json:"key"`651 Repeat *KeymapRepeat `json:"repeat,omitempty"`652}653654// An axis entry adds the value, because a hat axis reports -1 and 1655// as its two presses and 0 as the release: one axis is two rows.656type KeymapAxis struct {657 Axis string `json:"axis"`658 Value int `json:"value"`659 Key string `json:"key"`660 Repeat *KeymapRepeat `json:"repeat,omitempty"`661}662663// A ResourceClaim is the request for hardware. The operator664// writes only the spec. The scheduler writes the allocation into the665// status, and the pass reads it for one question: which screen the666// idle pod's draw request took.667type ResourceClaim struct {668 APIVersion string `json:"apiVersion,omitempty"`669 Kind string `json:"kind,omitempty"`670 Metadata ObjectMeta `json:"metadata"`671 Spec ResourceClaimSpec `json:"spec"`672 Status *ResourceClaimStatus `json:"status,omitempty"`673}674675type ResourceClaimSpec struct {676 Devices DeviceClaim `json:"devices"`677}678679// Requests name the devices by role and config carries driver680// parameters for them. Both are claim-level, so one claim covers the681// whole player.682type DeviceClaim struct {683 Requests []DeviceRequest `json:"requests,omitempty"`684 Config []DeviceClaimConfiguration `json:"config,omitempty"`685686 // Constraints tie named requests to one another, so two requests687 // allocate against the same piece of equipment.688 Constraints []DeviceConstraint `json:"constraints,omitempty"`689}690691// One constraint: the requests it covers, and the attribute whose692// value every one of those devices must share.693type DeviceConstraint struct {694 Requests []string `json:"requests,omitempty"`695 MatchAttribute string `json:"matchAttribute,omitempty"`696}697698// The request name is the role the pod refers to, and exactly is699// DRA's one-of that holds a plain request.700type DeviceRequest struct {701 Name string `json:"name"`702 Exactly *ExactDeviceRequest `json:"exactly,omitempty"`703}704705// ExactCount with count 1 asks for one device, no more offered and706// no fewer accepted. The selector list is omitted when the class707// alone chooses the device.708type ExactDeviceRequest struct {709 DeviceClassName string `json:"deviceClassName"`710 AllocationMode string `json:"allocationMode,omitempty"`711 Count int `json:"count,omitempty"`712 Selectors []DeviceSelector `json:"selectors,omitempty"`713 Tolerations []DeviceToleration `json:"tolerations,omitempty"`714}715716// A selector is a CEL expression over device.attributes, the same717// expression a hand-written claim would carry.718type DeviceSelector struct {719 CEL *CELDeviceSelector `json:"cel,omitempty"`720}721722type CELDeviceSelector struct {723 Expression string `json:"expression"`724}725726// A device taint evicts the pod that holds the device. A toleration727// with tolerationSeconds is how long the play survives an unplugged728// cable.729type DeviceToleration struct {730 Key string `json:"key,omitempty"`731 Operator string `json:"operator,omitempty"`732 Effect string `json:"effect,omitempty"`733 TolerationSeconds *int64 `json:"tolerationSeconds,omitempty"`734}735736// An opaque config block reaches the driver unread by the scheduler,737// and requests names which of the claim's requests it applies to.738type DeviceClaimConfiguration struct {739 Requests []string `json:"requests,omitempty"`740 Opaque *OpaqueDeviceConfiguration `json:"opaque,omitempty"`741}742743type OpaqueDeviceConfiguration struct {744 Driver string `json:"driver"`745 Parameters json.RawMessage `json:"parameters"`746}747748// The playback pod. The operator writes its spec once and reads its749// status every pass, because the pod's phase is where the Play's750// phase comes from.751type Pod struct {752 APIVersion string `json:"apiVersion,omitempty"`753 Kind string `json:"kind,omitempty"`754 Metadata ObjectMeta `json:"metadata"`755 Spec PodSpec `json:"spec"`756 Status PodStatus `json:"status"`757}758759// PodList is the pod collection ListPlaybackPods returns. Its760// resourceVersion is where the pod watch begins.761type PodList struct {762 Metadata ListMeta `json:"metadata"`763 Items []Pod `json:"items"`764}765766// The pod spec's few fields: restartPolicy Never because the pod's767// end is the play's end, and the short grace period because mpv768// exits promptly on SIGTERM.769//770// initContainers is where a native sidecar goes: an init container771// with restartPolicy Always starts before the ordinary containers,772// runs beside them, and restarts alone, without ending the pod.773type PodSpec struct {774 RestartPolicy string `json:"restartPolicy,omitempty"`775 TerminationGracePeriodSeconds *int64 `json:"terminationGracePeriodSeconds,omitempty"`776 ResourceClaims []PodResourceClaim `json:"resourceClaims,omitempty"`777 InitContainers []Container `json:"initContainers,omitempty"`778 Containers []Container `json:"containers"`779 Volumes []Volume `json:"volumes,omitempty"`780}781782// A pod names a claim once, and its containers refer to that name783// request by request, which is what keeps a device out of a784// container that must not hold it.785type PodResourceClaim struct {786 Name string `json:"name"`787 ResourceClaimName string `json:"resourceClaimName,omitempty"`788}789790// Command replaces the image's entrypoint, which is how one image791// runs the playback pod's two roles. RestartPolicy is set only on an792// init container, where Always is what makes it a sidecar.793type Container struct {794 Name string `json:"name"`795 Image string `json:"image"`796 Command []string `json:"command,omitempty"`797 Args []string `json:"args,omitempty"`798 Env []EnvVar `json:"env,omitempty"`799 Resources ResourceRequirements `json:"resources"`800 VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"`801 RestartPolicy string `json:"restartPolicy,omitempty"`802}803804// resources.claims is how a container holds one of the pod's805// claims, and request narrows it to one role inside that claim.806type ResourceRequirements struct {807 Claims []ContainerClaim `json:"claims,omitempty"`808}809810type ContainerClaim struct {811 Name string `json:"name"`812 Request string `json:"request,omitempty"`813}814815type EnvVar struct {816 Name string `json:"name"`817 Value string `json:"value,omitempty"`818}819820type VolumeMount struct {821 Name string `json:"name"`822 MountPath string `json:"mountPath"`823 ReadOnly bool `json:"readOnly,omitempty"`824}825826// An inline NFS volume needs no PersistentVolume and no CSI driver.827// The kubelet mounts it with the kernel's NFS client, through the828// mount helper liken's image carries.829type Volume struct {830 Name string `json:"name"`831 NFS *NFSVolumeSource `json:"nfs,omitempty"`832 PersistentVolumeClaim *PersistentVolumeClaimVolumeSource `json:"persistentVolumeClaim,omitempty"`833 EmptyDir *EmptyDirVolumeSource `json:"emptyDir,omitempty"`834}835836type NFSVolumeSource struct {837 Server string `json:"server"`838 Path string `json:"path"`839 ReadOnly bool `json:"readOnly,omitempty"`840}841842// A claim volume names a PersistentVolumeClaim in the pod's namespace.843// The kubelet resolves the claim when it starts the pod, so the operator844// reads no claim and no PersistentVolume, and its RBAC gains no rule.845// Read-only here and on the mount, because a player never writes to a846// library.847type PersistentVolumeClaimVolumeSource struct {848 ClaimName string `json:"claimName"`849 ReadOnly bool `json:"readOnly,omitempty"`850}851852// An emptyDir is a directory the kubelet creates with the pod and853// deletes with it, which is all two containers need to share one854// socket.855//856// SizeLimit caps the volume. The IPC volume leaves it empty, so it marshals as857// {}. The art volume sets it, so a runaway decode cannot fill the node disk.858type EmptyDirVolumeSource struct {859 SizeLimit string `json:"sizeLimit,omitempty"`860}861862// The pod status fields the phase derivation reads. The container's863// terminated state is the specific half of a failure message,864// because it carries the exit code.865type PodStatus struct {866 Phase string `json:"phase,omitempty"`867 Reason string `json:"reason,omitempty"`868 Message string `json:"message,omitempty"`869 Conditions []PodCondition `json:"conditions,omitempty"`870 ContainerStatuses []ContainerStatus `json:"containerStatuses,omitempty"`871}872873// The scheduler explains a pod it cannot place on the PodScheduled874// condition, not in the pod's message, so a hold like a claim that875// does not exist is only readable here.876type PodCondition struct {877 Type string `json:"type"`878 Status string `json:"status"`879 Message string `json:"message,omitempty"`880}881882type ContainerStatus struct {883 Name string `json:"name"`884 State ContainerState `json:"state"`885}886887type ContainerState struct {888 Terminated *ContainerStateTerminated `json:"terminated,omitempty"`889}890891type ContainerStateTerminated struct {892 ExitCode int `json:"exitCode"`893 Reason string `json:"reason,omitempty"`894 Message string `json:"message,omitempty"`895}896897// The pod phases Kubernetes reports, named here so the derivation898// reads as the mapping it is.899const (900 podPending = "Pending"901 podRunning = "Running"902 podSucceeded = "Succeeded"903 podFailed = "Failed"904)
apiclient.go 95.9%
1package main23// This is a Kubernetes client written straight against the HTTP API,4// following liken's own (kubernetes/apiclient.go) and the audio5// operator's, for the same reason: the API is HTTPS that serves6// JSON, and client-go would bring informers, work queues, and7// generated types this program does not use.8//9// Every pod already holds what it needs to reach the API server.10// Kubernetes injects two environment variables that name the11// server's in-cluster address, and the kubelet mounts a CA12// certificate and a ServiceAccount token at a known path. Those five13// values are the whole of an in-cluster config.1415import (16 "bytes"17 "crypto/tls"18 "crypto/x509"19 "encoding/json"20 "errors"21 "fmt"22 "io"23 "net"24 "net/http"25 "os"26 "time"27)2829// serviceAccountDir is a variable so a test points it at a directory30// it controls.31var serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"3233// These two answers are values, not failures. An absent object is34// the normal state the caller answers by creating it, and a conflict35// is the normal state under optimistic concurrency that the caller36// answers by reading again.37var (38 ErrNotFound = errors.New("not found")39 ErrConflict = errors.New("conflict: something else wrote this object first")40)4142type Client struct {43 base string44 http *http.Client45 credentials string46}4748// NewClient builds a client from its three parts. InClusterClient49// reads them from the pod's environment; a test hands in an50// httptest server's base and no credentials.51func NewClient(base string, httpClient *http.Client, credentials string) *Client {52 return &Client{base: base, http: httpClient, credentials: credentials}53}5455func InClusterClient() (*Client, error) {56 host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")57 if host == "" || port == "" {58 return nil, fmt.Errorf("not running in a cluster: KUBERNETES_SERVICE_HOST unset")59 }6061 // The client trusts the cluster's own CA and not the system62 // store, so it accepts this API server and no other server that63 // answers on the address.64 caPEM, err := os.ReadFile(serviceAccountDir + "/ca.crt")65 if err != nil {66 return nil, fmt.Errorf("reading service account CA: %w", err)67 }68 roots := x509.NewCertPool()69 if !roots.AppendCertsFromPEM(caPEM) {70 return nil, fmt.Errorf("service account CA contains no certificates")71 }7273 return NewClient("https://"+host+":"+port, &http.Client{74 Transport: &http.Transport{75 TLSClientConfig: &tls.Config{RootCAs: roots},76 // Each timeout bounds the same failure: a server that77 // stops answering without sending anything. There is no78 // overall client timeout, because the watch is a request79 // whose response never ends, and a whole-request80 // deadline would cut the stream on schedule.81 DialContext: (&net.Dialer{82 Timeout: 5 * time.Second,83 KeepAlive: 10 * time.Second,84 }).DialContext,85 ResponseHeaderTimeout: 10 * time.Second,86 IdleConnTimeout: 30 * time.Second,87 },88 }, serviceAccountDir), nil89}9091// The two content types this client sends. A body is JSON,92// except an apply, which the API server reads as a partial object93// under the caller's field manager. The apply media type is named for94// YAML and accepts JSON, because YAML is its superset.95const (96 jsonContentType = "application/json"97 applyContentType = "application/apply-patch+yaml"98)99100// Do sends one request and hands back the open response, which is101// what the watch needs and what RequestJSON is built on.102func (c *Client) Do(method, path string, body []byte) (*http.Response, error) {103 return c.send(method, path, jsonContentType, body)104}105106func (c *Client) send(method, path, contentType string, body []byte) (*http.Response, error) {107 var reader io.Reader108 if body != nil {109 reader = bytes.NewReader(body)110 }111 req, err := http.NewRequest(method, c.base+path, reader)112 if err != nil {113 return nil, err114 }115 // The token is read from disk on every request. The mounted116 // token is short-lived and the kubelet refreshes the file as117 // each one nears expiry, so a client that held one in memory118 // would start getting 401s.119 if c.credentials != "" {120 token, err := os.ReadFile(c.credentials + "/token")121 if err != nil {122 return nil, fmt.Errorf("reading service account token: %w", err)123 }124 req.Header.Set("Authorization", "Bearer "+string(token))125 }126 req.Header.Set("Accept", "application/json")127 if body != nil {128 req.Header.Set("Content-Type", contentType)129 }130 return c.http.Do(req)131}132133// RequestJSON sends one request and decodes the answer, turning134// every non-2xx status into an error that carries the server's own135// message.136func (c *Client) RequestJSON(method, path string, body []byte, out any) error {137 return c.requestJSON(method, path, jsonContentType, body, out)138}139140func (c *Client) requestJSON(method, path, contentType string, body []byte, out any) error {141 resp, err := c.send(method, path, contentType, body)142 if err != nil {143 return err144 }145 defer drain(resp.Body)146147 if resp.StatusCode == http.StatusNotFound {148 return ErrNotFound149 }150 if resp.StatusCode == http.StatusConflict {151 return ErrConflict152 }153 if resp.StatusCode < 200 || resp.StatusCode > 299 {154 message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))155 return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, message)156 }157 if out == nil {158 return nil159 }160 return json.NewDecoder(resp.Body).Decode(out)161}162163// drain reads whatever the caller left in the body, then closes it.164// Go returns a connection to its pool only when the body reaches165// EOF, so an early close costs a fresh connection and TLS handshake,166// and reaches the server as a hang-up on a request it answered.167const maxDrain = 4 << 20168169func drain(body io.ReadCloser) {170 _, _ = io.Copy(io.Discard, io.LimitReader(body, maxDrain))171 _ = body.Close()172}173174// The collection paths. Plays are listed and watched across every175// namespace and read back per namespace, and the two Kubernetes176// kinds this operator writes live under their own groups' paths.177const (178 playsPath = "/apis/" + mediaAPIVersion + "/plays"179 playersPath = "/apis/" + mediaAPIVersion + "/players"180 remotesAllPath = "/apis/" + mediaAPIVersion + "/remotes"181 keymapsPath = "/apis/" + mediaAPIVersion + "/keymaps"182 mediaPrefsPath = "/apis/" + mediaAPIVersion + "/mediapreferences"183 mediaPrefix = "/apis/" + mediaAPIVersion + "/namespaces/"184 claimPrefix = "/apis/" + claimAPIVersion + "/namespaces/"185 slicesPath = "/apis/" + claimAPIVersion + "/resourceslices"186 displaysPath = "/apis/" + displayAPIVersion + "/displays"187 peripheralsPath = "/apis/" + peripheralAPIVersion + "/peripherals"188 podPrefix = "/api/v1/namespaces/"189 podsAllPath = "/api/v1/pods"190)191192// playbackPodsQuery narrows a pod list or a pod watch to the operator's own193// playback pods, by the label buildPod stamps on each one.194const playbackPodsQuery = "labelSelector=" + playbackLabelKey + "%3D" + playbackLabelValue195196func playPath(namespace, name string) string {197 return mediaPrefix + namespace + "/plays/" + name198}199200func playerPath(namespace, name string) string {201 return mediaPrefix + namespace + "/players/" + name202}203204func remotesPath(namespace string) string {205 return mediaPrefix + namespace + "/remotes"206}207208func remotePath(namespace, name string) string {209 return remotesPath(namespace) + "/" + name210}211212// keymapPath reads one Keymap by name. A Keymap is cluster-scoped, so213// the path carries no namespace, the way a StorageClass path carries214// none.215func keymapPath(name string) string {216 return keymapsPath + "/" + name217}218219func claimsPath(namespace string) string {220 return claimPrefix + namespace + "/resourceclaims"221}222223func podsPath(namespace string) string {224 return podPrefix + namespace + "/pods"225}226227// ListPlays answers a whole pass with one request, and the list's228// resourceVersion is where a watch resumes from.229func ListPlays(c *Client) (*PlayList, error) {230 list := &PlayList{}231 if err := c.RequestJSON(http.MethodGet, playsPath, nil, list); err != nil {232 return nil, err233 }234 return list, nil235}236237func GetPlay(c *Client, namespace, name string) (*Play, error) {238 play := &Play{}239 if err := c.RequestJSON(http.MethodGet, playPath(namespace, name), nil, play); err != nil {240 return nil, err241 }242 return play, nil243}244245// DeletePlay removes one Play once its window after finishing has passed.246// It is the one object a person created that this operator deletes, and it247// deletes it only in that one state. An already-absent Play is success,248// because a person may delete a Finished Play before its window ends.249func DeletePlay(c *Client, namespace, name string) error {250 err := c.RequestJSON(http.MethodDelete, playPath(namespace, name), nil, nil)251 if errors.Is(err, ErrNotFound) {252 return nil253 }254 return err255}256257// ListPlayers answers a pass with one request across every258// namespace, the same shape as ListPlays, because a Player's status259// is derived from the Plays that name it and the pass already holds260// every Play.261func ListPlayers(c *Client) (*PlayerList, error) {262 list := &PlayerList{}263 if err := c.RequestJSON(http.MethodGet, playersPath, nil, list); err != nil {264 return nil, err265 }266 return list, nil267}268269// PutPlayerStatus writes the Player's status subresource, the one270// write path this operator has onto a Player.271func PutPlayerStatus(c *Client, player *Player) (*Player, error) {272 body, err := json.Marshal(player)273 if err != nil {274 return nil, err275 }276 written := &Player{}277 path := playerPath(player.Metadata.Namespace, player.Metadata.Name) + "/status"278 if err := c.RequestJSON(http.MethodPut, path, body, written); err != nil {279 return nil, err280 }281 return written, nil282}283284func GetPlayer(c *Client, namespace, name string) (*Player, error) {285 player := &Player{}286 if err := c.RequestJSON(http.MethodGet, playerPath(namespace, name), nil, player); err != nil {287 return nil, err288 }289 return player, nil290}291292// PutPlayStatus writes through the status subresource, which is its293// own write path: this request can never touch a spec. The294// resourceVersion in the body is what makes the write conditional.295func PutPlayStatus(c *Client, play *Play) (*Play, error) {296 body, err := json.Marshal(play)297 if err != nil {298 return nil, err299 }300 written := &Play{}301 path := playPath(play.Metadata.Namespace, play.Metadata.Name) + "/status"302 if err := c.RequestJSON(http.MethodPut, path, body, written); err != nil {303 return nil, err304 }305 return written, nil306}307308// GetRemote reads one Remote by name in a namespace, the name a309// Player's spec.remotes entry carries.310func GetRemote(c *Client, namespace, name string) (*Remote, error) {311 remote := &Remote{}312 if err := c.RequestJSON(http.MethodGet, remotePath(namespace, name), nil, remote); err != nil {313 return nil, err314 }315 return remote, nil316}317318// PutRemoteStatus writes the Remote's status subresource, the one write319// path this operator has onto a Remote. The subresource split keeps it320// from ever rewriting the device selector a person declared.321func PutRemoteStatus(c *Client, remote *Remote) (*Remote, error) {322 body, err := json.Marshal(remote)323 if err != nil {324 return nil, err325 }326 written := &Remote{}327 path := remotePath(remote.Metadata.Namespace, remote.Metadata.Name) + "/status"328 if err := c.RequestJSON(http.MethodPut, path, body, written); err != nil {329 return nil, err330 }331 return written, nil332}333334// ListAllRemotes reads every Remote in the cluster in one request, the335// same shape as ListPlays, because the operator reconciles a standing336// pod for each Remote whatever namespace it lives in, and the list's337// resourceVersion is where the remotes watch resumes from.338func ListAllRemotes(c *Client) (*RemoteList, error) {339 list := &RemoteList{}340 if err := c.RequestJSON(http.MethodGet, remotesAllPath, nil, list); err != nil {341 return nil, err342 }343 return list, nil344}345346func GetKeymap(c *Client, name string) (*Keymap, error) {347 keymap := &Keymap{}348 if err := c.RequestJSON(http.MethodGet, keymapPath(name), nil, keymap); err != nil {349 return nil, err350 }351 return keymap, nil352}353354// ListKeymaps reads every Keymap in the cluster in one request, because355// the operator compiles and publishes each one on every pass, and the356// list's resourceVersion is where the keymaps watch resumes from.357func ListKeymaps(c *Client) (*KeymapList, error) {358 list := &KeymapList{}359 if err := c.RequestJSON(http.MethodGet, keymapsPath, nil, list); err != nil {360 return nil, err361 }362 return list, nil363}364365// GetMediaPreferences reads the household default by name. MediaPreferences is366// cluster-scoped, so the path carries no namespace. A missing default is367// ErrNotFound, which the resolver reads as a skipped tier.368func GetMediaPreferences(c *Client, name string) (*MediaPreferences, error) {369 prefs := &MediaPreferences{}370 if err := c.RequestJSON(http.MethodGet, mediaPrefsPath+"/"+name, nil, prefs); err != nil {371 return nil, err372 }373 return prefs, nil374}375376// ListMediaPreferences reads every MediaPreferences in one request. The list's377// resourceVersion is where the watch resumes from.378func ListMediaPreferences(c *Client) (*MediaPreferencesList, error) {379 list := &MediaPreferencesList{}380 if err := c.RequestJSON(http.MethodGet, mediaPrefsPath, nil, list); err != nil {381 return nil, err382 }383 return list, nil384}385386func GetResourceClaim(c *Client, namespace, name string) (*ResourceClaim, error) {387 claim := &ResourceClaim{}388 if err := c.RequestJSON(http.MethodGet, claimsPath(namespace)+"/"+name, nil, claim); err != nil {389 return nil, err390 }391 return claim, nil392}393394func CreateResourceClaim(c *Client, claim *ResourceClaim) (*ResourceClaim, error) {395 body, err := json.Marshal(claim)396 if err != nil {397 return nil, err398 }399 created := &ResourceClaim{}400 if err := c.RequestJSON(http.MethodPost, claimsPath(claim.Metadata.Namespace), body, created); err != nil {401 return nil, err402 }403 return created, nil404}405406// ListPlaybackPods reads the operator's playback pods across every407// namespace. The list's resourceVersion is where the pod watch begins.408func ListPlaybackPods(c *Client) (*PodList, error) {409 list := &PodList{}410 if err := c.RequestJSON(http.MethodGet, podsAllPath+"?"+playbackPodsQuery, nil, list); err != nil {411 return nil, err412 }413 return list, nil414}415416func GetPod(c *Client, namespace, name string) (*Pod, error) {417 pod := &Pod{}418 if err := c.RequestJSON(http.MethodGet, podsPath(namespace)+"/"+name, nil, pod); err != nil {419 return nil, err420 }421 return pod, nil422}423424func CreatePod(c *Client, pod *Pod) (*Pod, error) {425 body, err := json.Marshal(pod)426 if err != nil {427 return nil, err428 }429 created := &Pod{}430 if err := c.RequestJSON(http.MethodPost, podsPath(pod.Metadata.Namespace), body, created); err != nil {431 return nil, err432 }433 return created, nil434}435436// DeletePod removes one playback pod. An already-absent pod is437// success, because the graceful recreate deletes the pod before it438// creates the replacement, and a delete that races another pass must439// not fail.440func DeletePod(c *Client, namespace, name string) error {441 err := c.RequestJSON(http.MethodDelete, podsPath(namespace)+"/"+name, nil, nil)442 if errors.Is(err, ErrNotFound) {443 return nil444 }445 return err446}447448// Every driver's slices in one request. The operator reads449// them for the attributes of the devices its own claims hold, so one450// list a pass answers every unit.451func ListResourceSlices(c *Client) (*ResourceSliceList, error) {452 list := &ResourceSliceList{}453 if err := c.RequestJSON(http.MethodGet, slicesPath, nil, list); err != nil {454 return nil, err455 }456 return list, nil457}458459// A Display is cluster-scoped, so the path carries no460// namespace. A screen whose cluster runs no display-operator answers461// ErrNotFound, and the panel then stays lit.462func GetDisplay(c *Client, name string) (*Display, error) {463 display := &Display{}464 if err := c.RequestJSON(http.MethodGet, displaysPath+"/"+name, nil, display); err != nil {465 return nil, err466 }467 return display, nil468}469470// ListPeripherals reads every bonded device the bluetooth-operator471// publishes. A Peripheral is cluster-scoped, so the path carries no472// namespace, and the list's resourceVersion is where the peripherals473// watch resumes from.474func ListPeripherals(c *Client) (*PeripheralList, error) {475 list := &PeripheralList{}476 if err := c.RequestJSON(http.MethodGet, peripheralsPath, nil, list); err != nil {477 return nil, err478 }479 return list, nil480}481482// ApplyDisplayOverride writes spec.override and nothing else,483// under this operator's own field manager. A nil override applies an484// empty spec, and the API server then removes the block this manager485// owns, which is how the panel comes back.486func ApplyDisplayOverride(c *Client, name string, override *DisplayOverride) error {487 body, err := json.Marshal(&displayApply{488 APIVersion: displayAPIVersion,489 Kind: "Display",490 Metadata: ObjectMeta{Name: name},491 Spec: DisplaySpec{Override: override},492 })493 if err != nil {494 return err495 }496 path := displaysPath + "/" + name + "?fieldManager=" + displayFieldManager497 return c.requestJSON(http.MethodPatch, path, applyContentType, body, nil)498}499500// DeleteResourceClaim removes one playback claim. The operator deletes501// a claim only when a Player reshaped it, so the recreate builds the502// claim the current Player produces. An already-absent claim is503// success.504func DeleteResourceClaim(c *Client, namespace, name string) error {505 err := c.RequestJSON(http.MethodDelete, claimsPath(namespace)+"/"+name, nil, nil)506 if errors.Is(err, ErrNotFound) {507 return nil508 }509 return err510}
art.go 98.5%
1package main23// The bridge decodes the art the display cannot. overlay-add takes raw bgra,4// and mpv's Lua has no image decoder. So the display asks the bridge for a5// logo at a pixel size. The bridge reads the file, scales it, writes the bgra6// to the volume it shares with mpv, and answers with the path and the size.7// The scaler is a small bilinear resampler on the standard library, so the8// decoder needs no dependency and no cgo.910import (11 "encoding/json"12 "fmt"13 "image"14 _ "image/jpeg"15 _ "image/png"16 "io"17 "math"18 "net/http"19 "os"20 "path/filepath"21 "strconv"22 "strings"23)2425// One decoded logo, ready for overlay-add: the file the bridge wrote, and the26// size mpv needs to read it back.27type artBlob struct {28 path string29 width int30 height int31 stride int32}3334// One parsed art request. timeMs carries the scrub time for a trickplay35// request, and stays zero for every other kind, which needs only the box.36// The box is the bounds the logo, the cover, or the tile must fit inside.37type artRequest struct {38 kind string39 timeMs int40 width int41 height int42}4344// parseArtRequest reads a client-message as a request for art. The first45// argument names the request, so the bridge drops another script's broadcast.46// The rest depends on the kind: a logo and a cover carry a box, and a47// trickplay carries a time and a box.48func parseArtRequest(args []string) (artRequest, bool) {49 if len(args) < 4 || args[0] != artRequestMessage {50 return artRequest{}, false51 }52 switch args[1] {53 case artKindLogo, artKindAlbum:54 w, h, ok := parseBox(args[2], args[3])55 if !ok {56 return artRequest{}, false57 }58 return artRequest{kind: args[1], width: w, height: h}, true59 case artKindTrickplay:60 if len(args) < 5 {61 return artRequest{}, false62 }63 timeMs, err := strconv.Atoi(args[2])64 if err != nil || timeMs < 0 {65 return artRequest{}, false66 }67 w, h, ok := parseBox(args[3], args[4])68 if !ok {69 return artRequest{}, false70 }71 return artRequest{kind: artKindTrickplay, timeMs: timeMs, width: w, height: h}, true72 }73 return artRequest{}, false74}7576// parseBox reads a width and a height from two arguments. A value that is not77// a positive number fails the request, so a bad box decodes nothing.78func parseBox(widthArg, heightArg string) (w, h int, ok bool) {79 w, err := strconv.Atoi(widthArg)80 if err != nil || w <= 0 {81 return 0, 0, false82 }83 h, err = strconv.Atoi(heightArg)84 if err != nil || h <= 0 {85 return 0, 0, false86 }87 return w, h, true88}8990// serveArt answers one art request. It dispatches by kind, the logo to91// serveLogo, the trickplay tile to serveTrickplay, and the playing album's92// cover to serveAlbum.93func (c *commander) serveArt(args []string) {94 request, ok := parseArtRequest(args)95 if !ok {96 return97 }98 switch request.kind {99 case artKindLogo:100 c.serveLogo(request)101 case artKindTrickplay:102 c.serveTrickplay(request)103 case artKindAlbum:104 c.serveAlbum(request)105 }106}107108// serveLogo decodes the current item's logo to the box the display asks for,109// caches the blob by size, and replies. A size already decoded for the current110// item replies from the cache and decodes nothing.111func (c *commander) serveLogo(request artRequest) {112 kind, w, h := request.kind, request.width, request.height113 key := kind + ":" + strconv.Itoa(w) + "x" + strconv.Itoa(h)114 c.artMutex.Lock()115 item := c.artItem116 if blob, cached := c.artCache[key]; cached {117 c.artMutex.Unlock()118 c.replyArt(kind, blob)119 return120 }121 block := c.blockForItem(item)122 c.artMutex.Unlock()123124 logo := logoOf(block)125 if logo == "" {126 return127 }128129 blob, err := c.decodeLogo(item, logo, w, h)130 if err != nil {131 fmt.Fprintf(os.Stderr, "command: logo %q: %v\n", logo, err)132 return133 }134135 c.artMutex.Lock()136 if item != c.artItem {137 // The item swapped while the decode ran. Drop this blob, so the last138 // item's art does not stay on the shared volume.139 c.artMutex.Unlock()140 os.Remove(blob.path)141 return142 }143 if c.artCache == nil {144 c.artCache = map[string]artBlob{}145 }146 c.artCache[key] = blob147 c.artMutex.Unlock()148149 c.replyArt(kind, blob)150}151152// decodeLogo reads one logo, scales it into the box, and writes the bgra to153// the shared volume. An https logo is fetched. Any other value is a path the154// resolver rewrote under the media mount.155func (c *commander) decodeLogo(item int, logo string, w, h int) (artBlob, error) {156 reader, err := openArt(logo)157 if err != nil {158 return artBlob{}, err159 }160 defer reader.Close()161162 pixels, outW, outH, stride, err := scaleToBGRA(reader, w, h)163 if err != nil {164 return artBlob{}, err165 }166167 path := filepath.Join(c.artDir, fmt.Sprintf("logo-%d-%dx%d.bgra", item, w, h))168 if err := os.WriteFile(path, pixels, 0o644); err != nil {169 return artBlob{}, err170 }171 return artBlob{path: path, width: outW, height: outH, stride: stride}, nil172}173174// openArt opens a logo for reading. An https logo is a network fetch. Any175// other value is a file the pod mounts.176func openArt(logo string) (io.ReadCloser, error) {177 if strings.HasPrefix(logo, "https://") {178 response, err := http.Get(logo)179 if err != nil {180 return nil, err181 }182 if response.StatusCode != http.StatusOK {183 response.Body.Close()184 return nil, fmt.Errorf("fetch returned %s", response.Status)185 }186 return response.Body, nil187 }188 return os.Open(logo)189}190191// replyArt hands the display one ready blob over the mpv socket. The display192// registers artReplyMessage and places the blob with overlay-add.193func (c *commander) replyArt(kind string, blob artBlob) {194 c.command([]any{195 "script-message-to", displayClientName, artReplyMessage,196 kind, blob.path,197 strconv.Itoa(blob.width), strconv.Itoa(blob.height), strconv.Itoa(blob.stride),198 })199}200201// logoOf reads the logo path from one presentation block. An empty block, or202// one with no logo, has none.203func logoOf(block []byte) string {204 var presentation Presentation205 if err := json.Unmarshal(block, &presentation); err != nil {206 return ""207 }208 return presentation.Logo209}210211// scaleToBGRA decodes an image and scales it to fit within boxW by boxH,212// keeping the aspect ratio. It returns premultiplied bgra with stride 4*w, the213// one format overlay-add reads. Go's image package returns alpha-premultiplied214// color, and blending in that space keeps a transparent edge clean.215func scaleToBGRA(reader io.Reader, boxW, boxH int) (pixels []byte, w, h, stride int, err error) {216 source, _, err := image.Decode(reader)217 if err != nil {218 return nil, 0, 0, 0, err219 }220 return scaleRegionToBGRA(source, source.Bounds(), boxW, boxH)221}222223// scaleRegionToBGRA scales one rectangle of a decoded image to fit within boxW224// by boxH, keeping the aspect ratio. It returns premultiplied bgra with stride225// 4*w, the one format overlay-add reads. A logo passes the image's full bounds.226// A trickplay passes one cell of a sprite sheet, so the crop and the scale are227// one step.228func scaleRegionToBGRA(source image.Image, region image.Rectangle, boxW, boxH int) (pixels []byte, w, h, stride int, err error) {229 sw, sh := region.Dx(), region.Dy()230 if sw <= 0 || sh <= 0 {231 return nil, 0, 0, 0, fmt.Errorf("the image has no pixels")232 }233234 scale := math.Min(float64(boxW)/float64(sw), float64(boxH)/float64(sh))235 outW := max(1, int(math.Round(float64(sw)*scale)))236 outH := max(1, int(math.Round(float64(sh)*scale)))237 stride = 4 * outW238 pixels = make([]byte, stride*outH)239240 for y := 0; y < outH; y++ {241 // The half-pixel offset samples each output pixel at its own center,242 // so the scaled image does not drift half a pixel.243 fy := (float64(y)+0.5)/scale - 0.5244 for x := 0; x < outW; x++ {245 fx := (float64(x)+0.5)/scale - 0.5246 r, g, b, a := sampleBilinear(source, region, fx, fy)247 offset := y*stride + x*4248 pixels[offset+0] = b249 pixels[offset+1] = g250 pixels[offset+2] = r251 pixels[offset+3] = a252 }253 }254 return pixels, outW, outH, stride, nil255}256257// sampleBilinear reads the four source pixels around (fx, fy) and blends them,258// so a scaled edge is smooth. The coordinates are in the source's own pixels,259// measured from its bounds origin.260func sampleBilinear(source image.Image, bounds image.Rectangle, fx, fy float64) (r, g, b, a byte) {261 x0 := int(math.Floor(fx))262 y0 := int(math.Floor(fy))263 dx := fx - float64(x0)264 dy := fy - float64(y0)265266 r00, g00, b00, a00 := sampleClamped(source, bounds, x0, y0)267 r10, g10, b10, a10 := sampleClamped(source, bounds, x0+1, y0)268 r01, g01, b01, a01 := sampleClamped(source, bounds, x0, y0+1)269 r11, g11, b11, a11 := sampleClamped(source, bounds, x0+1, y0+1)270271 blend := func(v00, v10, v01, v11 float64) byte {272 top := v00 + (v10-v00)*dx273 bottom := v01 + (v11-v01)*dx274 value := top + (bottom-top)*dy275 // Go returns each channel in 0..65535, so the high byte is the 8-bit276 // value overlay-add reads.277 return byte(uint32(value+0.5) >> 8)278 }279 return blend(r00, r10, r01, r11),280 blend(g00, g10, g01, g11),281 blend(b00, b10, b01, b11),282 blend(a00, a10, a01, a11)283}284285// sampleClamped reads one source pixel. It clamps to the edge for a coordinate286// the blend reaches past the image. The channels are the alpha-premultiplied287// values the image package gives, each 0..65535.288func sampleClamped(source image.Image, bounds image.Rectangle, x, y int) (r, g, b, a float64) {289 if x < 0 {290 x = 0291 }292 if x > bounds.Dx()-1 {293 x = bounds.Dx() - 1294 }295 if y < 0 {296 y = 0297 }298 if y > bounds.Dy()-1 {299 y = bounds.Dy() - 1300 }301 ri, gi, bi, ai := source.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()302 return float64(ri), float64(gi), float64(bi), float64(ai)303}
artserve.go 0.0%
1package main23// serve-art runs the bridge's decode side on its own, against a plain mpv IPC4// socket. The pod runs the whole command sidecar, which also reports to the5// bus. A person iterating on the display locally wants the decode half with no6// cluster and no bus. So this mode dials a socket, reads the same presentation7// blocks the pod passes, and runs the same decode and serve code the sidecar8// runs. The local screenshots then show art decoded the way the pod decodes it.910import (11 "context"12 "fmt"13 "os"14 "os/signal"15 "syscall"16)1718// serve-art reads two variables on top of the presentation blocks. It reads a19// socket path, because a local mpv serves its socket somewhere other than the20// pod's fixed path. It reads an art directory, because a local run has no21// mounted volume. Both take a default, so the mode runs with neither set.22const (23 mpvSocketVariable = "MEDIA_MPV_SOCKET"24 artDirVariable = "MEDIA_ART_DIR"25)2627// runArtServe dials the socket and runs the decode side with a report sender28// that publishes nothing. The item tracking and the logo decode then run as29// they do in the pod, with no bus.30func runArtServe() {31 socket := os.Getenv(mpvSocketVariable)32 if socket == "" {33 socket = mpvSocketPath34 }35 artDir := os.Getenv(artDirVariable)36 if artDir == "" {37 artDir = artMountPath38 }3940 cmd := &commander{41 presentations: parsePresentations(os.Getenv(presentationsVariable)),42 artDir: artDir,43 }4445 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)46 defer stop()4748 conn, err := dialMPV(ctx, socket)49 if err != nil {50 fmt.Fprintf(os.Stderr, "serve-art: %v\n", err)51 return52 }53 defer conn.Close()54 defer context.AfterFunc(ctx, func() { conn.Close() })()5556 cmd.drive(ctx, conn, func(playReport) error { return nil })57}
basekeys.go 96.4%
1package main23// The base table, the layer under every Keymap. Linux splits input in4// two: the kernel and udev's hwdb normalise a device, so a scancode5// becomes KEY_VOLUMEUP, and each program binds those names to what6// they mean there. The base is this operator's half of the first7// layer. Every KEY_* code passes as itself with no row, so a Remote8// with no Keymap already works. The base binds only what the kernel9// cannot name as a key: the hat axes become the arrows, because a10// d-pad is not a button, and the south and east face buttons become11// enter and back, because every console reads them that way. Nothing12// else in the base binds a button.1314import "fmt"1516// The hat repeat cadence. A gamepad never autorepeats in the kernel,17// so the standing pod synthesises the repeat, and 400ms then 250ms is18// the cadence a person expects from a keyboard's arrows.19var baseHatRepeat = KeymapRepeat{Delay: "400ms", Interval: "250ms"}2021// The base itself, written as a Keymap so it compiles through the22// same path a cluster's Keymap does. This is the one place the23// project states what a controller means before anybody writes a24// row.25var baseKeymap = &Keymap{26 Metadata: ObjectMeta{Name: "base"},27 Spec: KeymapSpec{28 Buttons: []KeymapButton{29 {Press: "BTN_SOUTH", Key: "KEY_ENTER"},30 {Press: "BTN_EAST", Key: "KEY_BACK"},31 },32 Axes: []KeymapAxis{33 {Axis: "ABS_HAT0Y", Value: -1, Key: "KEY_UP", Repeat: &baseHatRepeat},34 {Axis: "ABS_HAT0Y", Value: 1, Key: "KEY_DOWN", Repeat: &baseHatRepeat},35 {Axis: "ABS_HAT0X", Value: -1, Key: "KEY_LEFT", Repeat: &baseHatRepeat},36 {Axis: "ABS_HAT0X", Value: 1, Key: "KEY_RIGHT", Repeat: &baseHatRepeat},37 },38 },39}4041// baseKeys is the compiled base. A failure here panics at start,42// because the rows are compiled into the binary: a name this build43// cannot resolve is a fault in this file and not in a cluster's44// object.45var baseKeys = mustCompileBase()4647func mustCompileBase() []compiledBinding {48 rows, err := compileRows(baseKeymap)49 if err != nil {50 panic(fmt.Sprintf("the base key table does not compile: %v", err))51 }52 return rows53}5455// compileTable is the whole fold: the base first, then the Remote's56// Keymap over it, one row replacing the base row for the same57// control. A Remote with no Keymap gets the base alone. The order is58// stable, because the operator compares the published payload against59// the last one it wrote.60func compileTable(keymap *Keymap) ([]compiledBinding, error) {61 table := make([]compiledBinding, len(baseKeys))62 copy(table, baseKeys)63 if keymap == nil {64 return table, nil65 }66 rows, err := compileRows(keymap)67 if err != nil {68 return nil, err69 }70 for _, row := range rows {71 if index := indexOfControl(table, row); index >= 0 {72 table[index] = row73 continue74 }75 table = append(table, row)76 }77 return table, nil78}7980// indexOfControl finds the row that names the same control. A button81// row always carries value 1, and an axis row carries the direction,82// so the three fields together are the control's identity.83func indexOfControl(table []compiledBinding, row compiledBinding) int {84 for index, held := range table {85 if held.EventType == row.EventType && held.Code == row.Code && held.Value == row.Value {86 return index87 }88 }89 return -190}9192// lookupKey is the match the standing pod runs on every event. The93// value is normalised for a key and exact for an axis: a key's row94// states the press, and the release and the autorepeat carry the same95// meaning under the same name, while a hat's two directions are two96// rows on one code.97func lookupKey(table []compiledBinding, event inputEvent) (compiledBinding, bool) {98 value := event.Value99 if event.Type == evKey {100 value = 1101 }102 for _, row := range table {103 if row.EventType == event.Type && row.Code == event.Code && row.Value == value {104 return row, true105 }106 }107 return compiledBinding{}, false108}
bus.go 90.6%
1package main23// The bus client is one live TCP connection to the broker, with a4// reader, a single writer, and a keepalive timer, and it reconnects5// with backoff whenever any of the three fails. Every mode of this6// operator that speaks to the broker holds one Bus and lets it manage7// the connection, so no caller writes the socket directly.8//9// All writes go through one goroutine and one channel, because two10// goroutines writing one TCP connection would interleave their bytes11// and corrupt a packet. A publish or a subscribe from any goroutine12// enqueues a finished frame, and the writer sends it.1314import (15 "bufio"16 "context"17 "net"18 "sort"19 "sync"20 "time"21)2223// The keepalive the client asks the broker for, and the queue the24// writer drains. The client sends a PINGREQ once the connection has25// been idle longer than half the keepalive, so the broker never26// reaches the keepalive without hearing from the client. The queue is27// bounded, and a publish that would overflow it is dropped, which is28// correct at QoS 0.29const (30 busKeepalive = 3031 busQueueDepth = 6432)3334// The reconnect backoff bounds. The client waits busMinBackoff after35// the first failure and doubles the wait up to busMaxBackoff, so a36// broker that is down does not become a tight reconnect loop. Both are37// variables so a test drives a reconnect in milliseconds.38var (39 busMinBackoff = time.Second40 busMaxBackoff = 30 * time.Second41)4243// busHandler receives one inbound message's topic and payload. The Bus44// calls it on the reader goroutine, so a handler that blocks holds up45// every later message on the connection.46type busHandler func(topic string, payload []byte)4748// busWill is the MQTT Last Will the client names at connect time. The49// broker publishes it on any disconnect the client does not make50// cleanly, which is how a killed pod's status is marked offline.51type busWill struct {52 Topic string53 Payload []byte54 Retained bool55}5657// Bus holds the connection's parts and the state that outlives one58// connection: the remembered subscriptions and the packet identifier59// counter. out is the current connection's write queue, or nil while60// disconnected, and the mutex guards both it and the fields around it.61type Bus struct {62 clientID string63 will *busWill64 onConnect func(*Bus)65 handler busHandler66 dial func(context.Context) (net.Conn, error)6768 mutex sync.Mutex69 filters map[string]struct{}70 out chan []byte71 packetID uint1672}7374// newBus builds a client that dials the address over TCP. The address,75// the client identifier, the will, the connect callback, and the76// inbound handler are fixed for the client's life; the connection they77// drive is not.78func newBus(address, clientID string, will *busWill, onConnect func(*Bus), handler busHandler) *Bus {79 bus := &Bus{80 clientID: clientID,81 will: will,82 onConnect: onConnect,83 handler: handler,84 filters: map[string]struct{}{},85 }86 bus.dial = func(ctx context.Context) (net.Conn, error) {87 dialer := &net.Dialer{}88 return dialer.DialContext(ctx, "tcp", address)89 }90 return bus91}9293// Run holds the connection open until ctx ends. It dials, connects,94// and serves one session, then waits a backoff and dials again. A95// session that reached a CONNACK resets the backoff to its floor, so a96// connection that drops after an hour reconnects at once, while a97// broker that never answers is retried ever more slowly.98func (b *Bus) Run(ctx context.Context) {99 backoff := busMinBackoff100 for ctx.Err() == nil {101 connected := b.runSession(ctx)102 if ctx.Err() != nil {103 return104 }105 if connected {106 backoff = busMinBackoff107 }108 select {109 case <-ctx.Done():110 return111 case <-time.After(backoff):112 }113 if !connected {114 backoff *= 2115 if backoff > busMaxBackoff {116 backoff = busMaxBackoff117 }118 }119 }120}121122// runSession dials, completes the CONNECT handshake, and serves the123// connection until its reader or writer fails. It returns whether the124// handshake reached a CONNACK, which is what tells Run to reset the125// backoff.126func (b *Bus) runSession(parent context.Context) (connected bool) {127 conn, err := b.dial(parent)128 if err != nil {129 return false130 }131 defer conn.Close()132133 if _, err := conn.Write(encodeConnect(b.clientID, busKeepalive, b.will)); err != nil {134 return false135 }136 reader := bufio.NewReader(conn)137 first, body, err := readPacket(reader)138 if err != nil || first&0xF0 != mqttConnack {139 return false140 }141 if err := parseConnack(body); err != nil {142 return false143 }144145 ctx, cancel := context.WithCancel(parent)146 defer cancel()147 // The reader blocks in Read until the broker writes or the148 // connection closes. Closing the connection when the session ends149 // is what unblocks a reader waiting on a silent broker.150 defer context.AfterFunc(ctx, func() { conn.Close() })()151152 out := make(chan []byte, busQueueDepth)153 b.mutex.Lock()154 b.out = out155 b.mutex.Unlock()156 defer func() {157 b.mutex.Lock()158 b.out = nil159 b.mutex.Unlock()160 }()161162 var writing sync.WaitGroup163 writing.Add(1)164 go func() {165 defer writing.Done()166 // A write failure ends the session, so the reader stops too.167 defer cancel()168 b.writeLoop(ctx, conn, out)169 }()170171 // The remembered subscriptions go out first, so the broker is172 // delivering again before onConnect re-publishes any retained173 // state.174 b.resubscribe(out)175 if b.onConnect != nil {176 b.onConnect(b)177 }178179 b.readLoop(reader)180 cancel()181 writing.Wait()182 return true183}184185// writeLoop is the one goroutine that writes the connection. It sends186// each queued frame and, when no frame has gone out for half the187// keepalive, sends a PINGREQ so the broker hears from the client188// before the keepalive elapses.189func (b *Bus) writeLoop(ctx context.Context, conn net.Conn, out <-chan []byte) {190 idle := time.Duration(busKeepalive) * time.Second / 2191 ticker := time.NewTicker(idle)192 defer ticker.Stop()193 last := time.Now()194 for {195 select {196 case <-ctx.Done():197 return198 case frame := <-out:199 if _, err := conn.Write(frame); err != nil {200 return201 }202 last = time.Now()203 case <-ticker.C:204 if time.Since(last) >= idle {205 if _, err := conn.Write(encodePingreq()); err != nil {206 return207 }208 last = time.Now()209 }210 }211 }212}213214// readLoop reads whole packets and delivers each inbound PUBLISH to the215// handler. A SUBACK and a PINGRESP are read and dropped: the client216// asks for QoS 0, so a SUBACK carries nothing to act on, and a217// PINGRESP only proves the broker is alive, which a successful read218// already shows. Any read error ends the loop and the session.219func (b *Bus) readLoop(reader *bufio.Reader) {220 for {221 first, body, err := readPacket(reader)222 if err != nil {223 return224 }225 if first&0xF0 == mqttPublish {226 topic, payload, ok := parsePublish(body)227 if ok && b.handler != nil {228 b.handler(topic, payload)229 }230 }231 }232}233234// Publish enqueues a QoS 0 PUBLISH from any goroutine. While the client235// is disconnected the queue does not exist and the publish is dropped,236// which is correct at QoS 0: a caller re-publishes its retained state237// from onConnect, so the broker holds the current value again within238// the reconnect.239func (b *Bus) Publish(topic string, payload []byte, retained bool) {240 b.mutex.Lock()241 out := b.out242 b.mutex.Unlock()243 if out == nil {244 return245 }246 select {247 case out <- encodePublish(topic, payload, retained):248 default:249 }250}251252// Subscribe remembers the filter and sends it if the client is253// connected. The remembered set is what runSession re-sends on every254// reconnect, so a subscription outlives the connection it was made on.255func (b *Bus) Subscribe(filter string) {256 b.mutex.Lock()257 b.filters[filter] = struct{}{}258 out := b.out259 frame := encodeSubscribe(b.nextPacketID(), filter)260 b.mutex.Unlock()261 if out == nil {262 return263 }264 select {265 case out <- frame:266 default:267 }268}269270// resubscribe sends every remembered filter on a fresh connection. The271// filters go out in sorted order so the frames a reconnect writes are272// the same every time, which keeps a test deterministic.273func (b *Bus) resubscribe(out chan<- []byte) {274 b.mutex.Lock()275 filters := make([]string, 0, len(b.filters))276 for filter := range b.filters {277 filters = append(filters, filter)278 }279 frames := make([][]byte, 0, len(filters))280 sort.Strings(filters)281 for _, filter := range filters {282 frames = append(frames, encodeSubscribe(b.nextPacketID(), filter))283 }284 b.mutex.Unlock()285 for _, frame := range frames {286 select {287 case out <- frame:288 default:289 }290 }291}292293// nextPacketID hands out the identifier a SUBSCRIBE carries and its294// SUBACK echoes. It skips zero, which the protocol reserves. The caller295// holds the mutex.296func (b *Bus) nextPacketID() uint16 {297 b.packetID++298 if b.packetID == 0 {299 b.packetID = 1300 }301 return b.packetID302}
claims.go 85.5%
1package main23// This file writes the manifest the design says nobody should have4// to write again: one claim that holds the whole player, request by5// request, with the parameter blocks a person wrote by hand in the6// days of the demo.78import (9 "encoding/json"10 "errors"11 "strconv"12 "strings"13)1415// The request names are the roles, and they are the names the16// container's resources.claims refer to. One claim with named17// requests beats one claim per device because the set allocates as a18// unit: the scheduler finds one machine that satisfies every request,19// or the pod parks Pending with one story to read.20//21// A remote's request is named for the Remote it claims, behind a22// prefix no player role starts with. The standing remote pod's claim23// uses the name, because that pod holds the controller now.24const (25 screenRequest = "screen"26 renderRequest = "render"27 audioRequestPrefix = "audio"28 remoteRequestPrefix = "remote-"29)3031// remoteDisconnectedTaint is the taint the hardware operator sets when a32// controller disconnects. The standing remote reader pod tolerates it33// forever, because a controller sleeps whenever a person puts it down and34// the reader must wait for it. The playback claim tolerates no disconnect35// taint at all, so a display or a speaker that leaves evicts the playback36// pod, and the operator recreates it at the film's place.37const (38 remoteDisconnectedTaint = "bluetooth.liken.sh/disconnected"39)4041func remoteRequestName(remote string) string {42 return remoteRequestPrefix + remote43}4445// tolerateForever names the taint's effect, NoExecute, and has no46// tolerationSeconds. The effect must be stated: the device taint47// eviction controller applies a toleration only when the toleration's48// own effect is NoExecute, and a toleration with no effect tolerates49// the taint for scheduling alone, so the pod schedules and is evicted50// at once. The missing seconds are what keep a sleeping controller51// from evicting the pod later, because a controller sleeps whenever a52// person puts it down and the reader must wait for it.53func tolerateForever(taint string) []DeviceToleration {54 return []DeviceToleration{{55 Key: taint,56 Operator: "Exists",57 Effect: "NoExecute",58 }}59}6061// claimName is the Play's name plus its job, so a person reading62// either object finds the other.63func claimName(play string) string {64 return play + "-devices"65}6667// playOwner is the ownerReference that makes deleting the Play the68// whole teardown: the garbage collector deletes the claim and the pod69// the Play owns. The operator deletes a pod or a claim itself only to70// recreate a running Play's pod after its Player reshaped it.71func playOwner(play *Play) OwnerReference {72 return OwnerReference{73 APIVersion: mediaAPIVersion,74 Kind: "Play",75 Name: play.Metadata.Name,76 UID: play.Metadata.UID,77 Controller: true,78 }79}8081// buildClaim turns one Player into one claim, requests in role order:82// screen, sinks, render. A Player without a display or without a render83// node yields a claim without that request, and the player program84// plays without one, which is what an audio-only unit is.85//86// The playback claim holds the player's own devices and no controller.87// A Remote reconciles into its own standing pod, which holds the88// controller's claim, so a controller on one machine and a display on89// another still pair.90func buildClaim(play *Play, player *Player) *ResourceClaim {91 claim := &ResourceClaim{92 APIVersion: claimAPIVersion,93 Kind: "ResourceClaim",94 Metadata: ObjectMeta{95 Name: claimName(play.Metadata.Name),96 Namespace: play.Metadata.Namespace,97 OwnerReferences: []OwnerReference{playOwner(play)},98 },99 }100 if player.Spec.Display != nil {101 claim.add(screenRequest, *player.Spec.Display, nil)102 }103 for index, sink := range player.Spec.Sinks {104 claim.add(audioRequestPrefix+strconv.Itoa(index), sink, nil)105 }106 if player.Spec.Render != nil {107 // The render node takes no toleration, because a GPU does not108 // come and go while the machine runs.109 claim.add(renderRequest, *player.Spec.Render, nil)110 }111 return claim112}113114// add turns one device into a request and, when the Player states115// parameters for it, one opaque config block aimed at that request116// alone, so a codec never lands on a screen.117func (c *ResourceClaim) add(name string, device PlayerDevice, tolerations []DeviceToleration) {118 request := DeviceRequest{119 Name: name,120 Exactly: &ExactDeviceRequest{121 DeviceClassName: device.Class,122 // ExactCount with a count of one, because a role is one123 // piece of equipment.124 AllocationMode: "ExactCount",125 Count: 1,126 },127 }128 // An empty selector omits the list rather than sending an empty129 // expression, because a class that already names one kind of130 // device needs no CEL, and the API server refuses an expression131 // with nothing in it.132 if device.Selector != "" {133 request.Exactly.Selectors = []DeviceSelector{{CEL: &CELDeviceSelector{Expression: device.Selector}}}134 }135 request.Exactly.Tolerations = tolerations136 c.Spec.Devices.Requests = append(c.Spec.Devices.Requests, request)137138 if device.Parameters == nil {139 return140 }141 // The parameters travel as the Player wrote them. This operator142 // never reads a driver's vocabulary; the driver validates its143 // own block at the allocation.144 values := device.Parameters.Values145 if len(values) == 0 {146 values = json.RawMessage("{}")147 }148 c.Spec.Devices.Config = append(c.Spec.Devices.Config, DeviceClaimConfiguration{149 Requests: []string{name},150 Opaque: &OpaqueDeviceConfiguration{151 Driver: device.Parameters.Driver,152 Parameters: values,153 },154 })155}156157// claimRequests lists the request names in claim order, which is what158// the container's resources.claims list repeats. The playback claim159// holds the player's roles alone, so every request is the player160// container's.161func claimRequests(claim *ResourceClaim) []string {162 names := make([]string, 0, len(claim.Spec.Devices.Requests))163 for _, request := range claim.Spec.Devices.Requests {164 names = append(names, request.Name)165 }166 return names167}168169// claimHasSink reports whether this claim requests a speaker. The170// claim carries one request per sink the Player states, and none for171// a Player that states no sink, so it is the speaker gate the pod172// builder reads without holding the Player.173func claimHasSink(claim *ResourceClaim) bool {174 for _, request := range claim.Spec.Devices.Requests {175 if strings.HasPrefix(request.Name, audioRequestPrefix) {176 return true177 }178 }179 return false180}181182// ensureClaim creates the playback claim when none exists and keeps an183// existing one. A 409 on the create is success, because another pass,184// or another copy of this operator, created the same claim first. The185// graceful recreate deletes a claim a Player reshaped before it calls186// this, so ensureClaim never updates a claim in place.187func ensureClaim(c *Client, claim *ResourceClaim) error {188 _, err := GetResourceClaim(c, claim.Metadata.Namespace, claim.Metadata.Name)189 if err == nil {190 return nil191 }192 if !errors.Is(err, ErrNotFound) {193 return err194 }195 if _, err := CreateResourceClaim(c, claim); err != nil && !errors.Is(err, ErrConflict) {196 return err197 }198 return nil199}200201// claimDiverged reports whether the claim the current Player produces202// differs from the one in the cluster. An absent claim counts as203// diverged, so the recreate creates it.204func (o *operator) claimDiverged(desired *ResourceClaim) (bool, error) {205 current, err := GetResourceClaim(o.client, desired.Metadata.Namespace, desired.Metadata.Name)206 if errors.Is(err, ErrNotFound) {207 return true, nil208 }209 if err != nil {210 return false, err211 }212 same, err := sameClaimSpec(current.Spec, desired.Spec)213 if err != nil {214 return false, err215 }216 return !same, nil217}218219// sameClaimSpec compares two claim specs by their marshaled form. It220// reads only the fields this operator's own types model, because the221// client drops every field it does not know when it reads the stored222// claim, so a field the API server adds does not read as a change.223func sameClaimSpec(current, desired ResourceClaimSpec) (bool, error) {224 was, err := json.Marshal(current)225 if err != nil {226 return false, err227 }228 wants, err := json.Marshal(desired)229 if err != nil {230 return false, err231 }232 return string(was) == string(wants), nil233}
codes.go 98.1%
1package main23// A controller declares every code it can report in its nodes'4// capability bitmaps, readable the moment a node opens and complete5// with no button pressed. The standing pod publishes that declared set6// retained, this desk folds it in, and the reconcile pass reports the7// gap, the declared controls the compiled table maps to nothing, on the8// Remote's status. The gap is derived on every pass and never9// accumulated, so erasing the status loses nothing.1011import (12 "slices"13 "sync"14)1516// remoteCodes is the document the standing pod publishes retained at17// every node open: the key codes and the hat axes its nodes declare.18// It carries numbers alone, because the names are the API and the19// numbers are the wire.20type remoteCodes struct {21 Keys []uint16 `json:"keys,omitempty"`22 Axes []uint16 `json:"axes,omitempty"`23}2425// codesDesk is the boundary between the codes topic and the reconcile26// loop, shaped like the report desk beside it: the bus thread folds27// messages in, and the pass reads one answer out.28type codesDesk struct {29 mutex sync.Mutex30 declared map[string]remoteCodes31 online map[string]bool32 wake chan<- struct{}33}3435func newCodesDesk(wake chan<- struct{}) *codesDesk {36 return &codesDesk{37 declared: map[string]remoteCodes{},38 online: map[string]bool{},39 wake: wake,40 }41}4243// setCodes folds one controller's document in. A document that differs44// from the one held wakes the loop; a repeat, which every reconnect45// delivers, wakes nothing.46func (c *codesDesk) setCodes(key string, codes remoteCodes) {47 c.mutex.Lock()48 previous, had := c.declared[key]49 c.declared[key] = codes50 c.mutex.Unlock()51 if !had || !sameCodes(previous, codes) {52 poke(c.wake)53 }54}5556// clear drops one controller's document. The pod publishes the empty57// retained payload when its controller's nodes vanish, so the desk58// holds nothing for a controller that slept.59func (c *codesDesk) clear(key string) {60 c.mutex.Lock()61 _, had := c.declared[key]62 delete(c.declared, key)63 c.mutex.Unlock()64 if had {65 poke(c.wake)66 }67}6869// setAvailability records whether the standing pod that reports one70// controller is up, from the availability topic the pod names as its71// Last Will.72func (c *codesDesk) setAvailability(key string, online bool) {73 c.mutex.Lock()74 previous, had := c.online[key]75 c.online[key] = online76 c.mutex.Unlock()77 if !had || previous != online {78 poke(c.wake)79 }80}8182// codesFor returns what one controller declares, and whether the desk83// holds an answer. A controller whose pod is offline declares nothing,84// because a retained document outlives the pod that wrote it.85func (c *codesDesk) codesFor(key string) (remoteCodes, bool) {86 c.mutex.Lock()87 defer c.mutex.Unlock()88 codes, held := c.declared[key]89 if !held {90 return remoteCodes{}, false91 }92 if online, heard := c.online[key]; heard && !online {93 return remoteCodes{}, false94 }95 return codes, true96}9798// retain shrinks the desk to the Remotes the cluster still holds, so a99// long-running operator keeps no entry for a deleted Remote.100func (c *codesDesk) retain(live map[string]bool) {101 c.mutex.Lock()102 defer c.mutex.Unlock()103 for key := range c.declared {104 if !live[key] {105 delete(c.declared, key)106 }107 }108 for key := range c.online {109 if !live[key] {110 delete(c.online, key)111 }112 }113}114115func sameCodes(current, arriving remoteCodes) bool {116 return slices.Equal(current.Keys, arriving.Keys) &&117 slices.Equal(current.Axes, arriving.Axes)118}119120// The two words status.unbound uses for an entry's event type.121const (122 unboundKey = "key"123 unboundAxis = "abs"124)125126// unboundCodes is the gap the status reports: every declared control127// the table maps to nothing. A key code passes as itself, so it is128// unbound only where a row drops it with none or the kernel gives it129// no name at all. A hat axis is unbound where no row of it names a130// key, which the base makes rare. The gap is by code, so a row on one131// direction of a hat binds the whole axis. It is derived on every132// pass, so it empties as the table grows.133func unboundCodes(declared remoteCodes, table []compiledBinding) []UnboundCode {134 dropped := map[uint16]bool{}135 named := map[uint16]bool{}136 for _, row := range table {137 switch row.EventType {138 case evKey:139 if row.Key == keyNone {140 dropped[row.Code] = true141 }142 case evAbs:143 if row.Key != keyNone {144 named[row.Code] = true145 }146 }147 }148 var unbound []UnboundCode149 for _, code := range declared.Keys {150 if !dropped[code] && keyCodeNames[code] != "" {151 continue152 }153 unbound = append(unbound, UnboundCode{154 Code: code, Name: keyCodeNames[code], Type: unboundKey})155 }156 for _, code := range declared.Axes {157 if named[code] {158 continue159 }160 unbound = append(unbound, UnboundCode{161 Code: code, Name: axisCodeNames[code], Type: unboundAxis})162 }163 return unbound164}
command.go 82.9%
1package main23// The command sidecar is the playback pod's one owner of mpv's IPC4// socket, a native sidecar beside mpv. It subscribes to the Play's5// commands topic, writes each named command to mpv through the JSON IPC6// socket on the emptyDir the two containers share, and reads mpv's7// property changes back off the same socket to publish the status and8// availability. It holds no API credentials and reaches the control9// plane only through the operator's subscription to the bus.10//11// The sidecar reads two kinds of input. It reads the events topic of12// each controller the unit names, gated on the focus mark, and binds13// the kernel's key names itself in keybindings.go. And it reads the14// commands topic, which stays the surface any other program publishes15// to, so it answers a phone or a Home Assistant integration the same16// way it answers a gamepad. The report carries no API object: the Play17// it belongs to is named by the topic, not by the body.1819import (20 "context"21 "encoding/json"22 "fmt"23 "image"24 "math"25 "net"26 "os"27 "os/signal"28 "sync"29 "syscall"30 "time"31)3233// reportInterval is the ceiling on how often the command sidecar34// publishes a position while it advances. mpv sends a time-pos event35// several times a second, and one report is a small QoS 0 message, so36// the bus carries a live position at one report a second. The Play37// resource does not move that fast: the operator wakes its reconcile38// loop only on a pause or an item change, and a bare position advance39// waits for the backstop tick. So the bus is the live plane and the40// resource is the throttled one, and a consumer that wants a smooth41// position reads the bus.42var reportInterval = 1 * time.Second4344// busFlushGrace is how long the command sidecar holds the bus open45// after it publishes the two closing messages, so the writer goroutine46// drains them before the process exits. The messages are QoS 0 and47// carry no ack, so this window is the only signal the sidecar has that48// they left. The empty status is the one that needs the window: the49// Last Will publishes offline on an unclean exit, but nothing except50// this publish clears the retained report a finished Play would51// otherwise leave.52var busFlushGrace = 500 * time.Millisecond5354// commander holds the command sidecar's two sides: the connection to55// mpv that both the reporter and the command handler write, and the56// last-known report the connect callback re-publishes on a reconnect.57// Two goroutines write mpv's socket, so mpvMutex serializes them: a58// command and an observe request must not interleave their bytes. One59// goroutine reads the last report and another writes it, so reportMutex60// covers that pair.61type commander struct {62 statusTopic string63 availabilityTopic string64 commandsTopic string6566 // The unit's volume topic, empty for a Player with no sinks.67 // Empty is the speaker gate: the sidecar subscribes to no level,68 // applies none, and answers no volume press.69 volumeTopic string7071 // The unit's controllers, keyed by the events topic each one72 // publishes on, and the Player this Play runs on, which is the value73 // a mark must hold for a press to act. A pod for a Play whose Player74 // names no Remote holds none of this and answers no press.75 remotes map[string]playRemote76 playerName string7778 // The last mark each controller's focus topic delivered, keyed by79 // that controller's events topic. It takes a lock of its own,80 // because a press reads it while the bus reader writes it.81 focusMu sync.Mutex82 marks map[string]string8384 bus *Bus8586 // The items' presentation blocks in playlist order, baked into the87 // pod. Index i is item i's block text, which the sidecar forwards to88 // the display as the playlist reaches each item.89 presentations []json.RawMessage9091 mpvMutex sync.Mutex92 mpv net.Conn9394 reportMutex sync.Mutex95 lastReport playReport96 haveReport bool9798 // The last state the volume topic delivered, and whether one99 // arrived at all. A press computes from this and never from what100 // mpv reports, which keeps a held button from becoming its own101 // echo. The bus reader writes it and a press reads it, so it102 // takes a lock of its own.103 volumeMutex sync.Mutex104 volume volumeState105 haveVolume bool106107 // volumeCaughtUp marks that this bus session has already108 // delivered a level. The first message of a session is the109 // broker's retained catch-up, a restore and not a press, so it110 // applies silently and the display draws no indicator at pod111 // start. Every message after it signals the display.112 volumeCaughtUp bool113114 // ended is set once any of the three endings has happened. It is held115 // rather than sent and forgotten, so every later report of this run116 // carries the mark too and a reconnect re-publishes it. It sits under117 // reportMutex because the ending arrives on the message goroutine or118 // on the run's own goroutine, and the reporter reads it on each send.119 ended bool120121 // Where the bridge writes decoded bgra. It is the shared art volume in the122 // pod, and a local directory under the harness.123 artDir string124125 // artItem is the item the shared art volume holds art for. artCache maps a126 // decoded size to its blob. Both sit under artMutex, because the reporter127 // goroutine swaps the item while the art goroutine reads and writes the128 // cache.129 artMutex sync.Mutex130 artItem int131 artCache map[string]artBlob132133 // The one decoded sheet the bridge holds. A scrub within one sheet crops134 // every tile from this image, so it reads no new file.135 trickSheet image.Image136 trickSheetKey string137138 // The last tile written for the current item. A request that maps to the139 // same tile replies with this blob and crops nothing, so the overlay does140 // not churn.141 trickHave bool142 trickItem int143 trickIdx int144 trickBlob artBlob145146 // The prior tile file, kept one step longer than the current one. mpv places147 // a tile on a later turn than the reply that named it, so that file must148 // still exist when mpv maps it. The bridge removes a tile only once a second,149 // newer tile has replaced it.150 trickHavePrev bool151 trickPrev artBlob152153 // The playlist as the bridge resolved it: the file names mpv reported,154 // and the art tier each item resolved to.155 playlistFiles []string156 tracks []trackEntry157158 // The one album request the bridge holds, nil when it holds none. The159 // display asks for the cover as soon as it reads the item's block, and160 // mpv reports the playing item before it reports the playlist, so the161 // request can arrive in the moment between, when the bridge does not yet162 // know what the item's art is. A newer request replaces an older one,163 // because only the latest box is still on the screen.164 pendingAlbum *artRequest165}166167// runCommand connects to the bus, drives mpv's IPC socket, and reports168// the run. It returns when mpv's socket closes or the kubelet's grace169// signal arrives, and exits zero: the command sidecar is a native170// sidecar, and mpv's own exit code is the pod's outcome.171func runCommand() {172 namespace := os.Getenv(playNamespaceVariable)173 name := os.Getenv(playNameVariable)174 busAddress := os.Getenv(busAddressVariable)175 base := os.Getenv(topicBaseVariable)176 if base == "" {177 base = defaultTopicBase178 }179180 // The kernel runs no default action for a signal sent to PID 1, and181 // the command sidecar is its container's PID 1. The signal context182 // ends the report side on the kubelet's SIGTERM, which is the same183 // end mpv's closed socket gives.184 runCtx, stopRun := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)185 defer stopRun()186187 cmd := &commander{188 statusTopic: playStatusTopic(base, namespace, name),189 availabilityTopic: playAvailabilityTopic(base, namespace, name),190 commandsTopic: playCommandsTopic(base, namespace, name),191 volumeTopic: os.Getenv(playerVolumeTopicVariable),192 presentations: parsePresentations(os.Getenv(presentationsVariable)),193 artDir: artMountPath,194 playerName: os.Getenv(playerNameVariable),195 remotes: playRemoteMap(196 os.Getenv(remoteEventsTopicsVariable),197 os.Getenv(remoteFocusTopicsVariable)),198 marks: map[string]string{},199 }200201 // The bus runs on its own context, not the signal context, so the202 // two closing publishes still have a live connection to leave on203 // after the grace signal ends the report side.204 busCtx, stopBus := context.WithCancel(context.Background())205 cmd.bus = newBus(busAddress, "play-"+namespace+"-"+name,206 &busWill{Topic: cmd.availabilityTopic, Payload: []byte(availabilityOffline), Retained: true},207 cmd.onConnect, cmd.handle)208 // The subscription is made once. The Bus remembers the filter and209 // re-sends it on every reconnect, so a broker restart does not need210 // the command sidecar to subscribe again.211 cmd.bus.Subscribe(cmd.commandsTopic)212 // The volume topic is retained, so the broker delivers the213 // unit's current level on this subscribe and the level reaches214 // mpv with no request of its own. A Player with no sinks names no215 // topic, so this pod subscribes to no level at all.216 if cmd.volumeTopic != "" {217 cmd.bus.Subscribe(cmd.volumeTopic)218 }219 // The controllers' own topics, two per Remote the unit names.220 cmd.subscribeRemotes(cmd.bus)221 go cmd.bus.Run(busCtx)222223 cmd.report(runCtx)224225 // The run is over: mpv ended or the kubelet is terminating the pod.226 // report published the ending report first, so a subscriber that is227 // listening has already read the mark. Clear the retained status so a228 // finished Play leaves no report that reads as still playing, mark the229 // availability offline, and hold the bus open long enough to send both230 // before the connection ends.231 cmd.bus.Publish(cmd.statusTopic, nil, true)232 cmd.bus.Publish(cmd.availabilityTopic, []byte(availabilityOffline), true)233 time.Sleep(busFlushGrace)234 stopBus()235}236237// onConnect refills the broker the moment a session reaches a CONNACK.238// It publishes online, and re-publishes the last-known report, because239// the broker drops its retained set on a restart and a reconnect must240// leave the current status behind again.241func (c *commander) onConnect(bus *Bus) {242 // A fresh session redelivers the retained level, so that message243 // is a catch-up again and applies silently again.244 c.volumeMutex.Lock()245 c.volumeCaughtUp = false246 c.volumeMutex.Unlock()247 bus.Publish(c.availabilityTopic, []byte(availabilityOnline), true)248 c.reportMutex.Lock()249 payload, have := c.marshalLastReport()250 c.reportMutex.Unlock()251 if have {252 bus.Publish(c.statusTopic, payload, true)253 }254}255256// marshalLastReport encodes the last-known report, and reports whether257// there is one. The caller holds reportMutex, so the payload and the258// state it came from cannot diverge. A report that will not marshal is259// logged and treated as no report at all, because there is nothing to put260// on the bus in its place.261func (c *commander) marshalLastReport() ([]byte, bool) {262 if !c.haveReport {263 return nil, false264 }265 payload, err := json.Marshal(c.lastReport)266 if err != nil {267 fmt.Fprintf(os.Stderr, "command: report: %v\n", err)268 return nil, false269 }270 return payload, true271}272273// handle sorts one inbound message by its topic. The volume topic and274// the controllers' topics carry payloads that are not the command275// vocabulary, so each is read before it. A payload that does not276// decode, or an action this build has no command for, writes nothing,277// so a newer program's command degrades to no effect rather than a278// crash.279func (c *commander) handle(topic string, payload []byte) {280 if c.volumeTopic != "" && topic == c.volumeTopic {281 c.applyVolume(payload)282 return283 }284 if c.handleRemote(topic, payload) {285 return286 }287 var command mediaCommand288 if err := json.Unmarshal(payload, &command); err != nil {289 return290 }291 c.apply(command)292}293294// apply is the one path from a command to mpv, for a press this pod295// read off a controller and for a command another program published296// alike. A volume step and a mute leave without reaching mpv: they297// publish the unit's next state, and the subscription applies it, so298// the pod that pressed and every pod that only listened run one apply299// path. A seek and a chapter jump send a second command, because they300// carry no-osd and the sidecar summons the display to draw the new301// position.302func (c *commander) apply(command mediaCommand) {303 if isVolumeAction(command.Action) {304 c.pressVolume(command)305 return306 }307 mpv := commandFor(command)308 if mpv == nil {309 return310 }311 c.command(mpv)312 if feedback := feedbackFor(command); feedback != nil {313 c.command(feedback)314 }315}316317// applyVolume folds one message off the volume topic and writes it318// to mpv. It is the only place in the pod that sets the level, so a319// press made here and a press made on another screen of the same320// unit reach mpv the same way.321func (c *commander) applyVolume(payload []byte) {322 state, ok := parseVolumeState(payload)323 if !ok {324 return325 }326 c.volumeMutex.Lock()327 c.volume = state328 c.haveVolume = true329 signal := c.volumeCaughtUp330 c.volumeCaughtUp = true331 c.volumeMutex.Unlock()332 for _, command := range volumeCommands(state) {333 c.command(command)334 }335 if signal {336 c.command(volumeChangedCommand())337 }338}339340// pressVolume publishes what a press means, retained, and writes341// nothing to mpv. It computes from the last message the topic342// delivered, or from unity before any message arrives, so a pod343// that just started still steps from a definite level.344func (c *commander) pressVolume(command mediaCommand) {345 if c.volumeTopic == "" {346 return347 }348 payload, err := marshalVolumeState(nextVolume(c.heldVolume(), command))349 if err != nil {350 fmt.Fprintf(os.Stderr, "command: volume: %v\n", err)351 return352 }353 c.bus.Publish(c.volumeTopic, payload, true)354}355356// heldVolume is the state the last message left, and unity before357// any message arrives.358func (c *commander) heldVolume() volumeState {359 c.volumeMutex.Lock()360 defer c.volumeMutex.Unlock()361 if !c.haveVolume {362 return defaultVolumeState()363 }364 return c.volume365}366367// report is the reporting side of the run. It dials mpv, observes the368// four properties the status is made of, and turns each change into a369// report on the bus. It ends when mpv's socket closes, which is how mpv370// says the run is over, or when the context ends on the grace signal.371func (c *commander) report(ctx context.Context) {372 conn, err := dialMPV(ctx, mpvSocketPath)373 if err != nil {374 fmt.Fprintf(os.Stderr, "command: no reports for this run: %v\n", err)375 return376 }377 defer conn.Close()378 // The read blocks until mpv writes, which can be minutes apart in a379 // paused film, so closing the socket is what ends the read when the380 // context ends. A running mpv ends the read by closing its own end.381 defer context.AfterFunc(ctx, func() { conn.Close() })()382383 c.drive(ctx, conn, c.send)384385 // drive returns for one of two reasons, and each is an ending: mpv386 // reached the end of the last item and closed its socket, or the387 // kubelet's SIGTERM ended the context. So the mark goes out here, with388 // the position the last report carried, before runCommand clears the389 // retained status and marks the pod offline.390 c.endRun()391}392393// drive is the socket loop the reporter and the standalone art server share.394// It observes the properties, reads the events, folds each into a report395// through send, forwards the current item's block, and answers each logo396// request. The reporter passes its bus-backed send. The art server passes a397// send that reports nothing, so the same decode code runs with or without a398// bus.399func (c *commander) drive(ctx context.Context, conn net.Conn, send reportSender) {400 // observeProperties writes the socket, and so does the commands401 // handler, so the observe runs under the same mutex the handler402 // takes. The connection is set first, so a command that arrives403 // mid-observe waits on the mutex rather than reaching a nil socket.404 if err := c.attach(conn); err != nil {405 fmt.Fprintf(os.Stderr, "command: no reports for this run: %v\n", err)406 return407 }408 defer c.detach()409410 changes := make(chan propertyChange, 16)411 messages := make(chan clientMessage, 16)412 reading := make(chan struct{})413 go func() {414 defer close(reading)415 defer close(changes)416 defer close(messages)417 if err := readEvents(ctx, conn, changes, messages); err != nil && ctx.Err() == nil {418 fmt.Fprintf(os.Stderr, "command: mpv's socket: %v\n", err)419 }420 }()421422 // The message goroutine answers the display's exit press and its logo423 // requests off the same socket the reporter reads. It ends when the424 // socket closes and the reader closes the messages channel.425 go c.serveMessages(messages)426427 runReporter(ctx, changes, send, c.present, c.playlistChanged)428 <-reading429}430431// serveMessages answers the three things the display broadcasts: the exit432// press, its request for the current block, and each art request. It runs433// beside the reporter, so a slow decode or a network fetch never holds up the434// position reports.435func (c *commander) serveMessages(messages <-chan clientMessage) {436 for message := range messages {437 if isExitMessage(message.Args) {438 c.exit()439 continue440 }441 if isPresentationRequest(message.Args) {442 c.resendPresentation()443 continue444 }445 c.serveArt(message.Args)446 }447}448449// resendPresentation answers the display's request with the block for the450// item that is playing. It sends nothing before the first item, because the451// reporter presents that item as soon as mpv says which one plays, so a452// block is on its way already. It does not swap the art the way present453// does: the item has not changed, and the decoded art the volume holds is454// still the current item's own.455func (c *commander) resendPresentation() {456 c.artMutex.Lock()457 item := c.artItem458 c.artMutex.Unlock()459 if item < 1 {460 return461 }462 c.command(presentationCommand(c.blockForItem(item)))463}464465// attach sets the mpv connection and observes the properties under one466// lock, so the first writes to the socket cannot interleave with a467// command the handler sends the moment the connection is live.468func (c *commander) attach(conn net.Conn) error {469 c.mpvMutex.Lock()470 defer c.mpvMutex.Unlock()471 c.mpv = conn472 return observeProperties(conn, observedProperties)473}474475// detach forgets the connection when the run ends, so a late command476// writes nothing rather than a closed socket.477func (c *commander) detach() {478 c.mpvMutex.Lock()479 c.mpv = nil480 c.mpvMutex.Unlock()481}482483// command writes one mpv command under the mutex. A command that484// arrives before mpv is dialed, or after the run ends, finds a nil485// connection and writes nothing.486func (c *commander) command(command []any) {487 c.mpvMutex.Lock()488 defer c.mpvMutex.Unlock()489 if c.mpv == nil {490 return491 }492 if err := sendCommand(c.mpv, command); err != nil {493 fmt.Fprintf(os.Stderr, "command: mpv command: %v\n", err)494 }495}496497// parsePresentations reads the baked array into an indexed slice. An498// unset or malformed value leaves no blocks, so every item forwards the499// empty object and the display falls back to the file.500func parsePresentations(value string) []json.RawMessage {501 if value == "" {502 return nil503 }504 var blocks []json.RawMessage505 if err := json.Unmarshal([]byte(value), &blocks); err != nil {506 return nil507 }508 return blocks509}510511// present hands the current item's block to the display over the mpv512// socket.513//514// It clears the previous item's art first, so the shared volume holds only515// what the current item can show.516func (c *commander) present(item int) {517 c.swapArt(item)518 c.command(presentationCommand(c.blockForItem(item)))519}520521// swapArt drops the previous item's decoded art when the playlist reaches a522// new item, so one item's logo never lingers on the next and the shared volume523// holds only the current item's blobs.524func (c *commander) swapArt(item int) {525 c.artMutex.Lock()526 defer c.artMutex.Unlock()527 if item == c.artItem {528 return529 }530 for _, blob := range c.artCache {531 os.Remove(blob.path)532 }533 c.artCache = nil534 if c.trickHave {535 os.Remove(c.trickBlob.path)536 }537 if c.trickHavePrev && c.trickPrev.path != c.trickBlob.path {538 os.Remove(c.trickPrev.path)539 }540 c.trickHave = false541 c.trickHavePrev = false542 c.trickSheet = nil543 c.trickSheetKey = ""544 c.artItem = item545}546547// blockForItem returns one item's block, or the empty object when the548// item falls outside the baked list. The item counts from one, so its549// index is one less.550func (c *commander) blockForItem(item int) json.RawMessage {551 index := item - 1552 if index < 0 || index >= len(c.presentations) {553 return json.RawMessage(emptyPresentation)554 }555 return c.presentations[index]556}557558// send publishes one report to the status topic, retained, and holds it559// as the last-known report. A restarted operator reads the retained560// report back from the broker, and a reconnect re-publishes the held561// report through onConnect, so neither loses a running Play's place.562func (c *commander) send(report playReport) error {563 c.reportMutex.Lock()564 // The reporter folds mpv's property changes alone and carries no565 // ending, so the mark is stamped here. Every report after the ending566 // carries it, and a subscriber that reads any one of them reads the567 // same ending.568 if c.ended {569 report.Ended = true570 }571 payload, err := json.Marshal(report)572 if err == nil {573 c.lastReport = report574 c.haveReport = true575 }576 c.reportMutex.Unlock()577 if err != nil {578 return err579 }580 c.bus.Publish(c.statusTopic, payload, true)581 return nil582}583584// endRun marks the run over and publishes the mark at once, retained,585// beside the numbers the last report carried. Every ending calls it: the586// exit press, mpv reaching the end of the last item, and the kubelet's587// SIGTERM.588//589// The operator turns the mark into the Player's idle status and the590// re-present that draws the idle screen again. The pod takes seconds to591// terminate, and an ending read from the pod's own death would leave a592// dead film on the screen for every one of them.593//594// A run that never reported publishes nothing, the same rule the reporter595// follows: mpv has not said which item plays, so there are no numbers to596// carry, and the pod's own death is what ends such a run. The art server597// runs this same code with no bus, and it publishes nothing either.598func (c *commander) endRun() {599 if c.bus == nil {600 return601 }602 c.reportMutex.Lock()603 c.ended = true604 c.lastReport.Ended = true605 payload, have := c.marshalLastReport()606 c.reportMutex.Unlock()607 if !have {608 return609 }610 c.bus.Publish(c.statusTopic, payload, true)611}612613// exit ends the run at a person's press. The display broadcasts the exit614// message when a person presses back at the bare video, and the sidecar615// answers it here rather than letting the display quit mpv itself,616// because the order is the whole point: the ending reaches the bus617// first, and only then does quit reach mpv. So the operator holds the618// mark while the film is still on the display, and how far mpv gets619// through its shutdown before its socket closes changes nothing.620//621// The exit code is zero, so the pod ends Completed, the outcome a film622// that ran to its end gives, and not Error.623func (c *commander) exit() {624 c.endRun()625 c.command(exitCommand())626}627628// playbackState is the run at one moment, as the command sidecar holds629// it. The fields are the report's fields, held between events, because630// each event carries one property and a report carries all of them.631type playbackState struct {632 paused bool633 item int634 position string635 duration string636637 // The language of the audio track and the subtitle track mpv chose. Each stays638 // set once mpv reports it.639 audioLanguage string640 subtitleLanguage string641}642643// reportable holds reports back until mpv has said which item plays.644// mpv's playlist-pos counts from zero and reads -1 before anything645// loads; the API counts from one, the way a person counts tracks, so an646// item below one describes no playback at all.647func (s playbackState) reportable() bool {648 return s.item >= 1649}650651func (s playbackState) report() playReport {652 return playReport{653 Paused: s.paused,654 Item: s.item,655 Position: s.position,656 Duration: s.duration,657 AudioLanguage: s.audioLanguage,658 SubtitleLanguage: s.subtitleLanguage,659 }660}661662// apply folds one property change into the state, and its return value663// says whether the change earns a report at once. A pause and an item664// change are the two things a person watching kubectl is waiting to665// see, and both are rare. A position that advances is neither rare nor666// surprising, so it waits for the interval.667func (s *playbackState) apply(change propertyChange) bool {668 if !change.known() {669 return false670 }671 switch change.Name {672 case "pause":673 var paused bool674 if err := json.Unmarshal(change.Data, &paused); err != nil {675 return false676 }677 changed := paused != s.paused678 s.paused = paused679 return changed680 case "playlist-pos":681 var position int682 if err := json.Unmarshal(change.Data, &position); err != nil || position < 0 {683 return false684 }685 item := position + 1686 changed := item != s.item687 s.item = item688 return changed689 case "time-pos":690 var seconds float64691 if err := json.Unmarshal(change.Data, &seconds); err != nil {692 return false693 }694 s.position = formatPosition(seconds)695 return false696 case "duration":697 var seconds float64698 if err := json.Unmarshal(change.Data, &seconds); err != nil {699 return false700 }701 s.duration = formatPosition(seconds)702 return false703 case audioLanguageProperty:704 var lang string705 if err := json.Unmarshal(change.Data, &lang); err != nil {706 return false707 }708 changed := lang != s.audioLanguage709 s.audioLanguage = lang710 return changed711 case subtitleLanguageProperty:712 var lang string713 if err := json.Unmarshal(change.Data, &lang); err != nil {714 return false715 }716 changed := lang != s.subtitleLanguage717 s.subtitleLanguage = lang718 return changed719 }720 return false721}722723// formatPosition writes seconds as H:MM:SS, because the value's one job724// is to be read in kubectl get output. The seconds are floored, not725// rounded: a position that reads 0:00:01 while the first second still726// plays is wrong in the direction a person notices.727func formatPosition(seconds float64) string {728 if math.IsNaN(seconds) || math.IsInf(seconds, 0) || seconds < 0 {729 seconds = 0730 }731 whole := int(math.Floor(seconds))732 return fmt.Sprintf("%d:%02d:%02d", whole/3600, whole%3600/60, whole%60)733}734735// reportSender is how a report leaves the command sidecar. The loop736// takes a function rather than the bus so a test catches the reports737// with no broker at all.738type reportSender func(playReport) error739740// itemPresenter is how a forwarded block leaves the loop, a function741// like reportSender, so a test catches the forward with no mpv socket.742type itemPresenter func(item int)743744// playlistReceiver is how mpv's playlist leaves the loop, a function like745// itemPresenter. The playlist takes its own path out because it settles each746// item's album art, and no field of the report carries it.747type playlistReceiver func(playlist json.RawMessage)748749// runReporter is the whole reporting rule in one loop: fold the change,750// send it now when it is one of the two that matter, and otherwise send751// no more than one report per interval. A send that fails is logged and752// the run goes on, because the loss is the operator's view of the film753// and not a reason to stop playing.754func runReporter(ctx context.Context, changes <-chan propertyChange, send reportSender, present itemPresenter, playlist playlistReceiver) {755 var state playbackState756 var sent time.Time757 var presented int758 for {759 select {760 case <-ctx.Done():761 return762 case change, open := <-changes:763 if !open {764 return765 }766 // The playlist is the art, not the report, so it leaves here767 // and folds into no playbackState field. It arrives before mpv768 // says which item plays, because observe answers every property769 // with its current value at subscribe time.770 if change.Name == playlistProperty {771 if change.known() {772 playlist(change.Data)773 }774 continue775 }776 atOnce := state.apply(change)777 if !state.reportable() {778 continue779 }780 // Forward the block on the first item and on every advance,781 // keyed on the item and not the throttled position, so the782 // display swaps its presentation the moment the playlist783 // reaches a new item.784 if state.item != presented {785 presented = state.item786 present(state.item)787 }788 if !atOnce && time.Since(sent) < reportInterval {789 continue790 }791 sent = time.Now()792 if err := send(state.report()); err != nil {793 fmt.Fprintf(os.Stderr, "command: report: %v\n", err)794 }795 }796 }797}
commandkeys.go 82.0%
1package main23// The command sidecar's controller half. The events topic carries key4// names, so nothing stands between a controller and the pod that owns5// mpv's socket, and one container holds every controller the unit6// names. The focus mark is still the gate: it names a Player and never7// a Play, and a controller pointed at another room reaches this film8// not at all.910import "encoding/json"1112// A playRemote is one of the unit's controllers as this sidecar reads13// it: the retained focus topic it gates on. The events topic is the14// map's key, because that is what an inbound message carries.15type playRemote struct {16 focus string17}1819// playRemoteMap pairs each remote's events topic with the focus topic20// on the same line of the second list. The operator joins both lists21// with newlines and keeps them aligned by position, the same shape the22// idle screen client reads.23func playRemoteMap(events, focuses string) map[string]playRemote {24 remotes := map[string]playRemote{}25 focusList := splitTopicLines(focuses)26 for index, topic := range splitTopicLines(events) {27 if topic == "" {28 continue29 }30 remote := playRemote{}31 if index < len(focusList) {32 remote.focus = focusList[index]33 }34 remotes[topic] = remote35 }36 return remotes37}3839// subscribeRemotes makes the two subscriptions each controller needs.40// Both are made once, because the Bus re-sends every filter on a41// reconnect. The focus topic is retained, so the gate stands before42// the first press.43func (c *commander) subscribeRemotes(bus *Bus) {44 for events, remote := range c.remotes {45 bus.Subscribe(events)46 if remote.focus != "" {47 bus.Subscribe(remote.focus)48 }49 }50}5152// handleRemote answers the topics the controllers own and reports53// whether this message was one of them, so the caller reads the54// commands topic only for a message no controller sent.55func (c *commander) handleRemote(topic string, payload []byte) bool {56 if remote, ours := c.remotes[topic]; ours {57 c.key(topic, remote, payload)58 return true59 }60 if events, ours := c.remoteForFocus(topic); ours {61 c.setFocus(events, string(payload))62 return true63 }64 return false65}6667// key turns one key event into what this pod does with it. A press68// this Play's Player does not hold the mark for does nothing. A key69// with no row does nothing. The cycle key asks the operator to move70// the mark and never reaches mpv.71func (c *commander) key(topic string, remote playRemote, payload []byte) {72 var event keyEvent73 if err := json.Unmarshal(payload, &event); err != nil {74 return75 }76 if !c.holdsFocus(topic) {77 return78 }79 command, bound := commandForKey(event)80 if !bound {81 return82 }83 if command.Action == actionCycleFocus {84 c.publishCycle(remote)85 return86 }87 c.apply(command)88}8990// focusCycleSuffix turns a remote's focus topic into its cycle topic, the91// same path remoteFocusCycleTopic builds, so the sidecar needs no second92// topic list.93const focusCycleSuffix = "/cycle"9495// publishCycle sends the cycle request the operator arbitrates, on96// the remote's own cycle topic, not retained, because a cycle is an97// event and not a state. It is the same message the idle screen client98// publishes between films.99func (c *commander) publishCycle(remote playRemote) {100 if remote.focus == "" {101 return102 }103 c.bus.Publish(remote.focus+focusCycleSuffix, nil, false)104}105106// setFocus records one controller's mark. The gate is set on every107// message, catch-up and live alike, because this pod draws nothing on108// a mark and only reads it.109func (c *commander) setFocus(events, mark string) {110 c.focusMu.Lock()111 defer c.focusMu.Unlock()112 if c.marks == nil {113 c.marks = map[string]string{}114 }115 c.marks[events] = mark116}117118// holdsFocus reports whether this controller's mark names the Player119// this Play runs on. A sidecar that read no Player name matches no120// mark and answers no press.121func (c *commander) holdsFocus(events string) bool {122 c.focusMu.Lock()123 defer c.focusMu.Unlock()124 return c.playerName != "" && c.marks[events] == c.playerName125}126127// remoteForFocus reports which controller a focus topic marks. The128// list is the unit's own controllers, so the scan is over a handful.129func (c *commander) remoteForFocus(topic string) (string, bool) {130 if topic == "" {131 return "", false132 }133 for events, remote := range c.remotes {134 if remote.focus == topic {135 return events, true136 }137 }138 return "", false139}
discovery.go 96.4%
1package main23// Discovery is the reader's teaching mode. This file renders what one4// event reports: the node it came from, the event type, the code by5// its evdev name, the value as a press, a release, or a repeat, and6// the Keymap entry that would bind it, ready to paste. Where no entry7// can bind it, the line states why instead.89import "fmt"1011// The placeholder the pasted fragment carries on its right side. A12// Keymap row names the kernel key the control should report, or none13// to drop it, and most controls need no row at all.14const keymapKeyHint = "<a KEY_* name, or none>"1516// discoveryLines renders one event: the fields on the first line, and17// under them the Keymap entry that binds it, indented to paste under18// spec.buttons or spec.axes.19func discoveryLines(node string, event inputEvent) []string {20 switch event.Type {21 case evKey:22 return keyLines(node, event)23 case evAbs:24 return axisLines(node, event)25 }26 return nil27}2829// keyLines names a key event's code and value. Only the press earns a30// fragment, because a Keymap binds the press alone; the release and31// the repeat lines state that instead, so a stream of value 2 does not32// read as a bug.33func keyLines(node string, event inputEvent) []string {34 name := keyCodeNames[event.Code]35 line := fmt.Sprintf("%s %s %s %s", node, eventTypeField(event.Type),36 codeField(name, event.Code), keyValueField(event.Value))37 switch {38 case name == "":39 return []string{line + ": the kernel gives this code no name, so a Keymap cannot bind it"}40 case event.Value != 1:41 return []string{line + ": a Keymap binds the press alone"}42 }43 return []string{44 line,45 fmt.Sprintf(" - press: %s # code %d", name, event.Code),46 " key: " + keymapKeyHint,47 }48}4950// axisLines names a hat event's code and direction. The two directions51// earn a fragment; the return to the middle does not, because a Keymap52// binds -1 and 1 and treats 0 as the release.53func axisLines(node string, event inputEvent) []string {54 name := axisCodeNames[event.Code]55 line := fmt.Sprintf("%s %s %s %d", node, eventTypeField(event.Type),56 codeField(name, event.Code), event.Value)57 switch {58 case name == "":59 return []string{line + ": a Keymap binds the two hat axes alone"}60 case event.Value == 0:61 return []string{line + ": a Keymap binds -1 and 1, not the return to the middle"}62 }63 return []string{64 line,65 fmt.Sprintf(" - axis: %s # code %d", name, event.Code),66 fmt.Sprintf(" value: %d", event.Value),67 " key: " + keymapKeyHint,68 }69}7071// eventTypeField gives the type by the kernel's own name, with the72// number beside it.73func eventTypeField(eventType uint16) string {74 name := "EV_KEY"75 if eventType == evAbs {76 name = "EV_ABS"77 }78 return fmt.Sprintf("%s (%d)", name, eventType)79}8081// codeField gives the code by name and number, or the number alone82// when the kernel gives the code no name.83func codeField(name string, code uint16) string {84 if name == "" {85 return fmt.Sprintf("%d", code)86 }87 return fmt.Sprintf("%s (%d)", name, code)88}8990// keyValueField names a key's three values for what a person did.91func keyValueField(value int32) string {92 switch value {93 case 0:94 return "release (0)"95 case 1:96 return "press (1)"97 case 2:98 return "repeat (2)"99 }100 return fmt.Sprintf("(%d)", value)101}
edl/album.go 91.5%
1package edl23// An album folder becomes a timeline. Two callers write one: the player4// shim in the playback pod, because only the pod mounts the media, and the5// local/edl tool on a workstation. Both call this code, so what a person6// sees locally is what a Play runs.78import (9 "cmp"10 "io/fs"11 "os"12 "path/filepath"13 "regexp"14 "slices"15 "strings"1617 "github.com/dhowden/tag"18)1920// The file names an album is made of, matched without case because a library21// writes either. mpv decodes all six, and dhowden/tag reads only the MP3, M4A,22// FLAC, OGG, and DSF families, so a wma or wav track takes its title from its23// file name and its cover from the folder it sits in.24var AudioExtensions = []string{".mp3", ".flac", ".m4a", ".ogg", ".wma", ".wav"}2526// The track number a library writes in front of a file name, which the27// title fallback strips: digits, then a dash, a dot, or an underscore, with28// whatever spacing surrounds the mark.29var trackPrefix = regexp.MustCompile(`^\d+\s*[-._]\s*`)3031// Facts are the album's words as its own files state them, for a caller32// that builds a presentation block from a folder.33//34// The year is a number, because the presentation block's year is a number.35// A file that states none reads as zero.36type Facts struct {37 Artist string38 Album string39 Year int40}4142// AlbumFiles is the album in the order it plays. The file names carry the43// track order, so name order is play order and no tag is read to settle it.44//45// The walk descends, because a multi-disc album is a folder of folders, and46// the whole relative path settles the order, so CD1 comes before CD2 and47// each disc keeps its own track order. A hidden entry is skipped, because48// macOS and sync tools write dot-named companions beside the real files.49func AlbumFiles(dir string) ([]string, error) {50 absolute, err := filepath.Abs(dir)51 if err != nil {52 return nil, err53 }5455 var relatives []string56 walk := func(path string, entry fs.DirEntry, err error) error {57 if err != nil {58 return err59 }60 if path != absolute && strings.HasPrefix(entry.Name(), ".") {61 if entry.IsDir() {62 return fs.SkipDir63 }64 return nil65 }66 if entry.IsDir() || !IsAudio(entry.Name()) {67 return nil68 }69 relative, err := filepath.Rel(absolute, path)70 if err != nil {71 return err72 }73 relatives = append(relatives, relative)74 return nil75 }76 if err := filepath.WalkDir(absolute, walk); err != nil {77 return nil, err78 }7980 slices.SortFunc(relatives, compareNatural)81 files := make([]string, len(relatives))82 for index, relative := range relatives {83 files[index] = filepath.Join(absolute, relative)84 }85 return files, nil86}8788// The order is natural, not the byte order the file system gives: a person89// writes track 2 and track 10 without padding, and the byte order plays 1090// before 2. The comparison reads both names in step, a run of digits as the91// number it spells and any other run as its bytes.92func compareNatural(a, b string) int {93 for len(a) > 0 && len(b) > 0 {94 digits := isDigit(a[0])95 if digits != isDigit(b[0]) {96 return cmp.Compare(a[0], b[0])97 }98 var left, right string99 if digits {100 left, a = runOf(a, isDigit)101 right, b = runOf(b, isDigit)102 if order := compareNumbers(left, right); order != 0 {103 return order104 }105 continue106 }107 left, a = runOf(a, func(c byte) bool { return !isDigit(c) })108 right, b = runOf(b, func(c byte) bool { return !isDigit(c) })109 if order := strings.Compare(left, right); order != 0 {110 return order111 }112 }113 return cmp.Compare(len(a), len(b))114}115116// Two digit runs compare as the numbers they spell, and the written length117// breaks a value tie, so a padded name and a bare one hold one order.118func compareNumbers(a, b string) int {119 left, right := strings.TrimLeft(a, "0"), strings.TrimLeft(b, "0")120 if len(left) != len(right) {121 return cmp.Compare(len(left), len(right))122 }123 if order := strings.Compare(left, right); order != 0 {124 return order125 }126 return cmp.Compare(len(a), len(b))127}128129// runOf reads the leading run of bytes the test accepts, and returns it with130// what is left of the name.131func runOf(name string, accepts func(byte) bool) (run, rest string) {132 end := 0133 for end < len(name) && accepts(name[end]) {134 end++135 }136 return name[:end], name[end:]137}138139func isDigit(c byte) bool {140 return c >= '0' && c <= '9'141}142143// IsAudio says whether one file name is an album track.144func IsAudio(name string) bool {145 extension := strings.ToLower(filepath.Ext(name))146 return slices.Contains(AudioExtensions, extension)147}148149// Timeline writes the whole EDL: the header, then one segment per track.150// Each segment names the file and the title mpv turns into that track's151// chapter.152func Timeline(files []string) string {153 var text strings.Builder154 text.WriteString(Header + "\n")155 for _, file := range files {156 text.WriteString(Quote(file) + ",title=" + Quote(TitleOf(file)) + "\n")157 }158 return text.String()159}160161// TitleOf is the track's name: the tag first, then the file name with its162// extension and its track number removed. A ripped library names each file163// for its track, so the file name is a sound fallback.164func TitleOf(file string) string {165 if title := taggedTitle(file); title != "" {166 return flatten(title)167 }168 name := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))169 return flatten(trackPrefix.ReplaceAllString(name, ""))170}171172// A timeline is a line per segment, so a title that holds a newline would173// end the segment early and the rest of the title would read as another174// one. Every control byte becomes a space, and a run of them becomes one175// space.176func flatten(title string) string {177 var text strings.Builder178 spaced := false179 for index := 0; index < len(title); index++ {180 if c := title[index]; c >= 0x20 && c != 0x7f {181 text.WriteByte(c)182 spaced = false183 continue184 }185 if !spaced {186 text.WriteByte(' ')187 spaced = true188 }189 }190 return strings.TrimSpace(text.String())191}192193// taggedTitle reads one file's title tag, and reads nothing from a file with194// no tags.195func taggedTitle(file string) string {196 metadata := readTags(file)197 if metadata == nil {198 return ""199 }200 return strings.TrimSpace(metadata.Title())201}202203// AlbumFacts is the album's words as its own files state them. The album's204// fields are the same on every track, so the first tagged file states them205// for the whole album.206func AlbumFacts(files []string) Facts {207 for _, file := range files {208 metadata := readTags(file)209 if metadata == nil {210 continue211 }212 return Facts{213 Artist: strings.TrimSpace(metadata.Artist()),214 Album: strings.TrimSpace(metadata.Album()),215 Year: max(0, metadata.Year()),216 }217 }218 return Facts{}219}220221// readTags reads one file's tags, and reads nothing from a file it cannot open222// or that carries none.223func readTags(path string) tag.Metadata {224 file, err := os.Open(path)225 if err != nil {226 return nil227 }228 defer file.Close()229 metadata, err := tag.ReadFrom(file)230 if err != nil {231 return nil232 }233 return metadata234}
edl/format.go 95.5%
1// Package edl reads and writes mpv's EDL v0 format. An album plays as one2// EDL timeline: mpv reads it as one playlist entry with one duration and one3// chapter per track, so the display drives track selection with the chapter4// machinery a film already uses. The format lives in its own package5// of its own: the player shim writes a timeline, the command sidecar reads one6// back for the album's art, and the local tool prints one, and all three must7// agree byte for byte.8package edl910// The format is a header line, one segment per line, and comma-separated11// parameters, with a %<length>% quoting that lets a file name or a title12// carry a comma.1314import (15 "os"16 "path/filepath"17 "strconv"18 "strings"19)2021// The header mpv reads an EDL by. A file that opens with anything else is no22// timeline. The edl:// URL form carries no header, because the scheme has23// already said what the text is.24const Header = "# mpv EDL v0"2526// One segment of a timeline: the file it plays, and the named27// parameters the line carries, such as the title that becomes mpv's chapter28// name. The positional start and length are not read here.29type Segment struct {30 File string31 Params map[string]string32}3334// Parse reads a timeline into its segments. It skips the header, the35// comments, and the ! lines, which carry stream and chapter directives no36// reader here has a use for. A line it cannot read is skipped rather than37// fatal, so one malformed segment costs the album one cover and not the run.38func Parse(text string) []Segment {39 var segments []Segment40 for _, raw := range strings.Split(text, "\n") {41 line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))42 if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") {43 continue44 }45 if segment, ok := ParseSegment(line); ok {46 segments = append(segments, segment)47 }48 }49 return segments50}5152// ParseSegment reads one line: the file name first, then the53// parameters. A positional field is the segment's start or length, which54// nothing here reads, so only the named parameters are kept.55func ParseSegment(line string) (Segment, bool) {56 fields, ok := splitFields(line)57 if !ok || len(fields) == 0 || fields[0].name != "" || fields[0].value == "" {58 return Segment{}, false59 }60 segment := Segment{File: fields[0].value, Params: map[string]string{}}61 for _, field := range fields[1:] {62 if field.name == "" {63 continue64 }65 segment.Params[field.name] = field.value66 }67 return segment, true68}6970// One field of a segment line. A file name and a positional number71// carry no name, and a parameter carries both halves of its name=value.72type field struct {73 name string74 value string75}7677// splitFields breaks one line on its commas, with the quoting the78// format defines: a %<length>% run states its own length in bytes, so the text79// inside it may hold a comma, an equals sign, or a percent sign and still80// arrive whole.81func splitFields(line string) ([]field, bool) {82 var fields []field83 pos := 084 for {85 read := field{}86 if pos < len(line) && line[pos] == '%' {87 text, next, ok := readQuoted(line, pos)88 if !ok {89 return nil, false90 }91 read.value, pos = text, next92 } else {93 start := pos94 for pos < len(line) && line[pos] != ',' && line[pos] != '=' {95 pos++96 }97 word := line[start:pos]98 if pos < len(line) && line[pos] == '=' {99 pos++100 value, next, ok := readValue(line, pos)101 if !ok {102 return nil, false103 }104 read.name, read.value, pos = word, value, next105 } else {106 read.value = word107 }108 }109 fields = append(fields, read)110 if pos >= len(line) {111 return fields, true112 }113 if line[pos] != ',' {114 return nil, false115 }116 pos++117 }118}119120// readValue reads the right half of a name=value field, quoted or plain.121func readValue(line string, pos int) (value string, next int, ok bool) {122 if pos < len(line) && line[pos] == '%' {123 return readQuoted(line, pos)124 }125 start := pos126 for pos < len(line) && line[pos] != ',' {127 pos++128 }129 return line[start:pos], pos, true130}131132// readQuoted reads one %<length>%<text> run and returns the text and133// where the line goes on. A length that does not parse, or one that runs past134// the end of the line, is a line the reader cannot read.135func readQuoted(line string, pos int) (text string, next int, ok bool) {136 end := strings.IndexByte(line[pos+1:], '%')137 if end < 0 {138 return "", 0, false139 }140 end += pos + 1141 length, err := strconv.Atoi(line[pos+1 : end])142 if err != nil || length < 0 || end+1+length > len(line) {143 return "", 0, false144 }145 return line[end+1 : end+1+length], end + 1 + length, true146}147148// Quote writes one value in the format's own quoting: a percent sign, the149// length in bytes, another percent sign, then the text. The length is what150// lets a file name or a title carry a comma, an equals sign, or a percent151// sign, so every value is quoted and none is inspected first.152func Quote(value string) string {153 return "%" + strconv.Itoa(len(value)) + "%" + value154}155156// SegmentPath is where one segment's file lives. A relative path is157// measured from the timeline's own directory, the way mpv measures it, and an158// absolute path or a URL stands on its own.159func SegmentPath(dir, file string) string {160 if dir == "" || filepath.IsAbs(file) || strings.Contains(file, "://") {161 return file162 }163 return filepath.Join(dir, file)164}165166// ReadFile reads a timeline off disk. A file that opens with anything167// but the header is no timeline, and the caller reads it as the media file it168// names.169func ReadFile(path string) (text string, ok bool) {170 data, err := os.ReadFile(path)171 if err != nil {172 return "", false173 }174 if !strings.HasPrefix(strings.TrimSpace(string(data)), Header) {175 return "", false176 }177 return string(data), true178}
evdev.go 67.8%
1package main23// The kernel publishes every input device as a character node under4// /dev/input, and a read on one returns fixed-width event records:5// a button went down, an axis moved. The records are a stable6// kernel ABI, so the sidecar reads them directly rather than through7// an input library, which would be the player image's only reason to8// carry one.9//10// One controller publishes several nodes. A DualSense publishes11// three: the buttons and sticks, the touchpad, and the motion12// sensors. The kernel also publishes each node's capability bitmaps,13// one bit per event code it can report, and the node's name. The14// standing pod carries no keymap, so it keeps any node that declares a15// key code or a hat axis, which is every node a Keymap could bind: a16// gamepad's buttons and a media remote's keys alike. It logs one17// verdict line per node, so a node the rule rejects is legible in the18// pod log.1920import (21 "encoding/binary"22 "fmt"23 "os"24 "path/filepath"25 "sort"26 "syscall"27 "unsafe"28)2930// inputEvent is the kernel's struct input_event on a 64-bit31// machine: a timestamp, the event type, the code, and the value.32// The timestamp goes unread, because the sidecar acts on a press33// the moment it arrives and never orders or replays events.34type inputEvent struct {35 Sec int6436 Usec int6437 Type uint1638 Code uint1639 Value int3240}4142// A record is 24 bytes on a 64-bit kernel: sixteen of timestamp,43// two of type, two of code, four of value. The kernel returns whole44// records from every read, never a fraction of one.45const inputEventSize = 244647// The bitmap sizes, one bit per code: KEY_MAX is 0x2ff, so 96 bytes48// cover every key code, and ABS_MAX is 0x3f, so 8 bytes cover every49// axis.50const (51 keyBitmapBytes = 9652 absBitmapBytes = 853)5455// The buffer the kernel writes a node's name into. A longer name is56// cut at the buffer's end, which still tells one node from another.57const nodeNameBytes = 1285859// parseInputEvents decodes one read's worth of records. The fields60// are little endian, which is correct on the amd64 and arm6461// machines liken targets and wrong on a big-endian kernel. A tail62// shorter than one record is ignored; the kernel does not split63// records, so there is never one to carry over.64func parseInputEvents(buffer []byte) []inputEvent {65 events := make([]inputEvent, 0, len(buffer)/inputEventSize)66 for offset := 0; offset+inputEventSize <= len(buffer); offset += inputEventSize {67 record := buffer[offset : offset+inputEventSize]68 events = append(events, inputEvent{69 Sec: int64(binary.LittleEndian.Uint64(record[0:8])),70 Usec: int64(binary.LittleEndian.Uint64(record[8:16])),71 Type: binary.LittleEndian.Uint16(record[16:18]),72 Code: binary.LittleEndian.Uint16(record[18:20]),73 Value: int32(binary.LittleEndian.Uint32(record[20:24])),74 })75 }76 return events77}7879// bitmapRequest builds the EVIOCGBIT ioctl number by hand, the way80// the C macro _IOC does: two bits of direction (read), fourteen bits81// of size, the event family's letter 'E', and 0x20 plus the event82// type as the command. The size is part of the number, so it must83// state the buffer's real length or the kernel copies the wrong84// amount.85func bitmapRequest(evType uint16, length int) uintptr {86 return uintptr(2)<<30 | uintptr(length)<<16 | uintptr(0x45)<<8 | uintptr(0x20+evType)87}8889// bitmapHasCode reads one code's bit: byte code/8, bit code%8,90// least significant first. A code past the bitmap's end is a code91// the node does not report.92func bitmapHasCode(bitmap []byte, code uint16) bool {93 index := int(code / 8)94 if index >= len(bitmap) {95 return false96 }97 return bitmap[index]&(1<<(code%8)) != 098}99100// declaredKeyCodes lists every key code the node declares, in code101// order. The bitmap is read with no button pressed, so the set is102// complete the moment the node opens.103func declaredKeyCodes(bitmap []byte) []uint16 {104 var codes []uint16105 for code := range uint16(len(bitmap) * 8) {106 if bitmapHasCode(bitmap, code) {107 codes = append(codes, code)108 }109 }110 return codes111}112113// declaredHatAxes lists the hat axes the node declares, in code order.114// The hats are the only axes a Keymap binds.115func declaredHatAxes(bitmap []byte) []uint16 {116 var codes []uint16117 for _, code := range axisCodes {118 if bitmapHasCode(bitmap, code) {119 codes = append(codes, code)120 }121 }122 sort.Slice(codes, func(i, j int) bool { return codes[i] < codes[j] })123 return codes124}125126// controllerBitmaps is the selection rule: any key code at all, or127// either hat axis. The reader has no keymap, so it keeps every node128// that could carry a bound code, and one standing pod then feeds two129// players that map the controller differently.130func controllerBitmaps(keys, axes []byte) bool {131 return len(declaredKeyCodes(keys)) > 0 || len(declaredHatAxes(axes)) > 0132}133134// nodeVerdict is what the reader decided about one node, with the135// counts behind the decision. The verdicts log in both modes, so a136// node the rule rejects is legible in the pod log.137type nodeVerdict struct {138 Path string139 Name string140 Keys int141 Hats int142 Keep bool143}144145// line renders one verdict the way the pod logs it.146func (v nodeVerdict) line() string {147 verdict := "reject"148 if v.Keep {149 verdict = "keep"150 }151 return fmt.Sprintf("%s %q %s: %s, %s",152 filepath.Base(v.Path), v.Name, verdict,153 countOf(v.Keys, "key code", "key codes"),154 countOf(v.Hats, "hat axis", "hat axes"))155}156157// countOf writes one count in words, so a node that declares nothing158// states that plainly.159func countOf(count int, singular, plural string) string {160 switch count {161 case 0:162 return "no " + plural163 case 1:164 return "1 " + singular165 default:166 return fmt.Sprintf("%d %s", count, plural)167 }168}169170// openNode is one node the reader inspected: the file it reads, the171// path and name a person knows it by, and the two capability bitmaps,172// read once at open because no press changes them.173type openNode struct {174 file *os.File175 path string176 name string177 keys []byte178 axes []byte179}180181// label is the node as every logged line names it.182func (n openNode) label() string {183 return fmt.Sprintf("%s %q", filepath.Base(n.path), n.name)184}185186// verdict is this node's decision, with the counts behind it.187func (n openNode) verdict(keep bool) nodeVerdict {188 return nodeVerdict{189 Path: n.path,190 Name: n.name,191 Keys: len(declaredKeyCodes(n.keys)),192 Hats: len(declaredHatAxes(n.axes)),193 Keep: keep,194 }195}196197// inspectNode reads one node's name and both capability bitmaps. A198// node that refuses either ioctl is not an event device, and the199// reader skips it in either mode.200func inspectNode(descriptor int, path string) (openNode, bool) {201 keys, err := deviceBitmap(descriptor, evKey, keyBitmapBytes)202 if err != nil {203 return openNode{}, false204 }205 axes, err := deviceBitmap(descriptor, evAbs, absBitmapBytes)206 if err != nil {207 return openNode{}, false208 }209 // A node with no name is still readable, so a missing name is no210 // reason to skip it.211 name, _ := deviceName(descriptor)212 return openNode{path: path, name: name, keys: keys, axes: axes}, true213}214215// deviceBitmap fetches one event type's capability bitmap. The216// kernel fills the buffer up to the length the request number217// states and reports how much it wrote; only the bits matter here.218func deviceBitmap(descriptor int, evType uint16, length int) ([]byte, error) {219 bitmap := make([]byte, length)220 _, _, errno := syscall.Syscall(221 syscall.SYS_IOCTL,222 uintptr(descriptor),223 bitmapRequest(evType, length),224 uintptr(unsafe.Pointer(&bitmap[0])),225 )226 if errno != 0 {227 return nil, errno228 }229 return bitmap, nil230}231232// nameRequest builds the EVIOCGNAME ioctl number by hand, the way233// bitmapRequest builds EVIOCGBIT. The command is 0x06, and the size in234// the number states how much name the kernel may write.235func nameRequest(length int) uintptr {236 return uintptr(2)<<30 | uintptr(length)<<16 | uintptr(0x45)<<8 | uintptr(0x06)237}238239// deviceName reads one node's name from the kernel, which is the240// model's own string, such as a controller's product name.241func deviceName(descriptor int) (string, error) {242 buffer := make([]byte, nodeNameBytes)243 written, _, errno := syscall.Syscall(244 syscall.SYS_IOCTL,245 uintptr(descriptor),246 nameRequest(nodeNameBytes),247 uintptr(unsafe.Pointer(&buffer[0])),248 )249 if errno != 0 {250 return "", errno251 }252 return nodeName(buffer, int(written)), nil253}254255// nodeName decodes what the kernel wrote: the bytes it reported,256// ending at the first NUL.257func nodeName(buffer []byte, written int) string {258 if written > len(buffer) {259 written = len(buffer)260 }261 name := buffer[:written]262 for index, character := range name {263 if character == 0 {264 return string(name[:index])265 }266 }267 return string(name)268}
focus.go 98.2%
1package main23// Focus is which unit holds a controller's presses right now. One4// controller can drive several units, so its one events topic has several5// readers: the command sidecar in every Play's pod, and the idle screen6// client of every unit that lists it. Without a gate, one press would7// reach them all. A8// retained mark per Remote names the one Player that owns the presses, and9// every reader gates on the mark. The mark names a Player and never a Play:10// a claim is exclusive, so one active Play holds one Player, and the film11// on the focused Player is unambiguous. A mark may also name an idle12// Player, which is how the idle screen takes presses at all.13//14// This file is the operator's side of that. The focus desk is the boundary15// between the bus and the reconcile loop, the way the report desk is for a16// Play's status. The operator is the only writer of a mark: only it reads17// every Player, so only it holds a controller's full cycle set and its18// order. It reads its own retained writes back, so it recovers the current19// marks after a restart, and the loop drains and arbitrates the cycle20// requests a source press leaves on the desk.2122import (23 "slices"24 "sort"25 "strings"26 "sync"27)2829// focusDesk holds the current mark per controller and the pending cycle30// requests. One mutex guards both, because the bus handler writes them on31// its goroutine and the reconcile loop reads and clears them on its own.32type focusDesk struct {33 mutex sync.Mutex34 marks map[string]string35 cycles map[string]bool36 wake chan<- struct{}37}3839func newFocusDesk(wake chan<- struct{}) *focusDesk {40 return &focusDesk{41 marks: map[string]string{},42 cycles: map[string]bool{},43 wake: wake,44 }45}4647// setMark stores the owning Player for one controller. An empty player48// deletes the key, the shape a cleared retained mark arrives in. A value49// that changes wakes the loop, because a Player's bus status and a Remote's50// status both derive from the mark, and the pass that writes them must run51// again. The operator reads its own retained writes back, and those repeat52// the stored value, so they wake nothing.53func (f *focusDesk) setMark(key, player string) {54 f.mutex.Lock()55 was := f.marks[key]56 if player == "" {57 delete(f.marks, key)58 } else {59 f.marks[key] = player60 }61 f.mutex.Unlock()62 if was != player {63 poke(f.wake)64 }65}6667// markFor reads one controller's current mark.68func (f *focusDesk) markFor(key string) string {69 f.mutex.Lock()70 defer f.mutex.Unlock()71 return f.marks[key]72}7374// snapshot copies the current marks, so the operator republishes them75// after a fresh broker session without holding the desk lock while it76// writes the bus.77func (f *focusDesk) snapshot() map[string]string {78 f.mutex.Lock()79 defer f.mutex.Unlock()80 marks := make(map[string]string, len(f.marks))81 for key, player := range f.marks {82 marks[key] = player83 }84 return marks85}8687// requestCycle records that a source press asked to cycle one controller88// and wakes the loop to arbitrate it. The cycle is an event, so a request89// the operator misses while it is down is lost, and a later press asks90// again.91func (f *focusDesk) requestCycle(key string) {92 f.mutex.Lock()93 f.cycles[key] = true94 f.mutex.Unlock()95 poke(f.wake)96}9798// takeCycles returns the controllers with a pending cycle request and99// clears the set, so the loop arbitrates each request once.100func (f *focusDesk) takeCycles() []string {101 f.mutex.Lock()102 defer f.mutex.Unlock()103 keys := make([]string, 0, len(f.cycles))104 for key := range f.cycles {105 keys = append(keys, key)106 }107 f.cycles = map[string]bool{}108 return keys109}110111// controllerKey is the one key shape for a controller, its namespace and112// name. It matches the two segments the focus topic path carries, so the113// bus handler and the loop name a controller the same way.114func controllerKey(namespace, remote string) string {115 return namespace + "/" + remote116}117118// stealFocus marks each of a fresh Play's controllers to the Player the119// Play runs on. When a Play starts on a unit a controller drives, the120// newest film takes the controller, which is the friendly default: start a121// film, and the controller in your hand drives it. A source press moves122// the mark the other way.123func (o *operator) stealFocus(play *Play, remotes []boundRemote) {124 namespace := play.Metadata.Namespace125 for _, remote := range remotes {126 o.publishFocus(controllerKey(namespace, remote.Name), playerName(play))127 }128}129130// reconcileFocus arbitrates focus from the Players alone. A controller's131// cycle set is every Player in its namespace whose spec.remotes lists it,132// in name order, so the cycle steps the same way every pass and an idle133// Player is a real stop. No Play is read here: a claim is exclusive, so the134// film on the focused Player is whatever Play holds its claim, and a Play135// that finishes moves no mark, because the Player it names is still there,136// showing its idle screen.137func (o *operator) reconcileFocus(players []Player) {138 sets := map[string][]string{}139 for index := range players {140 player := &players[index]141 for _, entry := range player.Spec.Remotes {142 key := controllerKey(player.Metadata.Namespace, entry.Name)143 sets[key] = append(sets[key], player.Metadata.Name)144 }145 }146 for key := range sets {147 sort.Strings(sets[key])148 }149150 // A cycle advances one step from the current mark. A mark that names no151 // Player in the set reads as index -1, so the step lands on the first,152 // and the modulo wraps the last back to the first. A set of one wraps153 // onto itself and republishes the same mark, and that repeat is the154 // press's feedback: the idle screen answers it with a pulse on its155 // focus marker.156 for _, key := range o.focus.takeCycles() {157 names := sets[key]158 if len(names) == 0 {159 continue160 }161 index := slices.Index(names, o.focus.markFor(key))162 o.publishFocus(key, names[(index+1)%len(names)])163 }164165 // Recovery moves a mark that is empty or names a Player outside the166 // set, so a controller always drives a unit that lists it. It never167 // moves a mark that still names a Player in the set, so it does not168 // steal from a holder.169 for key, names := range sets {170 current := o.focus.markFor(key)171 if current == "" || !slices.Contains(names, current) {172 o.publishFocus(key, names[0])173 }174 }175176 // A controller no Player lists any more has nothing to drive, so its177 // mark is cleared. The empty retained payload is the delete, and it178 // leaves no stale mark on the bus for a later reader to gate open on.179 for key := range o.focus.snapshot() {180 if len(sets[key]) == 0 {181 o.publishFocus(key, "")182 }183 }184}185186// publishFocus writes one controller's mark to the retained topic and to187// the local desk, the two the operator keeps in step so the cycle math on188// the same pass reads the value it just wrote. It publishes every time it189// is called, an unchanged value included, because the repeat of a current190// mark is the feedback a cycle press earns.191func (o *operator) publishFocus(key, player string) {192 namespace, remote, _ := strings.Cut(key, "/")193 o.bus.Publish(remoteFocusTopic(o.topicBase, namespace, remote), []byte(player), true)194 o.focus.setMark(key, player)195}
idlepod.go 98.7%
1package main23// Each Player that drives a screen reconciles into a standing4// claim on the screen and, where this operator draws the idle screen5// itself, one standing idle client pod. Both are owned by the Player6// through an owner reference, so deleting the Player tears them down.7// The pod draws the clock while no Play runs. The claim holds a shared8// draw device on the Player's screen, so the idle pod and a Play's own9// pod draw to one screen at once. A Play's mpv starts with the same10// app-id and draws over the idle clock.11//12// Who draws is a choice. spec.idle.controller names the operator that13// draws, and under this operator's own name spec.idle.image names the14// client.15// A Player that names no image runs the idle client at the16// operator's own version.17// Every client starts with its image's own entrypoint and reads the18// unit's state off the bus, so one contract serves the client this19// project ships and any other a household states.2021import (22 "strconv"23 "strings"24)2526// The one container of a standing idle pod. It runs the idle client and27// draws the clock, and the name is the job the container does.28const idleContainer = "idle"2930// idleDrawRequest names the claim's request for the shared draw device,31// the display companion the display-operator publishes per connector. It32// delivers the compositor socket and the app-id, and sets no mode, so33// the idle clock draws to the screen without owning its resolution.34const idleDrawRequest = "draw"3536// How long the idle client waits for its window before it exits and37// lets the kubelet restart the container. It is longer than a38// compositor restart takes and shorter than a person waits at a black39// screen.40const idleWindowGraceSeconds = 154142// idlePodName is a Player's standing idle pod, the Player's name plus its43// job, so a person reading either object finds the other.44func idlePodName(player string) string {45 return player + "-idle"46}4748// retiredIdleCommandPodSuffix names the pod that stood beside the idle49// pod in releases before 2026.09.02-002. It held the timers, the focus50// gate, and the press gate, which the client holds now, so nothing51// builds it. The reconcile deletes one that still stands, because a52// live one would step the volume beside the client. This constant and53// the delete that reads it can go once no cluster runs an older54// release.55const retiredIdleCommandPodSuffix = "-command"5657// idleClaimName is the idle pod's claim, the pod's name plus the suffix58// the playback claim uses, so a person reading either object finds the59// other.60func idleClaimName(player string) string {61 return idlePodName(player) + "-devices"62}6364// playerOwner is the ownerReference that makes deleting the Player the65// whole teardown: the garbage collector deletes the idle claim and the66// idle pod the Player owns. The operator deletes either one itself only67// to replace an object that no longer matches the template.68func playerOwner(player *Player) OwnerReference {69 return OwnerReference{70 APIVersion: mediaAPIVersion,71 Kind: "Player",72 Name: player.Metadata.Name,73 UID: player.Metadata.UID,74 Controller: true,75 }76}7778// idlePlayerName is the friendly name the idle screen shows for the whole79// unit. It reads spec.displayName when the household set one, and falls back80// to the Player's object name, so an unnamed Player still shows a name.81func idlePlayerName(player *Player) string {82 if player.Spec.DisplayName != "" {83 return player.Spec.DisplayName84 }85 return player.Metadata.Name86}8788// deviceDisplayName is the friendly name the idle screen shows for one device89// selection. It reads displayName when set. Names are user-set, so the90// fallback stays plain: the DeviceClass name says what kind of device the91// selection is when the household named none.92func deviceDisplayName(device PlayerDevice) string {93 if device.DisplayName != "" {94 return device.DisplayName95 }96 return device.Class97}9899// remoteDisplayName is the friendly name the idle screen shows for one100// controller. It reads displayName when set, and falls back to Name, the101// Remote this entry references.102func remoteDisplayName(remote PlayerRemote) string {103 if remote.DisplayName != "" {104 return remote.DisplayName105 }106 return remote.Name107}108109// idleComponents lists the friendly names of the unit's parts in the order110// the idle screen shows them: the display first, then each sink in spec111// order, then each remote in spec order. Each name resolves to its112// displayName, or a plain fallback when the household named none. A Player113// with no display and no parts yields an empty list, and buildIdlePod then114// sends no parts variable.115func idleComponents(player *Player) []string {116 var components []string117 if player.Spec.Display != nil {118 components = append(components, deviceDisplayName(*player.Spec.Display))119 }120 for _, sink := range player.Spec.Sinks {121 components = append(components, deviceDisplayName(sink))122 }123 for _, remote := range player.Spec.Remotes {124 components = append(components, remoteDisplayName(remote))125 }126 return components127}128129// idleRemoteTopics is one of the unit's controllers as the idle130// client reads it: the topic its presses arrive on and the topic131// its focus mark stands on. The client gates every press on the mark132// naming this Player, and it builds the cycle topic from the focus133// topic.134type idleRemoteTopics struct {135 Events string136 Focus string137}138139// gatherIdleRemotes builds the events and focus topics of every140// controller the Player names, in spec.remotes order. That position is141// the index a focus moment carries, and it is the order the status142// topic lists the parts in, so the two agree. The playback pod's own143// gather sorts by name, and is a separate function for that reason.144// This one reads no API object: the key table is the standing pod's145// business.146func gatherIdleRemotes(player *Player, base string) []idleRemoteTopics {147 namespace := player.Metadata.Namespace148 remotes := make([]idleRemoteTopics, 0, len(player.Spec.Remotes))149 for _, entry := range player.Spec.Remotes {150 remotes = append(remotes, idleRemoteTopics{151 Events: remoteEventsTopic(base, namespace, entry.Name),152 Focus: remoteFocusTopic(base, namespace, entry.Name),153 })154 }155 return remotes156}157158// joinIdleRemotes joins one field of every remote with newlines, the159// same form the parts list travels in.160func joinIdleRemotes(remotes []idleRemoteTopics, field func(idleRemoteTopics) string) string {161 values := make([]string, len(remotes))162 for index, remote := range remotes {163 values[index] = field(remote)164 }165 return strings.Join(values, "\n")166}167168// buildIdleClaim turns one Player into the standing claim for its idle169// pod: the shared draw device on the Player's own screen, and the render170// node the idle client draws through.171//172// The draw request reuses the Player's own display selector, so the idle173// client draws to the same screen a Play does. Its class is the cluster's174// display-draw class, which the operator reads from its environment,175// because the draw companion is cluster policy and not the Player's176// exclusive output class. The display-operator marks that device177// shareable, so the idle pod and a Play pod hold the screen at once. The178// render request mirrors the playback claim, because the idle client179// draws through the GPU and needs the node that renders.180func buildIdleClaim(player *Player, displayClass string) *ResourceClaim {181 claim := &ResourceClaim{182 APIVersion: claimAPIVersion,183 Kind: "ResourceClaim",184 Metadata: ObjectMeta{185 Name: idleClaimName(player.Metadata.Name),186 Namespace: player.Metadata.Namespace,187 OwnerReferences: []OwnerReference{playerOwner(player)},188 },189 }190 claim.add(idleDrawRequest, PlayerDevice{Class: displayClass, Selector: player.Spec.Display.Selector}, nil)191 if player.Spec.Render != nil {192 // The render node takes no toleration, because a GPU does not come193 // and go while the machine runs.194 claim.add(renderRequest, *player.Spec.Render, nil)195 }196 return claim197}198199// buildIdlePod writes the standing idle client pod: one container200// that holds the draw and render requests and draws the clock. The201// client's image is idle.Image, which resolveIdle settles, and the pod202// runs only where the resolved controller is this operator's own.203//204// The client holds the fade and off windows, the focus gate, the shade,205// the volume step, the cycle request, and the panel desire in its own206// process, through the media-screen crate. So the container carries the207// whole contract that crate reads: the two windows, the unit's name,208// the bus address, and every topic the client subscribes to or209// publishes on.210//211// restartPolicy is Always because the pod is a service and not a job: a212// crash restarts it, and the pod ends only when the Player is deleted.213//214// The pod carries the household timezone when the cluster set one, so215// the idle clock reads the same wall-clock zone the playback display216// reads. It carries the Player's friendly name and its parts, which the217// idle screen draws so a person reads what the unit is while no film218// runs. Those two variables are the first paint alone: the client seeds219// the identity block from them before the broker answers, and the first220// retained status replaces them, so an edit to the Player shows with no221// pod restart.222func buildIdlePod(223 player *Player, claim *ResourceClaim, busAddress, topicBase, timeZone string,224 idle resolvedIdle, remotes []idleRemoteTopics,225) *Pod {226 namespace, name := player.Metadata.Namespace, player.Metadata.Name227228 // Every idle client is an image with its own entrypoint, so the229 // container names no command. A Player that states one runs that230 // image in place of the release's own, and a client that draws a231 // screen needs the same things whoever wrote it, so it keeps every232 // claim and every variable below.233 container := Container{234 Name: idleContainer,235 Image: idle.Image,236 }237 // The clock reads TZ against the image's tz database. Set it only when238 // the household stated a zone, so an unset zone leaves the pod on UTC,239 // the way the playback pod does.240 if timeZone != "" {241 container.Env = append(container.Env, EnvVar{Name: timeZoneVariable, Value: timeZone})242 }243 // The idle client holds a window for its whole life, so it arms the244 // client's own watchdog. A compositor that restarts takes the window245 // with it, and a client that kept running windowless would leave the246 // compositor's background on the screen until a person deleted the247 // pod. The client exits instead, and the kubelet restarts the248 // container with backoff until the compositor answers again.249 container.Env = append(container.Env,250 EnvVar{Name: idleWindowGraceVariable, Value: strconv.Itoa(idleWindowGraceSeconds)})251 // The idle screen names the unit and lists its parts, so a person reads252 // what the unit is and what it plays through while no film runs. The name253 // always resolves, so the pod always carries it. The parts join with254 // newlines and travel in one variable, which the client splits. A255 // Player with no listed parts sends no parts variable, so the idle256 // screen draws the name alone.257 container.Env = append(container.Env, EnvVar{Name: idlePlayerNameVariable, Value: idlePlayerName(player)})258 if components := idleComponents(player); len(components) > 0 {259 container.Env = append(container.Env,260 EnvVar{Name: idlePlayerComponentsVariable, Value: strings.Join(components, "\n")})261 }262263 // Every idle client reads the bus for itself, so the container264 // carries the address and the unit's retained state topic.265 container.Env = append(container.Env,266 EnvVar{Name: busAddressVariable, Value: busAddress},267 EnvVar{Name: playerStatusTopicVariable, Value: playerStatusTopic(topicBase, namespace, name)})268 if topic := idleVolumeTopic(player, topicBase); topic != "" {269 container.Env = append(container.Env,270 EnvVar{Name: playerVolumeTopicVariable, Value: topic})271 }272 // The Player's object name, the value every focus mark holds. It273 // is not the friendly name IDLE_PLAYER_NAME carries, because the274 // operator writes marks from metadata.name.275 container.Env = append(container.Env, EnvVar{Name: playerNameVariable, Value: name})276 // The commands topic carries the operator's re-present, which the277 // client answers with a fresh surface. The panel topic is where the278 // client states its desire for the panel, and the operator reads279 // that desire and overrides the screen's Display. Both arrive280 // whole, because the operator holds the topic base and the client281 // parses no topic.282 container.Env = append(container.Env,283 EnvVar{Name: playerCommandsTopicVariable, Value: playerCommandsTopic(topicBase, namespace, name)},284 EnvVar{Name: playerPanelTopicVariable, Value: playerPanelTopic(topicBase, namespace, name)})285 // The two remote lists stay index-aligned, so the client pairs286 // each events topic with the focus topic that carries its mark. A287 // remote's position in them is its spec.remotes order, and the288 // client sends that index with the focus moment. A Player with no289 // remotes sends neither variable, and the fade then runs on the290 // timer alone.291 if len(remotes) > 0 {292 container.Env = append(container.Env,293 EnvVar{294 Name: remoteEventsTopicsVariable,295 Value: joinIdleRemotes(remotes, func(r idleRemoteTopics) string { return r.Events }),296 },297 EnvVar{298 Name: remoteFocusTopicsVariable,299 Value: joinIdleRemotes(remotes, func(r idleRemoteTopics) string { return r.Focus }),300 })301 }302 // Both windows travel on every client, because the resolver settles303 // them for every Player and zero is a policy the client must read,304 // not infer from an absent variable. The off mode stays with the305 // operator, because the operator writes the override.306 container.Env = append(container.Env,307 EnvVar{308 Name: idleFadeAfterSecondsVariable,309 Value: strconv.FormatInt(idle.FadeAfterSeconds, 10),310 },311 EnvVar{312 Name: idleOffAfterSecondsVariable,313 Value: strconv.FormatInt(idle.OffAfterSeconds, 10),314 })315316 // The idle container holds every request the claim carries,317 // because the pod's one job is to draw.318 for _, request := range claimRequests(claim) {319 container.Resources.Claims = append(container.Resources.Claims,320 ContainerClaim{Name: podClaimName, Request: request})321 }322323 return &Pod{324 APIVersion: podAPIVersion,325 Kind: "Pod",326 Metadata: ObjectMeta{327 Name: idlePodName(name),328 Namespace: namespace,329 OwnerReferences: []OwnerReference{playerOwner(player)},330 },331 Spec: PodSpec{332 RestartPolicy: "Always",333 ResourceClaims: []PodResourceClaim{{334 Name: podClaimName,335 ResourceClaimName: claim.Metadata.Name,336 }},337 Containers: []Container{container},338 },339 }340}341342// idleVolumeTopic is the unit's level topic, or empty for a unit with343// no speakers. The volume topic is the speaker gate as well as the344// address, the way wire.go states it. A Player with no sinks has no345// level to mean anything, so the idle client reads no topic here: it346// draws no level and answers no volume press. The same empty string347// goes on the status, so a delegate's client reads the same gate.348func idleVolumeTopic(player *Player, topicBase string) string {349 if len(player.Spec.Sinks) == 0 {350 return ""351 }352 return playerVolumeTopic(topicBase, player.Metadata.Namespace, player.Metadata.Name)353}354355// idleClaimFor is the standing claim on one Player's screen, or nil when356// the Player drives no screen or the cluster names no display-draw class,357// which is how a cluster turns the idle screen off. Every part of the358// pass that reads the idle claim reads it here, so the reconcile and the359// status answer the same question once.360func (o *operator) idleClaimFor(player *Player) *ResourceClaim {361 if player.Spec.Display == nil || o.idleDisplayClass == "" {362 return nil363 }364 return buildIdleClaim(player, o.idleDisplayClass)365}366367// reconcileIdle reconciles one Player into the standing objects368// its resolved controller calls for, all of them owned by the Player. It369// builds nothing when the Player drives no screen or when the cluster370// names no display-draw class.371//372// The controller decides which objects stand.373// media.liken.sh/idle-screen is this operator's own, and it stands the374// claim and the idle client pod. Under media.liken.sh/none nothing375// stands. Every other name is a delegate: the claim stands, and the376// delegate's own operator builds the pod that draws, wired from377// status.idle.378//379// The objects follow the template, the way the standing remote pod does:380// an edit to the Player, or a release that changes either image, deletes381// the stale object and the next pass creates the replacement. Recreating382// the idle pod blinks the idle screen once. A release and a spec edit383// are both deliberate acts, so the pass rolls the pod with no guard.384func (o *operator) reconcileIdle(player *Player, timeZone string, defaultIdle *IdlePolicy) error {385 claim := o.idleClaimFor(player)386 if claim == nil {387 return nil388 }389 idle := resolveIdle(player.Spec.Idle, defaultIdle, o.idleImage)390 namespace, name := player.Metadata.Namespace, player.Metadata.Name391392 screen := standing{namespace: namespace, claimName: claim.Metadata.Name, podName: idlePodName(name)}393 if idle.Controller != idleControllerNone {394 screen.claim = claim395 if idle.Controller == idleControllerOwn {396 remotes := gatherIdleRemotes(player, o.topicBase)397 screen.pod = buildIdlePod(player, claim, o.busAddress, o.topicBase, timeZone, idle, remotes)398 }399 }400 if err := o.reconcileStanding(screen, claimRead{}); err != nil {401 return err402 }403 // The pod an older release stood. This pass wants none under that404 // name, so a live one is deleted and an absent one costs the one405 // GET the standing rule makes. See retiredIdleCommandPodSuffix.406 return o.reconcileStanding(standing{407 namespace: namespace,408 podName: idlePodName(name) + retiredIdleCommandPodSuffix,409 }, claimRead{})410}
images.go 97.3%
1package main23// The images the operator stamps into the pods it creates come from4// its own pod. The Deployment names the operator image once, with a5// tag, and every companion image is that repository with a suffix at6// the same tag: media-operator-player, media-operator-idle, and7// media-operator-sidecar beside media-operator. So one pin in a8// kustomization moves every image together, and no manifest names a9// version twice. PLAYER_IMAGE, IDLE_IMAGE, and SIDECAR_IMAGE still10// win when set, for a test or for a cluster whose pod names its image11// by digest, which has no tag to share.1213import (14 "fmt"15 "os"16 "strings"17)1819// The downward API sets these two, so the operator can read the pod20// it runs in. Nothing else tells a container which pod it is.21const (22 podNameVariable = "POD_NAME"23 podNamespaceVariable = "POD_NAMESPACE"24)2526// The container this binary runs in. Its image is the reference27// every companion image derives from.28const operatorContainerName = "operator"2930// The three images the operator stamps into the pods it creates.31type companionImages struct {32 player string33 idle string34 sidecar string35}3637// resolveImages settles each companion image. A variable that is set38// wins. When every variable is set, the operator reads no pod, so a39// cluster with no downward API can still run it. Otherwise it reads40// its own pod and derives the rest from the operator container's41// image.42func resolveImages(client *Client) (companionImages, error) {43 stated := companionImages{44 player: os.Getenv(playerImageVariable),45 idle: os.Getenv(idleImageVariable),46 sidecar: os.Getenv(sidecarImageVariable),47 }48 if stated.player != "" && stated.idle != "" && stated.sidecar != "" {49 return stated, nil50 }5152 name, namespace := os.Getenv(podNameVariable), os.Getenv(podNamespaceVariable)53 if name == "" || namespace == "" {54 return companionImages{}, fmt.Errorf(55 "%s and %s are unset, so the operator cannot read its own pod to derive %s, %s, and %s",56 podNameVariable, podNamespaceVariable,57 playerImageVariable, idleImageVariable, sidecarImageVariable)58 }59 pod, err := GetPod(client, namespace, name)60 if err != nil {61 return companionImages{}, fmt.Errorf("reading pod %s/%s: %w", namespace, name, err)62 }63 reference := ""64 for _, container := range pod.Spec.Containers {65 if container.Name == operatorContainerName {66 reference = container.Image67 }68 }69 if reference == "" {70 return companionImages{}, fmt.Errorf(71 "pod %s/%s has no container named %s", namespace, name, operatorContainerName)72 }73 derived, err := deriveCompanionImages(reference)74 if err != nil {75 return companionImages{}, err76 }7778 if stated.player == "" {79 stated.player = derived.player80 }81 if stated.idle == "" {82 stated.idle = derived.idle83 }84 if stated.sidecar == "" {85 stated.sidecar = derived.sidecar86 }87 return stated, nil88}8990// deriveCompanionImages names each companion as the operator's91// repository with a suffix, at the operator's tag. An image with no92// tag has no version to share, and the error says what to set.93func deriveCompanionImages(reference string) (companionImages, error) {94 repository, tag, tagged := splitReference(reference)95 if !tagged {96 return companionImages{}, fmt.Errorf(97 "the operator's image %q names no tag, and the companion images take the operator's tag; "+98 "name the image by tag, or set %s, %s, and %s",99 reference, playerImageVariable, idleImageVariable, sidecarImageVariable)100 }101 return companionImages{102 player: repository + "-player:" + tag,103 idle: repository + "-idle:" + tag,104 sidecar: repository + "-sidecar:" + tag,105 }, nil106}107108// splitReference takes the repository and the tag apart. Only the109// part after the last "/" can hold a tag: a "@" there is a digest,110// which names no tag, and the last ":" there splits the tag off. A111// ":" before that "/" is a registry port, so it never splits.112func splitReference(reference string) (repository, tag string, tagged bool) {113 last := reference[strings.LastIndex(reference, "/")+1:]114 if strings.Contains(last, "@") {115 return "", "", false116 }117 colon := strings.LastIndex(last, ":")118 if colon < 0 || colon == len(last)-1 {119 return "", "", false120 }121 split := len(reference) - len(last) + colon122 return reference[:split], reference[split+1:], true123}
input.go 100.0%
1package main23// The input contract across the operator, the standing remote pod,4// and the consumers. The operator compiles each Remote's table over5// the base and publishes it retained on that Remote's keys topic. The6// standing remote pod folds every raw evdev event through the table7// and publishes kernel key names on the events topic. Each consumer8// holds its own table from key names to what it does there. The split9// keeps each vocabulary on its own side: only the operator reads the10// Keymap resource, and only a consumer knows what a key means to it.1112import (13 "encoding/json"14 "io"15)1617// The action words. They are no longer an API vocabulary: no Keymap18// and no controller payload carries one. They are the playback pod's19// internal step between a key name and mpv's own words, and the two20// display verbs, re-present and sleep, that travel on a Player's21// commands topic. cycle-focus never reaches a player program.22const (23 actionPause = "pause"24 actionMute = "mute"25 actionSeek = "seek"26 actionVolume = "volume"27 actionChapter = "chapter"28 actionSubtitles = "subtitles"29 actionAudio = "audio"30 actionInfo = "info"31 actionCycleFocus = "cycle-focus"3233 // The navigation words are what the playback pod sends the display34 // script. The key table in keybindings.go is what reaches them.35 actionUp = "up"36 actionDown = "down"37 actionLeft = "left"38 actionRight = "right"39 actionSelect = "select"40 actionBack = "back"41)4243// actionRePresent is display plumbing, not part of the media vocabulary44// above. The operator publishes it to a Player's commands topic when a45// Play ends. The idle screen client reads it off that topic and maps a46// fresh surface, so a seatless kiosk shell shows the clock again. A47// controller never sends it, so commandFor holds no case for it and it48// reaches no player program.49const actionRePresent = "re-present"5051// A compiledBinding is one row of a Remote's key table: an evdev52// type, code, and value on the left, the kernel key name the pod53// publishes on the right. The names are the API and the numbers are54// the wire, because the pod matches what the kernel gives it. The55// operator compiles the durations to milliseconds so the pod parses56// nothing.57type compiledBinding struct {58 EventType uint16 `json:"type"`59 Code uint16 `json:"code"`60 Value int32 `json:"value"`61 Key string `json:"key"`6263 // RepeatDelay and RepeatInterval are milliseconds. An interval64 // above zero makes the standing pod synthesise the value 2 stream65 // while the control is held. A keyboard needs no row, because the66 // kernel autorepeats for it.67 RepeatDelay int `json:"repeatDelay,omitempty"`68 RepeatInterval int `json:"repeatInterval,omitempty"`69}7071// keyNone is the right side that drops a control. The base passes72// every KEY_* code, so silencing one is a row and not an omission.73const keyNone = "none"7475// keyEvent is what a Remote's events topic carries: the kernel's name76// for the control and the kernel's value, 0 release, 1 press, 277// repeat. A consumer holds no table of numbers.78type keyEvent struct {79 Key string `json:"key"`80 Value int32 `json:"value"`81}8283// mediaCommand is the JSON that travels on a commands topic. On a84// Play's topic it is the surface any program publishes to, a phone or85// a Home Assistant integration alike, and the playback pod's own step86// from a key name to mpv's words. On a Player's topic it is the87// operator's re-present and a delegate client's sleep. No controller88// payload carries one.89type mediaCommand struct {90 Action string `json:"action"`91 Amount int `json:"amount,omitempty"`92}9394// The two evdev event types a keymap can bind. Buttons arrive as95// EV_KEY presses with value 1; the d-pad on a gamepad arrives not as96// buttons but as the EV_ABS hat axes, with -1 and 1 as the presses97// and 0 as the release.98const (99 evKey uint16 = 0x01100 evAbs uint16 = 0x03101)102103// keyCode is one row of the kernel's EV_KEY table, which keycodes.go104// holds whole, generated from input-event-codes.h. The names are the105// API and the numbers are the wire: a Keymap says BTN_SOUTH because106// every Linux controller driver reports the south face button under107// that code, whatever the glyph on the plastic.108type keyCode struct {109 Name string110 Code uint16111}112113// buttonCodes are the evdev key names a Keymap's buttons may use,114// which is the whole EV_KEY name space, so a gamepad's face buttons115// and a media remote's transport keys are alike bindable.116var buttonCodes = codesByName(keyCodes)117118// keyCodeNames names one code back, for a report of what a controller119// declares. Where the kernel gives one code several names, the first120// wins.121var keyCodeNames = namesByCode(keyCodes)122123// axisCodes are the hat axes a Keymap's axes may name. The analog124// sticks are deliberately absent: a resting thumb reports at 250Hz,125// and no key name carries an analog value.126var axisCodes = map[string]uint16{127 "ABS_HAT0X": 0x10,128 "ABS_HAT0Y": 0x11,129}130131// axisCodeNames names one hat axis back, the way keyCodeNames names a132// key code back.133var axisCodeNames = namesByCode([]keyCode{134 {Name: "ABS_HAT0X", Code: 0x10},135 {Name: "ABS_HAT0Y", Code: 0x11},136})137138// codesByName builds the name-to-code lookup a compile reads. Every139// alias resolves to the code it names.140func codesByName(table []keyCode) map[string]uint16 {141 codes := make(map[string]uint16, len(table))142 for _, each := range table {143 codes[each.Name] = each.Code144 }145 return codes146}147148// namesByCode builds the code-to-name lookup a report reads. The first149// name wins, because the table is in the header's order and an alias150// follows the name it aliases.151func namesByCode(table []keyCode) map[uint16]string {152 names := make(map[uint16]string, len(table))153 for _, each := range table {154 if _, named := names[each.Code]; named {155 continue156 }157 names[each.Code] = each.Name158 }159 return names160}161162// The IPC socket's home. mpv serves its JSON IPC socket on an emptyDir163// the playback pod always carries, rather than in the player container's164// private /tmp, because the command sidecar drives the same socket and a165// volume is the only thing two containers share. The volume is166// unconditional, so mpv serves its socket at one path whether or not the167// play binds a remote.168const (169 ipcVolumeName = "ipc"170 ipcMountPath = "/ipc"171 ipcSocketPath = "/ipc/mpv.sock"172)173174// The decoded-art volume the command sidecar and mpv share. The bridge writes175// each logo as raw bgra here, and mpv reads it back by path through176// overlay-add. It is disk-backed, so the art never counts against the pod's177// memory, and its sizeLimit caps how much the bridge can write.178const (179 artVolumeName = "art"180 artMountPath = "/art"181 artSizeLimit = "16Mi"182)183184// The mpv client name the display script registers under. It is the target of185// every script-message-to that carries a navigation action to the display.186const displayClientName = "display"187188// The script-message name the sidecar and the display agree on for a189// presentation block. It sits beside the six navigation actions the190// display already answers.191const presentationMessage = "presentation"192193// displaySummonMessage is the script-message the sidecar sends to summon the194// display after a seek or a chapter jump, so the scrubber shows the new195// position. It sits beside the navigation actions the display already answers.196const displaySummonMessage = "summon"197198// The two script-message names the display and the bridge agree on for art.199// The display broadcasts artRequestMessage to ask for a decoded blob at a200// pixel size. The bridge answers with artReplyMessage, addressed to the201// display, carrying the ready blob.202//203// The three art kinds: the film's logo, the scrub tile, and the playing204// album's cover.205const (206 artRequestMessage = "liken-art-request"207 artReplyMessage = "liken-art"208 artKindLogo = "logo"209 artKindTrickplay = "trickplay"210 artKindAlbum = "album"211)212213// exitMessage is the script-message the display broadcasts when a person214// presses back at the bare video. The command sidecar answers it: it215// publishes the ending to the bus and then quits mpv. The display does not216// quit mpv itself, because the ending must reach the bus before mpv starts217// to tear down the film's surface.218const exitMessage = "liken-exit"219220// presentationRequestMessage is the script-message the display broadcasts221// once, when it loads. The sidecar sends each item's block the moment the222// playlist reaches it, and a block sent before the script registered223// reaches nobody. The display carries no block of its own until one224// arrives, so it asks for the current one as soon as it can answer, and225// the sidecar replays it.226const presentationRequestMessage = "liken-presentation-request"227228// isExitMessage reads a client-message as the display's exit press. The229// first argument names the request, the same shape an art request takes,230// so another script's broadcast is not an ending.231func isExitMessage(args []string) bool {232 return len(args) > 0 && args[0] == exitMessage233}234235// isPresentationRequest reads a client-message as the display asking for the236// current item's block. It takes no arguments, so the name is the whole of it.237func isPresentationRequest(args []string) bool {238 return len(args) > 0 && args[0] == presentationRequestMessage239}240241// exitCommand is mpv's own word for the ending. The exit code is zero, so242// the pod ends Completed, the outcome a film that ran to its end gives,243// and not Error.244func exitCommand() []any {245 return []any{"quit", "0"}246}247248// The interval one trickplay tile covers, used when a Play sets no249// trickplayInterval. Jellyfin generates the sheets at this spacing by250// default, and the library keeps that default.251const defaultTrickplayInterval = "10s"252253// An item with no presentation, and an index the sidecar holds no block254// for, forward this empty object. The display reads it as no declared255// fields and falls back to what mpv reads from the file.256const emptyPresentation = "{}"257258// presentationCommand hands the display one item's block. The block is259// one string argument, so the display reads it as text and needs no260// decode.261func presentationCommand(block json.RawMessage) []any {262 return []any{"script-message-to", displayClientName, presentationMessage, string(block)}263}264265// commandFor is where the action vocabulary becomes the words mpv266// takes on a press. Seek, chapter, and pause carry no-osd, because267// the liken display draws their feedback and mpv's own overlay would268// draw a second time over it. The rest carry osd-auto, so mpv shows269// a short line, such as the track name, that the display does not270// yet draw. Volume and mute are absent on purpose: a press of either271// publishes the unit's next state on the volume topic, and the272// subscription applies it, so neither action becomes a command a273// sidecar sends on the press. An action this build has no case for274// sends nothing, so a command from a newer program has no effect275// rather than a crash.276func commandFor(command mediaCommand) []any {277 switch command.Action {278 case actionPause:279 return []any{"no-osd", "cycle", "pause"}280 case actionSeek:281 return []any{"no-osd", "seek", command.Amount}282 case actionChapter:283 return []any{"no-osd", "add", "chapter", command.Amount}284 case actionSubtitles:285 return []any{"osd-auto", "cycle", "sub"}286 case actionAudio:287 return []any{"osd-auto", "cycle", "audio"}288 case actionInfo:289 return []any{"expand-properties", "show-text", "${filename}\n${time-pos} / ${duration}", 4000}290 case actionUp, actionDown, actionLeft, actionRight, actionSelect, actionBack:291 // A navigation action reaches the display script over script-message-to,292 // and the display draws its own feedback, so it carries no osd-auto prefix293 // and becomes no native mpv command.294 return []any{"script-message-to", displayClientName, command.Action}295 }296 return nil297}298299// feedbackFor returns the follow-up the sidecar sends after an mpv command,300// or nil when none is needed. A seek and a chapter jump move the playhead with301// no on-screen mark of their own, so the sidecar summons the display and its302// scrubber shows the new position. mpv's pause observer summons the display on303// its own, so pause needs no follow-up here.304func feedbackFor(command mediaCommand) []any {305 switch command.Action {306 case actionSeek, actionChapter:307 return []any{"script-message-to", displayClientName, displaySummonMessage}308 }309 return nil310}311312// sendCommand writes one newline-delimited JSON command, the shape313// mpv's IPC socket accepts.314func sendCommand(writer io.Writer, command []any) error {315 return json.NewEncoder(writer).Encode(mpvCommand{Command: command})316}
keybindings.go 100.0%
1package main23// The playback pod's table from key names to what they do during a4// film, and the documentation of that pod's controls. This is the5// consumer's half of the two layers: the pod binds the kernel's names6// the way Kodi and mpv bind them, and the amounts here are this7// consumer's defaults, stated in no Keymap. Four keys act on a repeat8// as well as a press, because they are the four a person holds: a9// seek, a chapter step, a volume step, and an arrow.1011// The amounts. They are this pod's own defaults. A cluster that wants12// other numbers gets them from a later preferences tier, and no13// per-controller table states them.14const (15 seekSeconds = 1016 volumeStep = 517 chapterStep = 118)1920// A keyBinding is one row: the command the key means, and whether a21// held key repeats it. A key that does not repeat acts on the press22// alone, so a held pause toggles once.23type keyBinding struct {24 command mediaCommand25 repeats bool26}2728// The table. Each group of names is one command: the four transport29// names a keyboard and a media remote report for play and pause, the30// four names a shell sends for OK, and the three it sends for back.31// KEY_CYCLEWINDOWS asks the operator to move the focus mark and32// reaches no player program. The reserved keys, KEY_HOMEPAGE,33// KEY_WWW, KEY_POWER, and BTN_MODE, are absent on purpose: they belong34// to a home surface this operator does not own.35var playbackKeys = map[string]keyBinding{36 "KEY_PLAYPAUSE": {command: mediaCommand{Action: actionPause}},37 "KEY_PLAY": {command: mediaCommand{Action: actionPause}},38 "KEY_PAUSE": {command: mediaCommand{Action: actionPause}},39 "KEY_PLAYCD": {command: mediaCommand{Action: actionPause}},4041 "KEY_REWIND": {command: mediaCommand{Action: actionSeek, Amount: -seekSeconds}, repeats: true},42 "KEY_FASTFORWARD": {command: mediaCommand{Action: actionSeek, Amount: seekSeconds}, repeats: true},4344 "KEY_PREVIOUSSONG": {command: mediaCommand{Action: actionChapter, Amount: -chapterStep}, repeats: true},45 "KEY_NEXTSONG": {command: mediaCommand{Action: actionChapter, Amount: chapterStep}, repeats: true},4647 "KEY_VOLUMEUP": {command: mediaCommand{Action: actionVolume, Amount: volumeStep}, repeats: true},48 "KEY_VOLUMEDOWN": {command: mediaCommand{Action: actionVolume, Amount: -volumeStep}, repeats: true},49 "KEY_MUTE": {command: mediaCommand{Action: actionMute}},5051 "KEY_SUBTITLE": {command: mediaCommand{Action: actionSubtitles}},52 "KEY_AUDIO": {command: mediaCommand{Action: actionAudio}},53 "KEY_INFO": {command: mediaCommand{Action: actionInfo}},5455 "KEY_UP": {command: mediaCommand{Action: actionUp}, repeats: true},56 "KEY_DOWN": {command: mediaCommand{Action: actionDown}, repeats: true},57 "KEY_LEFT": {command: mediaCommand{Action: actionLeft}, repeats: true},58 "KEY_RIGHT": {command: mediaCommand{Action: actionRight}, repeats: true},5960 "KEY_ENTER": {command: mediaCommand{Action: actionSelect}},61 "KEY_OK": {command: mediaCommand{Action: actionSelect}},62 "KEY_SELECT": {command: mediaCommand{Action: actionSelect}},63 "KEY_KPENTER": {command: mediaCommand{Action: actionSelect}},6465 "KEY_BACK": {command: mediaCommand{Action: actionBack}},66 "KEY_ESC": {command: mediaCommand{Action: actionBack}},67 "KEY_EXIT": {command: mediaCommand{Action: actionBack}},6869 "KEY_CYCLEWINDOWS": {command: mediaCommand{Action: actionCycleFocus}},70}7172// commandForKey reads one key event as this pod's command. Value 073// does nothing, because the release of a key this pod acted on has no74// meaning here. Value 2 acts only for the keys that repeat. A key with75// no row, which is most of a keyboard, does nothing.76func commandForKey(event keyEvent) (mediaCommand, bool) {77 binding, bound := playbackKeys[event.Key]78 if !bound {79 return mediaCommand{}, false80 }81 switch event.Value {82 case 1:83 return binding.command, true84 case 2:85 if binding.repeats {86 return binding.command, true87 }88 }89 return mediaCommand{}, false90}
keymap.go 93.8%
1package main23// The compile step. A Keymap is written in names, evdev's on both4// sides, and the standing pod matches numbers. This file turns one5// Keymap's names into rows, and basekeys.go folds them over the base.6// The compile runs in the operator, before any pod reads the table,7// so a name that means nothing is logged instead of crash-looping a8// pod.910import (11 "errors"12 "fmt"13 "sort"14 "time"15)1617// The repeat defaults, applied when a binding sets a repeat block but18// leaves a field empty. The delay is long enough that a tap does not19// repeat; the interval is a middling rate a keymap tunes per binding.20const (21 defaultRepeatDelay = 400 * time.Millisecond22 defaultRepeatInterval = 300 * time.Millisecond23)2425// A boundRemote is one Remote as the pod builders need it: its name,26// the events topic the command sidecar reads, and the focus topic it27// gates on. The gather sets the name, and the operator fills the two28// topics in reconcile, because the topic base lives with the29// operator.30type boundRemote struct {31 Name string32 EventsTopic string33 FocusTopic string34}3536// compileRows turns one Keymap into its own rows, before the fold37// over the base. A button compiles to EV_KEY with value 1, the press,38// because the release and the autorepeat carry the same name. An axis39// compiles to EV_ABS with the value the entry states, because a hat's40// two directions arrive as -1 and 1 on one code.41func compileRows(keymap *Keymap) ([]compiledBinding, error) {42 name := keymap.Metadata.Name43 var bindings []compiledBinding4445 for _, button := range keymap.Spec.Buttons {46 code, known := buttonCodes[button.Press]47 if !known {48 return nil, fmt.Errorf(49 "the Keymap %s binds %s, which is not an evdev button name this operator knows",50 name, button.Press)51 }52 if err := checkKey(name, button.Press, button.Key); err != nil {53 return nil, err54 }55 binding := compiledBinding{56 EventType: evKey,57 Code: code,58 Value: 1,59 Key: button.Key,60 }61 if err := applyRepeat(&binding, name, button.Press, button.Repeat); err != nil {62 return nil, err63 }64 bindings = append(bindings, binding)65 }6667 for _, axis := range keymap.Spec.Axes {68 code, known := axisCodes[axis.Axis]69 if !known {70 return nil, fmt.Errorf(71 "the Keymap %s binds the axis %s, which is not one of the hat axes this operator knows",72 name, axis.Axis)73 }74 entry := fmt.Sprintf("%s %d", axis.Axis, axis.Value)75 if err := checkKey(name, entry, axis.Key); err != nil {76 return nil, err77 }78 binding := compiledBinding{79 EventType: evAbs,80 Code: code,81 Value: int32(axis.Value),82 Key: axis.Key,83 }84 if err := applyRepeat(&binding, name, entry, axis.Repeat); err != nil {85 return nil, err86 }87 bindings = append(bindings, binding)88 }8990 if len(bindings) == 0 {91 return nil, fmt.Errorf("the Keymap %s binds nothing; it needs at least one button or axis", name)92 }93 return bindings, nil94}9596// checkKey holds the right side to the kernel's own names, plus none,97// which drops the control. The CRD pattern states the shape, and the98// compile states the rule again, because a resource can predate the99// rule or arrive through a server that never validated it, and this100// is the last gate before a pod reads the table.101func checkKey(keymap, entry, key string) error {102 if key == keyNone {103 return nil104 }105 if _, known := buttonCodes[key]; !known {106 return fmt.Errorf("the Keymap %s maps %s to %s, which is not an evdev key name this operator knows",107 keymap, entry, key)108 }109 return nil110}111112// applyRepeat folds a Keymap's repeat block into the row. A row with113// no block reports only what the kernel reports. A block turns114// synthesis on, and an empty delay or interval takes the default. The115// operator parses the durations here, so the pod carries milliseconds116// and parses nothing.117func applyRepeat(binding *compiledBinding, keymap, entry string, repeat *KeymapRepeat) error {118 if repeat == nil {119 return nil120 }121 delay, err := repeatDuration(keymap, entry, "delay", repeat.Delay, defaultRepeatDelay)122 if err != nil {123 return err124 }125 interval, err := repeatDuration(keymap, entry, "interval", repeat.Interval, defaultRepeatInterval)126 if err != nil {127 return err128 }129 binding.RepeatDelay = int(delay / time.Millisecond)130 binding.RepeatInterval = int(interval / time.Millisecond)131 return nil132}133134// repeatDuration reads one repeat field, an empty value taking the135// default. The compile is the gate a bad duration hits, so a Keymap with136// a value like "soon" fails the Play with a message instead of reaching a137// pod that cannot parse it.138func repeatDuration(keymap, entry, field, value string, fallback time.Duration) (time.Duration, error) {139 if value == "" {140 return fallback, nil141 }142 parsed, err := time.ParseDuration(value)143 if err != nil {144 return 0, fmt.Errorf("the Keymap %s sets the repeat %s on %s to %q, which is not a duration like 400ms",145 keymap, field, entry, value)146 }147 if parsed <= 0 {148 return 0, fmt.Errorf("the Keymap %s sets the repeat %s on %s to %q, which is not a positive duration",149 keymap, field, entry, value)150 }151 return parsed, nil152}153154// gatherRemotes reads every Remote a Player owns. It reads155// spec.remotes in name order, because the result becomes a pod spec,156// and a pod spec built twice from the same resources must be the same157// spec. A named Remote that does not exist fails the gather, and the158// message names it. The table is the standing pod's business,159// published on the Remote's own keys topic, so nothing here reads a160// Keymap.161func gatherRemotes(c *Client, player *Player) ([]boundRemote, error) {162 namespace := player.Metadata.Namespace163164 entries := make([]PlayerRemote, len(player.Spec.Remotes))165 copy(entries, player.Spec.Remotes)166 sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })167168 bound := make([]boundRemote, 0, len(entries))169 for _, entry := range entries {170 remote, err := GetRemote(c, namespace, entry.Name)171 if errors.Is(err, ErrNotFound) {172 return nil, fmt.Errorf("the Player %s names the Remote %s, which does not exist in this namespace",173 player.Metadata.Name, entry.Name)174 }175 if err != nil {176 return nil, err177 }178 bound = append(bound, boundRemote{Name: remote.Metadata.Name})179 }180 return bound, nil181}
local/edl/main.go 8.7%
1package main23// edl is a workstation testing utility. It builds and runs on its own, with4// `go run ./local/edl`, and no part of it ships in the media-operator binary.5// The local/music script runs it to build the two things a music run needs.67// An album plays as one EDL timeline: mpv reads it as one item with one8// duration and one chapter per track, so the display drives track selection9// with the chapter machinery a film already uses. The tool prints that10// timeline by default, and with -block it prints the presentation block that11// carries the album's words.1213// The timeline this tool prints is the one the player shim writes in the14// pod, because both call the edl package, so what a person sees on a15// workstation is what a Play runs.1617import (18 "encoding/json"19 "flag"20 "fmt"21 "os"2223 "github.com/liken-sh/media-operator/edl"24)2526// The presentation block this tool prints. The field order here sets the27// JSON key order the block prints with.28//29// The block is meant to be pasted into a Play item's presentation, so it30// carries the two fields the operator and the shim read as a declaration,31// and the year is a number because the API's year is a number.32type block struct {33 Type string `json:"type"`34 Hint string `json:"hint"`35 Artist string `json:"artist,omitempty"`36 Album string `json:"album,omitempty"`37 Year int `json:"year,omitempty"`38}3940func main() {41 asBlock := flag.Bool("block", false, "print the presentation block instead of the timeline")42 flag.Parse()43 if flag.NArg() != 1 {44 fmt.Fprintln(os.Stderr, "usage: edl [-block] <album-dir>")45 os.Exit(2)46 }4748 files, err := edl.AlbumFiles(flag.Arg(0))49 if err != nil {50 fmt.Fprintf(os.Stderr, "edl: %v\n", err)51 os.Exit(1)52 }53 if len(files) == 0 {54 fmt.Fprintf(os.Stderr, "edl: no audio files in %q\n", flag.Arg(0))55 os.Exit(1)56 }5758 if *asBlock {59 printBlock(files)60 return61 }62 fmt.Print(edl.Timeline(files))63}6465// albumBlock is the album's words as one presentation block. The type and the66// hint are the tool's own, because a folder of audio files is a music album67// whatever its tags say, and the rest is what the files state.68//69// The hint is what makes the pod expand the folder, so a block printed70// without it would play nothing.71func albumBlock(files []string) block {72 facts := edl.AlbumFacts(files)73 return block{74 Type: "music",75 Hint: "album",76 Artist: facts.Artist,77 Album: facts.Album,78 Year: facts.Year,79 }80}8182// printBlock writes the block as one JSON object on stdout, the shape the Play83// carries under an item's presentation.84func printBlock(files []string) {85 encoded, err := json.Marshal(albumBlock(files))86 if err != nil {87 fmt.Fprintf(os.Stderr, "edl: %v\n", err)88 os.Exit(1)89 }90 fmt.Println(string(encoded))91}
local/present/main.go 86.2%
1package main23// present is a workstation testing utility. It builds and runs on its own,4// with `go run ./local/present`, and no part of it ships in the media-operator5// binary. The local/video script runs it to build the block it hands to6// serve-art.78// present reads the NFO that sits beside a media file and prints the9// presentation block the liken display expects. The block is one JSON object10// on stdout. A movie folder holds a sibling movie.nfo. An episode holds a11// sibling <basename>.nfo with a <season> and an <episode>.12//13// When a logo art file or a trickplay directory sits beside the media, the14// block carries its path, so the bridge decodes the art and crops the tiles.15//16// The art does not need an NFO. With no NFO the block starts empty, and the17// display falls back to mpv's own media-title for the name. The art fields are18// added when the files are present, so a film with trickplay but no NFO still19// shows the scrub thumbnail.2021import (22 "encoding/json"23 "encoding/xml"24 "fmt"25 "os"26 "path/filepath"27 "strconv"28 "strings"29)3031// The field order here sets the JSON key order the block prints with. A32// pointer field marks a number the NFO did not carry, so a real season zero33// survives and an absent value is omitted.34type block struct {35 Type string `json:"type,omitempty"`36 Hint string `json:"hint,omitempty"`37 Title string `json:"title,omitempty"`38 Year *int `json:"year,omitempty"`39 Series string `json:"series,omitempty"`40 Season *int `json:"season,omitempty"`41 Episode *int `json:"episode,omitempty"`42 EpisodeTitle string `json:"episodeTitle,omitempty"`43 Date string `json:"date,omitempty"`44 Logo string `json:"logo,omitempty"`45 Trickplay string `json:"trickplay,omitempty"`46}4748// An NFO is read as the direct children of its root, so the root tag itself49// does not matter. A movie NFO roots at <movie> and an episode NFO at50// <episodedetails>, and both read the same way.51type document struct {52 Elements []element `xml:",any"`53}5455type element struct {56 XMLName xml.Name57 Text string `xml:",chardata"`58}5960// has reports whether the document holds a <tag>, whatever its text.61func (d document) has(tag string) bool {62 _, found := d.find(tag)63 return found64}6566func (d document) find(tag string) (element, bool) {67 for _, el := range d.Elements {68 if el.XMLName.Local == tag {69 return el, true70 }71 }72 return element{}, false73}7475// text returns the stripped text of the first <tag>, and false when it is76// absent or empty.77func (d document) text(tag string) (string, bool) {78 el, found := d.find(tag)79 if !found {80 return "", false81 }82 value := strings.TrimSpace(el.Text)83 if value == "" {84 return "", false85 }86 return value, true87}8889// yearOf returns the release year, from <year>, or the leading year of90// <premiered>.91func (d document) yearOf() (*int, error) {92 raw, found := d.text("year")93 if !found {94 premiered, hasPremiered := d.text("premiered")95 if !hasPremiered {96 return nil, nil97 }98 raw = premiered99 if len(raw) > 4 {100 raw = raw[:4]101 }102 }103 return number(raw)104}105106func number(raw string) (*int, error) {107 value, err := strconv.Atoi(raw)108 if err != nil {109 return nil, err110 }111 return &value, nil112}113114// findNFO returns the NFO for a media file: a sibling movie.nfo for a movie,115// or a sibling <basename>.nfo for an episode. The episode NFO wins when both116// exist, so a file named for its episode keeps its own metadata.117func findNFO(media string) (string, bool) {118 episodeNFO := strings.TrimSuffix(media, filepath.Ext(media)) + ".nfo"119 movieNFO := filepath.Join(filepath.Dir(media), "movie.nfo")120 if exists(episodeNFO) {121 return episodeNFO, true122 }123 if exists(movieNFO) {124 return movieNFO, true125 }126 return "", false127}128129// findLogo returns the logo art beside the media, as an absolute path. A file130// named for the media wins over a folder-wide one, so an item with its own131// logo keeps it. The bridge opens this path and decodes it.132func findLogo(media string) string {133 parent := filepath.Dir(media)134 stem := stemOf(media)135 candidates := []string{136 filepath.Join(parent, stem+"-clearlogo.png"),137 filepath.Join(parent, stem+"-logo.png"),138 filepath.Join(parent, "clearlogo.png"),139 filepath.Join(parent, "logo.png"),140 }141 for _, candidate := range candidates {142 if exists(candidate) {143 return absolute(candidate)144 }145 }146 return ""147}148149// findTrickplay returns the trickplay directory beside the media, as an150// absolute path. Jellyfin names it for the media file, so `X.mkv` has a151// sibling `X.trickplay` directory of sprite sheets. The bridge reads the152// sheets and crops one tile per scrub position.153func findTrickplay(media string) string {154 trickplay := filepath.Join(filepath.Dir(media), stemOf(media)+".trickplay")155 info, err := os.Stat(trickplay)156 if err != nil || !info.IsDir() {157 return ""158 }159 return absolute(trickplay)160}161162func stemOf(media string) string {163 name := filepath.Base(media)164 return strings.TrimSuffix(name, filepath.Ext(name))165}166167func exists(path string) bool {168 _, err := os.Stat(path)169 return err == nil170}171172func absolute(path string) string {173 full, err := filepath.Abs(path)174 if err != nil {175 return path176 }177 return full178}179180func movieBlock(doc document) (block, error) {181 made := block{Type: "video", Hint: "movie"}182 title, found := doc.text("title")183 if found {184 made.Title = title185 }186 year, err := doc.yearOf()187 if err != nil {188 return block{}, err189 }190 made.Year = year191 return made, nil192}193194func seriesBlock(doc document) (block, error) {195 made := block{Type: "video", Hint: "series"}196 series, found := doc.text("showtitle")197 if found {198 made.Series = series199 }200 season, found := doc.text("season")201 if found {202 value, err := number(season)203 if err != nil {204 return block{}, err205 }206 made.Season = value207 }208 episode, found := doc.text("episode")209 if found {210 value, err := number(episode)211 if err != nil {212 return block{}, err213 }214 made.Episode = value215 }216 episodeTitle, found := doc.text("title")217 if found {218 made.EpisodeTitle = episodeTitle219 }220 date, found := doc.text("aired")221 if found {222 made.Date = date223 }224 return made, nil225}226227// blockFor returns a series block when the NFO declares a <season>, and a228// movie block otherwise. A sibling movie.nfo is a movie regardless.229func blockFor(nfo string) (block, error) {230 raw, err := os.ReadFile(nfo)231 if err != nil {232 return block{}, err233 }234 var doc document235 if err := xml.Unmarshal(raw, &doc); err != nil {236 return block{}, err237 }238 if filepath.Base(nfo) != "movie.nfo" && doc.has("season") {239 return seriesBlock(doc)240 }241 return movieBlock(doc)242}243244// presentationBlock builds the block for one media file: the NFO metadata when245// an NFO sits beside it, and the art fields when the art files are present.246func presentationBlock(media string) (block, error) {247 made := block{}248 nfo, found := findNFO(media)249 if found {250 fromNFO, err := blockFor(nfo)251 if err != nil {252 return block{}, err253 }254 made = fromNFO255 }256 made.Logo = findLogo(media)257 made.Trickplay = findTrickplay(media)258 return made, nil259}260261func main() {262 if len(os.Args) != 2 {263 fmt.Fprintln(os.Stderr, "usage: present <media-file>")264 os.Exit(2)265 }266 made, err := presentationBlock(os.Args[1])267 if err != nil {268 fmt.Fprintln(os.Stderr, err)269 os.Exit(1)270 }271 encoded, err := json.Marshal(made)272 if err != nil {273 fmt.Fprintln(os.Stderr, err)274 os.Exit(1)275 }276 fmt.Println(string(encoded))277}
main.go 0.0%
1// The media operator reconciles Player and Play resources into2// playback pods, and Remote resources into standing pods that read a3// controller. A Player declares a unit of equipment; a Play runs media4// on it; a Remote drives a Play. The operator turns them into claims5// on the hardware operators' devices, the pods that perform the work,6// and the statuses a person reads.7//8// One binary, five roles, the way the audio operator's one image runs9// in several roles. With no argument it is the operator: a Deployment10// that watches Plays, Remotes, Players, and Keymaps, creates claims and11// pods, publishes each Remote's key table, and writes every status. As12// `player` it is the playback pod's entrypoint shim: it appends the13// display's app-id flag and execs mpv. As `remote` it is the standing14// remote pod: it reads a controller's input nodes, folds each event15// through the table the operator publishes for that Remote, and16// publishes the kernel's key name to the bus. As `command` it is the17// playback pod's command sidecar: it owns mpv's IPC socket, reads the18// controllers' key events and the commands topic, and publishes the19// report.20//21// As `serve-art` it is the command sidecar's decode side alone, run against a22// plain mpv socket for local display work with no cluster and no bus. It runs23// the same decode code the sidecar runs.24//25// The split is the trust boundary. The playback pod decodes media26// pulled off the network, so it is the least trusted process in the27// system and holds no Kubernetes credentials. Its containers speak28// only to the bus and to the local mpv socket, and the operator alone29// writes a Play's status.30package main3132import "os"3334// The arguments that select the pod roles. The operator writes each35// into a container's command, over the image's entrypoint. The operator36// itself runs with no argument.37const (38 playerMode = "player"39 remoteMode = "remote"40 commandMode = "command"41 artServeMode = "serve-art"42)4344func main() {45 if len(os.Args) > 1 {46 switch os.Args[1] {47 case playerMode:48 runPlayer(os.Args[2:])49 return50 case remoteMode:51 runReader()52 return53 case commandMode:54 runCommand()55 return56 case artServeMode:57 runArtServe()58 return59 }60 }61 operate()62}
mpvipc.go 88.9%
1package main23// mpv's JSON IPC socket carries newline-delimited JSON objects in4// both directions: commands in, replies and events out. It is the5// one interface mpv offers a program, and the supervisor drives it6// instead of parsing mpv's terminal output, which is written for a7// person and changes shape freely.8//9// The supervisor observes properties instead of polling them. mpv10// sends the current value once at observe time and then one event11// per change, so the socket carries the whole story with no timer of12// the supervisor's own.1314import (15 "bufio"16 "context"17 "encoding/json"18 "fmt"19 "io"20 "net"21 "slices"22 "time"23)2425// mpvSocketPath is a variable rather than a constant because the26// same string appears in mpv's --input-ipc-server argument: one27// value is what makes the two ends meet, and a test moves both at28// once.29var mpvSocketPath = ipcSocketPath3031// mpv creates the socket a moment after it starts, so a dial retries.32// mpvDialDelay is the pause between tries, a variable so a test drives33// the retry in milliseconds.34var mpvDialDelay = time.Second3536// The properties the Play's status is made of. The supervisor asks for no37// others, because every event mpv sends costs a decode. The two current-tracks38// entries are the languages mpv selected.39const (40 audioLanguageProperty = "current-tracks/audio/lang"41 subtitleLanguageProperty = "current-tracks/sub/lang"4243 // The playlist is the one observed property no report carries. The44 // bridge resolves each item's album art from it once, before the45 // display asks for any of it. It is observed rather than asked for,46 // because the supervisor observes properties and never polls.47 playlistProperty = "playlist"48)4950var observedProperties = []string{51 "pause", "playlist-pos", "time-pos", "duration",52 audioLanguageProperty, subtitleLanguageProperty,53 playlistProperty,54}5556// propertyChangeEvent carries an observed property's new value.57// clientMessageEvent carries a script-message any client broadcast, which is58// how the display asks the bridge to decode a logo. mpv sends many other59// events through the same socket, and readEvents drops them.60const (61 propertyChangeEvent = "property-change"62 clientMessageEvent = "client-message"63)6465// mpvCommand is the shape mpv accepts: an array of the command name66// followed by its arguments.67type mpvCommand struct {68 Command []any `json:"command"`69}7071// mpvMessage is the shape of one line mpv writes. Command replies72// and events share the socket, so every field here is absent from73// some of the lines that arrive.74type mpvMessage struct {75 Event string `json:"event"`76 ID int `json:"id"`77 Name string `json:"name"`78 Data json.RawMessage `json:"data"`79 Args []string `json:"args"`80}8182// clientMessage is one script-message the display broadcast, as its arguments83// reach the bridge over the socket. The first argument names the request, so84// the bridge tells its own requests from any other script's traffic.85type clientMessage struct {86 Args []string87}8889// propertyChange is one observed property's new value. The data90// stays raw because each property has its own type, and only the91// fold that applies the change knows which one to decode.92type propertyChange struct {93 Name string94 Data json.RawMessage95}9697// known drops a null datum before anything decodes it. mpv writes98// null for a property it has no value for yet, such as time-pos99// before the first frame, and a null decoded into a number would100// report a position of zero that the player never held.101func (c propertyChange) known() bool {102 return string(c.Data) != "null" && len(c.Data) > 0103}104105// dialMPV retries until it connects or the context ends. The command106// sidecar is a native sidecar the kubelet starts before mpv, so the107// socket can be minutes away, and the wait has no deadline of its own:108// nothing but mpv appearing or the kubelet's SIGTERM ends it. Waiting is109// the whole job when mpv is not up yet, so a wall-clock limit would only110// quit early on a slow image pull or a display claim that is not ready.111func dialMPV(ctx context.Context, path string) (net.Conn, error) {112 for ctx.Err() == nil {113 connection, err := net.Dial("unix", path)114 if err == nil {115 return connection, nil116 }117 select {118 case <-ctx.Done():119 case <-time.After(mpvDialDelay):120 }121 }122 return nil, ctx.Err()123}124125// observeProperties gives each property its own observe id because126// mpv requires one, but the supervisor keys off the name in each127// event rather than the id, so the ids only have to be distinct.128func observeProperties(writer io.Writer, names []string) error {129 encoder := json.NewEncoder(writer)130 for index, name := range names {131 command := mpvCommand{Command: []any{"observe_property", index + 1, name}}132 if err := encoder.Encode(command); err != nil {133 return fmt.Errorf("observe %s: %w", name, err)134 }135 }136 return nil137}138139// maxEventLine bounds one line of mpv's output, far above anything140// the four observed properties produce.141const maxEventLine = 1 << 20142143// readEvents delivers the observed property changes and the client messages,144// and drops everything else: replies, other events, and any line that does not145// decode. mpv's protocol grows new events, and one the supervisor cannot read146// is no reason to stop reporting the ones it can. It ends when the socket147// closes, which is how mpv says the run is over.148func readEvents(ctx context.Context, reader io.Reader, changes chan<- propertyChange, messages chan<- clientMessage) error {149 scanner := bufio.NewScanner(reader)150 scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), maxEventLine)151 for scanner.Scan() {152 var message mpvMessage153 if err := json.Unmarshal(scanner.Bytes(), &message); err != nil {154 continue155 }156 switch message.Event {157 case propertyChangeEvent:158 if !slices.Contains(observedProperties, message.Name) {159 continue160 }161 select {162 case changes <- propertyChange{Name: message.Name, Data: message.Data}:163 case <-ctx.Done():164 return ctx.Err()165 }166 case clientMessageEvent:167 select {168 case messages <- clientMessage{Args: message.Args}:169 case <-ctx.Done():170 return ctx.Err()171 }172 }173 }174 return scanner.Err()175}
mqtt.go 95.0%
1package main23// The MQTT 3.1.1 wire codec, written straight against the protocol4// the way apiclient.go writes the Kubernetes client. The broker is5// Mosquitto and the protocol is a published standard, so a client6// that speaks the few packets this operator needs costs less than a7// third-party library and its release cadence. This operator uses8// QoS 0 alone: input events are momentary and a report is republished9// on every reconnect, so the delivery guarantees of QoS 1 and QoS 210// buy nothing here.11//12// The functions below build and read whole packets and hold no13// connection state. bus.go owns the socket and drives them.1415import (16 "bufio"17 "fmt"18 "io"19)2021// The MQTT control packet types, in the high nibble of a fixed22// header's first byte. The low nibble carries per-type flags, which23// matter here only for a PUBLISH, where bit 0 is the retain flag.24const (25 mqttConnect = 0x1026 mqttConnack = 0x2027 mqttPublish = 0x3028 mqttSubscribe = 0x8029 mqttSuback = 0x9030 mqttPingreq = 0xC031 mqttPingresp = 0xD032)3334// The MQTT 3.1.1 protocol name and level. The name is the literal35// string "MQTT" and the level is 4, which together tell the broker36// which version of the protocol this client speaks.37const (38 mqttProtocolName = "MQTT"39 mqttProtocolLevel = 0x0440)4142// The CONNECT flag bits this client sets. Clean session starts every43// connection with no server-side state, which is correct because the44// client re-subscribes and re-publishes on each connect. The will45// bits arrive only when the caller states a will. Username and46// password stay unset, because the in-cluster network is the trust47// boundary and the broker accepts the cluster's own pods.48const (49 connectCleanSession = 0x0250 connectWillFlag = 0x0451 connectWillRetain = 0x2052)5354// encodeRemainingLength writes the packet length in the variable-byte55// form the protocol uses: seven bits of length per byte, and the high56// bit set on every byte but the last. One byte covers up to 127, and57// four bytes cover the 268435455 the protocol allows.58func encodeRemainingLength(length int) []byte {59 var encoded []byte60 for {61 digit := byte(length % 128)62 length /= 12863 if length > 0 {64 digit |= 0x8065 }66 encoded = append(encoded, digit)67 if length == 0 {68 return encoded69 }70 }71}7273// decodeRemainingLength reads the variable-byte length back. It reads74// at most four bytes, because a fifth would exceed the protocol's75// limit and marks a stream that has lost frame alignment.76func decodeRemainingLength(reader io.ByteReader) (int, error) {77 length := 078 multiplier := 179 for count := 0; count < 4; count++ {80 digit, err := reader.ReadByte()81 if err != nil {82 return 0, err83 }84 length += int(digit&0x7F) * multiplier85 if digit&0x80 == 0 {86 return length, nil87 }88 multiplier *= 12889 }90 return 0, fmt.Errorf("mqtt: remaining length runs past four bytes")91}9293// appendString writes one length-prefixed UTF-8 string, the shape the94// protocol uses for a topic, a filter, and the client identifier: two95// bytes of length, most significant first, then the bytes.96func appendString(buffer []byte, value string) []byte {97 buffer = append(buffer, byte(len(value)>>8), byte(len(value)))98 return append(buffer, value...)99}100101// appendBytes writes one length-prefixed byte string, the shape a will102// payload takes.103func appendBytes(buffer []byte, value []byte) []byte {104 buffer = append(buffer, byte(len(value)>>8), byte(len(value)))105 return append(buffer, value...)106}107108// packet frames one control packet: a fixed-header first byte, then109// the remaining length, then the body. Every encode function ends110// here, so the length is computed once from the finished body.111func packet(first byte, body []byte) []byte {112 frame := make([]byte, 0, 2+len(body))113 frame = append(frame, first)114 frame = append(frame, encodeRemainingLength(len(body))...)115 return append(frame, body...)116}117118// encodeConnect builds the first packet the client sends. The body is119// the protocol name and level, one flags byte, the keepalive in120// seconds, and the payload: the client identifier and, when the caller121// states one, the will topic and payload. The client authenticates122// with nothing more than its identifier, because the broker accepts123// the cluster's own pods.124func encodeConnect(clientID string, keepalive uint16, will *busWill) []byte {125 flags := byte(connectCleanSession)126 if will != nil {127 flags |= connectWillFlag128 if will.Retained {129 flags |= connectWillRetain130 }131 }132133 var body []byte134 body = appendString(body, mqttProtocolName)135 body = append(body, mqttProtocolLevel, flags)136 body = append(body, byte(keepalive>>8), byte(keepalive))137 body = appendString(body, clientID)138 if will != nil {139 body = appendString(body, will.Topic)140 body = appendBytes(body, will.Payload)141 }142 return packet(mqttConnect, body)143}144145// encodePublish builds a QoS 0 PUBLISH. The retain flag is bit 0 of146// the first byte, and a retained publish tells the broker to hold this147// payload as the topic's last value and deliver it to every later148// subscriber. QoS 0 carries no packet identifier, so the body is the149// length-prefixed topic and then the raw payload.150func encodePublish(topic string, payload []byte, retained bool) []byte {151 first := byte(mqttPublish)152 if retained {153 first |= 0x01154 }155 body := appendString(nil, topic)156 body = append(body, payload...)157 return packet(first, body)158}159160// encodeSubscribe builds a SUBSCRIBE for one topic filter at QoS 0.161// The fixed header is 0x82, because bit 1 is reserved and must be set162// on a SUBSCRIBE. The body is the packet identifier the broker echoes163// in its SUBACK, then the length-prefixed filter and one byte of164// requested QoS.165func encodeSubscribe(packetID uint16, filter string) []byte {166 body := []byte{byte(packetID >> 8), byte(packetID)}167 body = appendString(body, filter)168 body = append(body, 0x00)169 return packet(mqttSubscribe|0x02, body)170}171172// encodePingreq builds the keepalive packet. It carries no body, so it173// is the two bytes 0xC0 0x00, and the broker answers with a PINGRESP.174func encodePingreq() []byte {175 return []byte{mqttPingreq, 0x00}176}177178// readPacket reads one whole control packet: the fixed-header first179// byte, the remaining length, and that many bytes of body. It returns180// the first byte so the caller reads both the packet type in the high181// nibble and the flags in the low nibble.182func readPacket(reader *bufio.Reader) (byte, []byte, error) {183 first, err := reader.ReadByte()184 if err != nil {185 return 0, nil, err186 }187 length, err := decodeRemainingLength(reader)188 if err != nil {189 return 0, nil, err190 }191 body := make([]byte, length)192 if _, err := io.ReadFull(reader, body); err != nil {193 return 0, nil, err194 }195 return first, body, nil196}197198// parseConnack reads the broker's answer to a CONNECT. The body is one199// byte of acknowledge flags and one byte of return code, and a return200// code other than zero is the broker refusing the connection.201func parseConnack(body []byte) error {202 if len(body) < 2 {203 return fmt.Errorf("mqtt: a CONNACK carried %d bytes, want 2", len(body))204 }205 if body[1] != 0x00 {206 return fmt.Errorf("mqtt: the broker refused the connection with code %d", body[1])207 }208 return nil209}210211// parseSuback reads the broker's answer to a SUBSCRIBE. The body is the212// echoed packet identifier and one return code per filter, and a213// return code of 0x80 is the broker refusing that subscription.214func parseSuback(body []byte) error {215 if len(body) < 3 {216 return fmt.Errorf("mqtt: a SUBACK carried %d bytes, want at least 3", len(body))217 }218 for _, code := range body[2:] {219 if code == 0x80 {220 return fmt.Errorf("mqtt: the broker refused a subscription")221 }222 }223 return nil224}225226// parsePublish reads an inbound PUBLISH body into its topic and227// payload. The client subscribes at QoS 0 alone, so an inbound publish228// carries no packet identifier and the payload begins right after the229// length-prefixed topic.230func parsePublish(body []byte) (topic string, payload []byte, ok bool) {231 if len(body) < 2 {232 return "", nil, false233 }234 topicLength := int(body[0])<<8 | int(body[1])235 if len(body) < 2+topicLength {236 return "", nil, false237 }238 return string(body[2 : 2+topicLength]), body[2+topicLength:], true239}
musicart.go 87.2%
1package main23// The music art pipeline. An album's cover lives inside its media files as4// often as it lives in the Play's spec, so the bridge resolves each item's5// art through tiers and reads a file's tags where the block is silent. An6// album arrives as one EDL item, and its first segment carries the cover7// for the whole timeline. The tiers are settled once per playlist, when mpv8// reports it, so no art request opens a media file again.910import (11 "bytes"12 "encoding/json"13 "fmt"14 "io"15 "os"16 "path/filepath"17 "slices"18 "strconv"19 "strings"2021 "github.com/dhowden/tag"22 "github.com/liken-sh/media-operator/edl"23)2425// The tiers an item's art resolves through, in the order the bridge tries26// them. A reference the block states beats the file, because the block27// carries what the operator resolved from the spec. The picture inside the28// file beats a cover beside it, because a file's own art is more specific29// than its folder's.30const (31 artTierNone = ""32 artTierBlock = "block"33 artTierEmbedded = "embedded"34 artTierSibling = "sibling"35)3637// The sibling cover file names, in the order the bridge tries them. Rippers38// and library managers write one cover file into an album's folder, so one39// file serves every track in it.40var coverFileNames = []string{"cover.jpg", "Cover.jpg", "folder.jpg", "Folder.jpg"}4142// One item's art as the bridge resolved it: the tier it came from, and the43// file that holds it.44type trackEntry struct {45 tier string46 source string47}4849// resolveTrack settles one item's art from its presentation block and the50// file mpv named in the playlist. An album is one EDL item, so the file51// tiers read the album's first segment, and one album resolves one cover.52func resolveTrack(block json.RawMessage, file string) trackEntry {53 var presentation Presentation54 if err := json.Unmarshal(block, &presentation); err != nil {55 presentation = Presentation{}56 }57 path := albumSourcePath(file)58 tier, source := resolveTrackArt(presentation, path, readTags(path))59 return trackEntry{tier: tier, source: source}60}6162// resolveTrackArt is the tier order itself: the block's own reference, then63// the picture inside the file, then a cover beside it. An item that reaches64// the end of the list draws no art.65func resolveTrackArt(presentation Presentation, path string, metadata tag.Metadata) (tier, source string) {66 if presentation.Art != "" {67 return artTierBlock, presentation.Art68 }69 if metadata != nil && metadata.Picture() != nil && len(metadata.Picture().Data) > 0 {70 return artTierEmbedded, path71 }72 if cover := siblingCover(path); cover != "" {73 return artTierSibling, cover74 }75 return artTierNone, ""76}7778// albumSourcePath is the file the two file tiers read. A plain media file79// is its own source. An EDL is a timeline of tracks, so the album's first80// segment stands for the whole of it.81func albumSourcePath(file string) string {82 text, dir, ok := albumTimeline(file)83 if !ok {84 return localMediaPath(file)85 }86 segments := edl.Parse(text)87 if len(segments) == 0 {88 return ""89 }90 return edl.SegmentPath(dir, segments[0].File)91}9293// albumTimeline returns the timeline an item names, and the directory a94// relative segment path is measured from. mpv reads a timeline from a file95// the shim wrote or from an edl:// URL, where a semicolon stands in for the96// newline. An item that names no timeline says so, and the caller reads it97// as a plain media file.98func albumTimeline(file string) (text, dir string, ok bool) {99 if strings.HasPrefix(file, edlScheme) {100 inline := strings.TrimPrefix(file, edlScheme)101 return strings.ReplaceAll(inline, edlURLSeparator, "\n"), "", true102 }103 path := localMediaPath(file)104 if path == "" || !strings.EqualFold(filepath.Ext(path), edlFileExtension) {105 return "", "", false106 }107 text, ok = edl.ReadFile(path)108 if !ok {109 return "", "", false110 }111 return text, filepath.Dir(path), true112}113114// The two ways an item names a timeline, and the separator that stands in115// for a newline inside the URL form.116const (117 edlScheme = "edl://"118 edlURLSeparator = ";"119 edlFileExtension = ".edl"120)121122// readTags reads one file's tags, and reads nothing for an item that is not123// a local file. A file with no tags is not an error here: it is an item124// whose art comes from the block, from a cover beside it, or from nowhere.125func readTags(path string) tag.Metadata {126 if path == "" {127 return nil128 }129 file, err := os.Open(path)130 if err != nil {131 return nil132 }133 defer file.Close()134 metadata, err := tag.ReadFrom(file)135 if err != nil {136 return nil137 }138 return metadata139}140141// localMediaPath turns one playlist entry into a path the bridge can open,142// and returns nothing for a URI it cannot read as a file. The tags and the143// sibling cover both need a local file.144func localMediaPath(entry string) string {145 path := strings.TrimPrefix(entry, "file://")146 if strings.Contains(path, "://") {147 return ""148 }149 return path150}151152// siblingCover finds the album's own cover file beside the track.153func siblingCover(path string) string {154 if path == "" {155 return ""156 }157 dir := filepath.Dir(path)158 for _, name := range coverFileNames {159 candidate := filepath.Join(dir, name)160 if info, err := os.Stat(candidate); err == nil && !info.IsDir() {161 return candidate162 }163 }164 return ""165}166167// openTrackArt opens the art the tier named. The block's reference and the168// sibling cover are files or URLs the logo path already opens, and the169// embedded picture comes out of the media file itself.170func openTrackArt(track trackEntry) (io.ReadCloser, error) {171 switch track.tier {172 case artTierBlock, artTierSibling:173 return openArt(track.source)174 case artTierEmbedded:175 return openEmbeddedPicture(track.source)176 }177 return nil, fmt.Errorf("the item has no art")178}179180// openEmbeddedPicture reads the picture out of one media file's tags and181// hands it back as bytes, so the decode path is the same one a file on disk182// takes.183func openEmbeddedPicture(path string) (io.ReadCloser, error) {184 file, err := os.Open(path)185 if err != nil {186 return nil, err187 }188 defer file.Close()189 metadata, err := tag.ReadFrom(file)190 if err != nil {191 return nil, err192 }193 picture := metadata.Picture()194 if picture == nil || len(picture.Data) == 0 {195 return nil, fmt.Errorf("the file embeds no picture")196 }197 return io.NopCloser(bytes.NewReader(picture.Data)), nil198}199200// One entry of mpv's playlist property. The bridge reads the file name201// alone: the entry's other fields say what plays now, and the bridge already202// observes that through playlist-pos.203type mpvPlaylistEntry struct {204 Filename string `json:"filename"`205}206207// parsePlaylistFiles reads mpv's playlist property into the file names in208// playlist order.209func parsePlaylistFiles(data json.RawMessage) ([]string, bool) {210 var entries []mpvPlaylistEntry211 if err := json.Unmarshal(data, &entries); err != nil {212 return nil, false213 }214 files := make([]string, len(entries))215 for index, entry := range entries {216 files[index] = entry.Filename217 }218 return files, true219}220221// playlistChanged is where the art tiers are settled. mpv reports the222// playlist once at observe time and again on every change, and the bridge223// resolves each item's art before the display asks for any of it. mpv also224// reports the property when only the playing row moves, so the file names225// decide whether anything changed, and each file is opened once per226// playlist.227func (c *commander) playlistChanged(data json.RawMessage) {228 files, ok := parsePlaylistFiles(data)229 if !ok {230 return231 }232 c.artMutex.Lock()233 unchanged := slices.Equal(files, c.playlistFiles)234 c.artMutex.Unlock()235 if unchanged {236 return237 }238239 tracks := make([]trackEntry, len(files))240 for index, file := range files {241 tracks[index] = resolveTrack(c.blockForItem(index+1), file)242 }243244 c.artMutex.Lock()245 c.playlistFiles = files246 c.tracks = tracks247 pending := c.pendingAlbum248 c.pendingAlbum = nil249 c.artMutex.Unlock()250251 // A request held from before the playlist was known is served here, now252 // that the item's art is settled, and it takes the ordinary path, so it253 // answers with the cover or with the empty path the same way any other254 // request does. It runs on a goroutine of its own, because this is the255 // reporter's goroutine, and a decode or a fetch must never hold up the256 // position reports.257 if pending != nil {258 go c.serveAlbum(*pending)259 }260}261262// serveAlbum decodes the current item's cover to the box the display asks for,263// caches the blob by size, and replies. A size already decoded for the current264// item replies from the cache and decodes nothing.265//266// Every request the bridge can answer is answered, including the ones with267// nothing to show: an item with no art, and a decode that failed, both reply268// with an empty path. Silence would read as a slow decode, so the display269// would ask again on every redraw and never stop, and an empty path is the270// answer that ends the asking.271//272// The one exception: a request for an item the bridge has not resolved yet273// is held rather than answered, because the display asks once and keeps the274// answer, so an empty path sent before the playlist arrived would leave the275// cover dark for the whole run. playlistChanged serves the held request the276// moment it knows.277func (c *commander) serveAlbum(request artRequest) {278 kind, w, h := request.kind, request.width, request.height279 key := kind + ":" + strconv.Itoa(w) + "x" + strconv.Itoa(h)280 c.artMutex.Lock()281 item := c.artItem282 if blob, cached := c.artCache[key]; cached {283 c.artMutex.Unlock()284 c.replyArt(kind, blob)285 return286 }287 track, known := c.trackFor(item)288 if !known {289 held := request290 c.pendingAlbum = &held291 c.artMutex.Unlock()292 return293 }294 c.artMutex.Unlock()295296 if track.tier == artTierNone {297 c.replyArt(kind, artBlob{})298 return299 }300301 blob, err := c.decodeAlbumArt(item, track, w, h)302 if err != nil {303 fmt.Fprintf(os.Stderr, "command: album art %q: %v\n", track.source, err)304 c.replyArt(kind, artBlob{})305 return306 }307308 c.artMutex.Lock()309 if item != c.artItem {310 // The item swapped while the decode ran. Drop this blob, so the last311 // item's art does not stay on the shared volume.312 c.artMutex.Unlock()313 os.Remove(blob.path)314 return315 }316 if c.artCache == nil {317 c.artCache = map[string]artBlob{}318 }319 c.artCache[key] = blob320 c.artMutex.Unlock()321322 c.replyArt(kind, blob)323}324325// trackFor returns one item's resolved art, and says so when the playlist326// holds no such item. The caller holds artMutex.327func (c *commander) trackFor(item int) (trackEntry, bool) {328 index := item - 1329 if index < 0 || index >= len(c.tracks) {330 return trackEntry{}, false331 }332 return c.tracks[index], true333}334335// decodeAlbumArt reads one track's art, scales it into the box, and writes the336// bgra to the shared volume.337func (c *commander) decodeAlbumArt(item int, track trackEntry, w, h int) (artBlob, error) {338 reader, err := openTrackArt(track)339 if err != nil {340 return artBlob{}, err341 }342 defer reader.Close()343344 pixels, outW, outH, stride, err := scaleToBGRA(reader, w, h)345 if err != nil {346 return artBlob{}, err347 }348349 path := filepath.Join(c.artDir, fmt.Sprintf("album-%d-%dx%d.bgra", item, w, h))350 if err := os.WriteFile(path, pixels, 0o644); err != nil {351 return artBlob{}, err352 }353 return artBlob{path: path, width: outW, height: outH, stride: stride}, nil354}
operate.go 68.8%
1package main23// The operator's loop has the shape liken's own operators use:4// level-triggered, woken by a watch, with a ticker as the backstop,5// and a reconcile before the first event ever arrives.6//7// A pass reads the whole collection instead of acting on the object8// an event carried. The event is only a wake. Every pass derives9// every status from what the API server holds right now, so a lost10// event costs at most one backstop tick, a reordered burst collapses11// into one pass, and a restarted operator starts correct with no12// replay.1314import (15 "context"16 "encoding/json"17 "errors"18 "fmt"19 "os"20 "sync/atomic"21 "time"22)2324// The operator's own environment: three image overrides, the broker,25// and the topic base. Only MEDIA_TOPIC_BASE has a default. An image26// variable that is set wins over the image images.go derives from27// the operator's own pod, and one that is unset derives.28const (29 playerImageVariable = "PLAYER_IMAGE"3031 // IDLE_IMAGE names the client that draws the idle screen. It is32 // what an idle container runs where no tier states33 // spec.idle.image, so a household that states nothing gets the34 // client this release ships.35 idleImageVariable = "IDLE_IMAGE"3637 // SIDECAR_IMAGE names the image that carries this binary alone,38 // which every sidecar container runs. It holds none of the player39 // image's mpv, drivers, or fonts, because a sidecar decodes40 // nothing.41 sidecarImageVariable = "SIDECAR_IMAGE"4243 // IDLE_DISPLAY_CLASS names the cluster's display-draw DeviceClass, the44 // shareable draw companion a Player's idle pod claims. The class is45 // cluster policy, so the Deployment sets it and the operator reads it46 // here. An unset value turns the idle screen off, so reconcileIdle47 // creates no idle claim and no idle pod.48 idleDisplayClassVariable = "IDLE_DISPLAY_CLASS"49)5051// backstopInterval is how often the loop reconciles with nothing to52// prompt it. The tick is what recovers a lost watch event, a pod that53// changed phase, and a Player that appeared after its Play.54const backstopInterval = 10 * time.Second5556// positionWriteInterval bounds how often a bare position advance reaches57// a Play's status. The command sidecar publishes a live position to the58// bus every second, but a status write wakes the operator's own plays59// watch, so writing the position every second would spin the loop and60// the API server. A pause, an item change, or a phase change writes at61// once; a62// position that advanced alone waits this interval, and the bus carries63// the live value in between.64//65// The backstop tick is what drives a bare position write, because a66// position advance wakes nothing on its own. So this interval sits below67// backstopInterval on purpose: a write stamps a moment after the tick68// that made it, so an interval equal to the tick would miss the next tick69// by that moment and write every second tick, at twice the period. Two70// seconds of headroom absorbs that skew, so a steadily playing film71// writes on every tick.72const positionWriteInterval = 8 * time.Second7374// playReclaimGrace is how long a deleted Play's retained status and75// availability stay on the bus before the operator clears them. The76// command sidecar clears its own status on a clean exit but marks the77// availability offline and never clears it, and an unclean exit leaves78// both, so without this a deleted Play's gravestone would sit on the79// broker forever. The grace is short but not zero: a subscriber that80// reads the topic in the moment after the delete still sees the run's81// final state, and then the retained topics go empty. It is a variable so82// a test drives the reclaim without waiting minutes.83var playReclaimGrace = 2 * time.Minute8485// volumeSeedGrace is how long the pass waits after a broker session86// begins before it seeds any unit's level. The broker delivers the87// retained levels within milliseconds of the subscribe, and the wait88// covers that delivery, so the first pass reads a desk that already89// holds what the broker holds. A pass that seeded inside the window90// would publish unity over a level a person had set. It is a variable91// so a test seeds without waiting.92var volumeSeedGrace = 2 * time.Second9394// defaultTTLSecondsAfterFinished is how long a Finished Play stands when95// its spec sets no ttlSecondsAfterFinished. Five minutes is the default96// continue-watching window: while the Play stands, kubectl get plays still97// answers what just played and where it stopped, and the record goes when98// the Play does. A library app sets the field on each Play it creates when99// its continue-watching feature reads a different window.100const defaultTTLSecondsAfterFinished = 300101102// operator holds what every pass needs. The report desk and the bus103// are fields rather than globals so a test builds an operator around a104// desk it can inspect.105type operator struct {106 client *Client107 image string108 // idleImage is the client an idle container runs where no tier109 // states spec.idle.image. It is a release decision, so it arrives110 // in the environment beside the player image.111 idleImage string112 // sidecarImage is the image every sidecar container runs, the113 // binary alone. It is a release decision, so it arrives in the114 // environment beside the player image.115 sidecarImage string116 busAddress string117 topicBase string118 // idleDisplayClass is the display-draw DeviceClass a Player's idle pod119 // claims. An empty value turns the idle screen off, so reconcileIdle120 // builds nothing.121 idleDisplayClass string122 bus *Bus123 reports *reports124 // focus is the desk for the retained focus mark, built on the same125 // wake as the report desk: a cycle request on the bus wakes the pass126 // that arbitrates it.127 focus *focusDesk128129 // peripherals is the desk for the bluetooth-operator's Peripherals and130 // for the Peripheral each Remote's claim allocated. The pass fills it131 // from the API and reads it when it builds a Player's bus status. The132 // peripherals watch is the wake, so a controller that connects,133 // disconnects, or reports a new charge reaches the pass that134 // republishes that status.135 peripherals *peripheralDesk136137 // codes is the desk for each controller's declared code set, built138 // on the same wake: a code set that changed wakes the pass that139 // rewrites the Remote status the gap appears on.140 codes *codesDesk141142 // panels is the desk for each unit's panel desire, built on143 // the same wake: a desire that changed wakes the pass that writes144 // the override onto the screen's Display.145 panels *panelDesk146147 // panelOverrides holds the override this operator last148 // applied per unit, so a pass writes the Display only when the149 // desire changed, and a deleted Player's dark panel still has a150 // screen to lift. Only the pass goroutine touches it.151 panelOverrides map[string]panelOverride152153 // panelFaults holds the last panel fault reported per unit,154 // so a screen with no Display logs once and not once a pass. Only155 // the pass goroutine touches it.156 panelFaults map[string]string157158 // volumes is the desk for each unit's level. Unlike the desks159 // above, it wakes no pass, because the level folds into no status.160 // The pass reads it for one question alone: whether the broker161 // already holds a level for a unit. It seeds only where the desk162 // holds none.163 volumes *volumeDesk164165 // volumeSeedAfter is when the pass may seed again. A fresh bus166 // session's retained messages arrive on their own goroutine moments167 // after the subscribe, so each connect pushes the first seed out by168 // volumeSeedGrace. Only the pass goroutine touches it.169 volumeSeedAfter time.Time170171 // positionWrites stamps when each run last wrote its position, so a172 // bare position advance writes no more than once per173 // positionWriteInterval. Only the pass goroutine touches it.174 positionWrites map[string]time.Time175176 // keysPublished maps each Remote's keys topic to the table the177 // operator last published there. The topic is retained, so the178 // broker serves the current table to any new subscriber, and the179 // operator republishes only when the table changes. The map also180 // lets a later pass find a topic whose Remote is gone and clear its181 // retained value. Only the pass goroutine touches it.182 keysPublished map[string]string183184 // playerStatusPublished maps each Player's status topic to the payload185 // the operator last published there. It is the keymap pattern applied to186 // the unit's presentable state: the topic is retained, so the operator187 // republishes only when the payload changes, and a topic whose Player no188 // longer exists has its retained value cleared. Only the pass goroutine189 // touches it.190 playerStatusPublished map[string]string191192 // playReclaim stamps when the operator first saw a deleted Play whose193 // retained topics still stand, so it clears them once the grace has194 // passed. Only the pass goroutine touches it.195 playReclaim map[string]time.Time196197 // recreateBackoff holds one run's recreate count and the earliest time it198 // may recreate again, so a pod that keeps failing recreates slower up to a199 // cap. Only the pass goroutine touches it.200 recreateBackoff map[string]backoffState201202 // wake is the loop's own wake channel. The operator schedules one wake at203 // a backoff deadline, so a run waiting out its backoff resumes when the204 // wait ends rather than on the next backstop tick.205 wake chan<- struct{}206207 // busReconnected is set on the bus goroutine when a session reaches a208 // CONNACK, and read on the pass goroutine. A fresh broker session209 // holds none of the retained state the operator owns, so the next pass210 // re-establishes it. It is atomic because the two goroutines share it211 // with no other lock between them.212 busReconnected atomic.Bool213}214215func operate() {216 // Setup failures end the process on purpose. The kubelet restarts217 // the pod with backoff, and the failure shows in kubectl instead of218 // hiding in a retry loop.219 busAddress := os.Getenv(busAddressVariable)220 if busAddress == "" {221 fmt.Fprintf(os.Stderr, "%s is unset; the Deployment must name the broker\n", busAddressVariable)222 os.Exit(1)223 }224 topicBase := os.Getenv(topicBaseVariable)225 if topicBase == "" {226 topicBase = defaultTopicBase227 }228 // The idle display class is optional. An unset value turns the idle229 // screen off, so the operator runs with no idle pods rather than230 // exiting the way a missing image or broker does.231 idleDisplayClass := os.Getenv(idleDisplayClassVariable)232233 client, err := InClusterClient()234 if err != nil {235 fmt.Fprintf(os.Stderr, "in-cluster config: %v\n", err)236 os.Exit(1)237 }238239 images, err := resolveImages(client)240 if err != nil {241 fmt.Fprintln(os.Stderr, err)242 os.Exit(1)243 }244245 // One wake channel serves the two watches and the bus handler,246 // because a wake carries no information beyond "read the collection247 // again".248 wake := make(chan struct{}, 1)249 desk := newReports(wake)250 focusDesk := newFocusDesk(wake)251 codesDesk := newCodesDesk(wake)252 panels := newPanelDesk(wake)253 media := &operator{254 client: client,255 image: images.player,256 idleImage: images.idle,257 sidecarImage: images.sidecar,258 busAddress: busAddress,259 topicBase: topicBase,260 idleDisplayClass: idleDisplayClass,261 reports: desk,262 focus: focusDesk,263 peripherals: newPeripheralDesk(),264 codes: codesDesk,265 panels: panels,266 panelOverrides: map[string]panelOverride{},267 panelFaults: map[string]string{},268 volumes: newVolumeDesk(),269 positionWrites: map[string]time.Time{},270 keysPublished: map[string]string{},271 playerStatusPublished: map[string]string{},272 playReclaim: map[string]time.Time{},273 recreateBackoff: map[string]backoffState{},274 wake: wake,275 }276277 // onConnect marks that a fresh broker session began, so the next pass278 // re-establishes the retained state the operator owns. The broker holds279 // none of it on a new session, whether the operator or the broker280 // restarted, so without this the keymaps and focus marks would stay281 // missing until a person edited one.282 onConnect := func(bus *Bus) {283 media.busReconnected.Store(true)284 poke(wake)285 }286 // The bus handler is the only path the control plane takes a report or287 // a focus signal.288 media.bus = newBus(busAddress, "media-operator", nil, onConnect, media.handleBusMessage)289 media.bus.Subscribe(playStatusFilter(topicBase))290 media.bus.Subscribe(playAvailabilityFilter(topicBase))291 media.bus.Subscribe(remoteFocusFilter(topicBase))292 media.bus.Subscribe(remoteFocusCycleFilter(topicBase))293 media.bus.Subscribe(remoteAvailabilityFilter(topicBase))294 media.bus.Subscribe(remoteCodesFilter(topicBase))295 media.bus.Subscribe(playerPanelFilter(topicBase))296 media.bus.Subscribe(playerVolumeFilter(topicBase))297 go media.bus.Run(context.Background())298299 // The first lists do two jobs: they prove the operator can read the300 // collections, and their resourceVersions are where the watches301 // start.302 plays, err := ListPlays(client)303 if err != nil {304 fmt.Fprintf(os.Stderr, "listing plays: %v\n", err)305 os.Exit(1)306 }307 remotes, err := ListAllRemotes(client)308 if err != nil {309 fmt.Fprintf(os.Stderr, "listing remotes: %v\n", err)310 os.Exit(1)311 }312 players, err := ListPlayers(client)313 if err != nil {314 fmt.Fprintf(os.Stderr, "listing players: %v\n", err)315 os.Exit(1)316 }317 keymaps, err := ListKeymaps(client)318 if err != nil {319 fmt.Fprintf(os.Stderr, "listing keymaps: %v\n", err)320 os.Exit(1)321 }322 prefs, err := ListMediaPreferences(client)323 if err != nil {324 fmt.Fprintf(os.Stderr, "listing media preferences: %v\n", err)325 os.Exit(1)326 }327 pods, err := ListPlaybackPods(client)328 if err != nil {329 fmt.Fprintf(os.Stderr, "listing playback pods: %v\n", err)330 os.Exit(1)331 }332 // The Peripherals are listed on the same terms as the other333 // collections. A cluster whose bluetooth-operator serves no Peripheral334 // answers an error here, and the operator ends rather than run with no335 // record of any controller's link.336 peripherals, err := ListPeripherals(client)337 if err != nil {338 fmt.Fprintf(os.Stderr, "listing peripherals: %v\n", err)339 os.Exit(1)340 }341 fmt.Printf("media.liken.sh: operating %d plays and %d remotes over %s\n",342 len(plays.Items), len(remotes.Items), busAddress)343 go watchPlays(client, plays.Metadata.ResourceVersion, wake)344 go watchRemotes(client, remotes.Metadata.ResourceVersion, wake)345 go watchPlayers(client, players.Metadata.ResourceVersion, wake)346 go watchKeymaps(client, keymaps.Metadata.ResourceVersion, wake)347 go watchMediaPreferences(client, prefs.Metadata.ResourceVersion, wake)348 go watchPods(client, pods.Metadata.ResourceVersion, wake)349 go watchPeripherals(client, peripherals.Metadata.ResourceVersion, wake)350351 ticker := time.NewTicker(backstopInterval)352 for {353 media.pass()354 select {355 case <-wake:356 case <-ticker.C:357 }358 }359}360361// pass runs one reconcile over every Play and every Remote in the362// cluster. A failure on one object is reported and the pass continues,363// because one broken run must not freeze every other unit's status.364func (o *operator) pass() {365 if o.busReconnected.Swap(false) {366 o.reestablishRetained()367 }368 list, err := ListPlays(o.client)369 if err != nil {370 fmt.Fprintf(os.Stderr, "listing plays: %v\n", err)371 return372 }373 // Read the household default once per pass. A missing default is not an error,374 // and a read that fails skips the tier this pass.375 defaults, err := GetMediaPreferences(o.client, mediaPreferencesName)376 if err != nil {377 if !errors.Is(err, ErrNotFound) {378 fmt.Fprintf(os.Stderr, "reading media preferences: %v\n", err)379 }380 defaults = nil381 }382 live := make(map[string]bool, len(list.Items))383 for index := range list.Items {384 play := &list.Items[index]385 live[runKey(play.Metadata.Namespace, play.Metadata.Name)] = true386 // A Finished Play is over, so the pass retires it in place of387 // reconciling it. A Failed Play is not over here, because the388 // reconcile below resumes it, so only a Finished Play is retired.389 if finishedPhase(play.Status.Phase) {390 if err := o.retire(play); err != nil {391 fmt.Fprintf(os.Stderr, "retiring play %s/%s: %v\n",392 play.Metadata.Namespace, play.Metadata.Name, err)393 }394 continue395 }396 if err := o.reconcile(play, defaults); err != nil {397 fmt.Fprintf(os.Stderr, "reconciling play %s/%s: %v\n",398 play.Metadata.Namespace, play.Metadata.Name, err)399 }400 }401 o.reports.retain(live)402 for key := range o.positionWrites {403 if !live[key] {404 delete(o.positionWrites, key)405 }406 }407 for key := range o.recreateBackoff {408 if !live[key] {409 delete(o.recreateBackoff, key)410 }411 }412 o.reclaimPlays(live)413 // The Remotes are listed once for the whole pass. The Peripherals are414 // folded in here, before any Player status is written, because a415 // unit's bus status carries each controller's link and charge. The same416 // list reconciles the standing pods at the end of the pass. A list that417 // fails skips both, so no desk shrinks to a collection the operator418 // could not read.419 remotes, remotesErr := ListAllRemotes(o.client)420 var remoteClaims map[string]claimRead421 if remotesErr != nil {422 fmt.Fprintf(os.Stderr, "listing remotes: %v\n", remotesErr)423 } else {424 remoteClaims = o.observePeripherals(remotes.Items)425 }426 players, err := ListPlayers(o.client)427 if err != nil {428 fmt.Fprintf(os.Stderr, "listing players: %v\n", err)429 } else {430 // The idle clock reads the household zone, one setting per cluster from431 // the default MediaPreferences alone, so the pass resolves it once and432 // hands it to every Player's idle pod.433 var zone string434 // The idle default is read once from the same MediaPreferences,435 // and each Player's own block overrides it field by field.436 var defaultIdle *IdlePolicy437 if defaults != nil {438 zone = defaults.Spec.TimeZone439 defaultIdle = defaults.Spec.Idle440 }441 // Focus settles before the Player statuses, because a remote part442 // of a unit's bus status carries whether the mark names that unit.443 // Arbitrating after would publish the previous pass's mark and444 // leave the indicator one pass behind.445 o.reconcileFocus(players.Items)446 o.reconcilePlayers(players.Items, list.Items, zone, defaultIdle)447 }448 if remotesErr == nil {449 // The Keymaps are read first, because each Remote's table is its450 // Keymap folded over the base, and the pass compiles one table per451 // Remote.452 o.reconcileRemotes(remotes.Items, remoteClaims, o.loadKeymaps())453 }454}455456// handleBusMessage folds one bus message into the report desk or the457// focus desk by its topic. An empty payload on a status or availability458// topic is a cleared retained value, not a live signal, so it is ignored:459// the operator publishes that empty value itself when it reclaims a460// deleted Play, and reading its own clear back as an offline signal would461// mark the run seen again and reclaim it forever.462func (o *operator) handleBusMessage(topic string, payload []byte) {463 if namespace, name, kind, ok := parsePlayTopic(o.topicBase, topic); ok {464 if len(payload) == 0 {465 return466 }467 switch kind {468 case playStatusKind:469 var report playReport470 if err := json.Unmarshal(payload, &report); err != nil {471 return472 }473 o.reports.fold(namespace, name, report)474 case playAvailabilityKind:475 o.reports.availability(namespace, name, string(payload) == availabilityOnline)476 }477 return478 }479 if namespace, name, ok := parseRemoteFocusTopic(o.topicBase, topic); ok {480 o.focus.setMark(controllerKey(namespace, name), string(payload))481 return482 }483 if namespace, name, ok := parseRemoteFocusCycleTopic(o.topicBase, topic); ok {484 o.focus.requestCycle(controllerKey(namespace, name))485 return486 }487 // An availability with an empty payload is a cleared retained value and488 // not a live signal, so it is ignored the way an empty plays message489 // is. The signal gates the declared codes, because a retained document490 // outlives the pod that wrote it.491 if namespace, name, ok := parseRemoteAvailabilityTopic(o.topicBase, topic); ok {492 if len(payload) == 0 {493 return494 }495 o.codes.setAvailability(controllerKey(namespace, name),496 string(payload) == availabilityOnline)497 return498 }499 // An empty payload on the codes topic is the pod's own clear,500 // published when the controller's nodes vanish, so the desk drops501 // the document rather than keep a stale one.502 if namespace, name, ok := parseRemoteCodesTopic(o.topicBase, topic); ok {503 key := controllerKey(namespace, name)504 if len(payload) == 0 {505 o.codes.clear(key)506 return507 }508 var codes remoteCodes509 if err := json.Unmarshal(payload, &codes); err != nil {510 return511 }512 o.codes.setCodes(key, codes)513 return514 }515 // An empty payload is a cleared retained value and not a live516 // signal, so the desk holds what it had.517 if namespace, name, ok := parsePlayerPanelTopic(o.topicBase, topic); ok {518 if len(payload) == 0 {519 return520 }521 var panel panelDesire522 if err := json.Unmarshal(payload, &panel); err != nil {523 return524 }525 o.panels.setState(playerKey(namespace, name), panel.Desire)526 return527 }528 // The operator reads the level only to learn that one stands, so529 // the seed skips the unit. An empty payload is a cleared retained530 // value, and it changes nothing on the desk.531 if namespace, name, ok := parsePlayerVolumeTopic(o.topicBase, topic); ok {532 if len(payload) == 0 {533 return534 }535 state, decoded := parseVolumeState(payload)536 if !decoded {537 return538 }539 o.volumes.setState(playerKey(namespace, name), state)540 return541 }542}543544// reestablishRetained rewrites everything the operator publishes retained545// after a fresh broker session, because the broker holds none of it. It546// clears the record of published keymaps and Player statuses, so547// reconcileKeymaps and reconcilePlayers write every one of them again this548// pass, and it republishes each focus mark, so a controller keeps the549// Player it drives across a broker or operator restart. The command sidecar and550// the standing remote pod re-establish their own retained topics from551// their own connect, so those need no help here.552func (o *operator) reestablishRetained() {553 o.keysPublished = map[string]string{}554 o.playerStatusPublished = map[string]string{}555 // The levels are the one retained state this rewrite skips. The556 // sidecars and the broker hold them, not the operator, so the pass557 // waits out volumeSeedGrace and then seeds only the units nothing558 // answered for.559 o.volumeSeedAfter = time.Now().Add(volumeSeedGrace)560 for key, player := range o.focus.snapshot() {561 o.publishFocus(key, player)562 }563}564565// retire gives a Finished Play its two endings: the playback objects go at566// once, and the Play itself goes when the window on its spec has passed.567//568// The pod holds nothing worth keeping after the film ends. The final569// position is on the Play's status and the ending already traveled the bus,570// so the pod and its claim go on the first pass that reads the Finished571// phase. Only a Finished Play is retired. A Failed pod stands, because its572// log is the evidence a person debugs from and the reconcile resumes the573// run.574//575// The finishedAt stamp is what tells a Play this pass just retired from one576// retired earlier, so the two deletes run once per Play and the passes that577// follow only read the clock. Both deletes read an absent object as success,578// so a retry after a failed status write deletes nothing twice.579//580// The deletion follows the pass cadence, so a Play goes at most one581// backstopInterval, ten seconds, after its window ends.582func (o *operator) retire(play *Play) error {583 namespace, name := play.Metadata.Namespace, play.Metadata.Name584 // A Play a person already deleted is on its way out, and the garbage585 // collector takes the pod and the claim with it through their586 // ownerReferences. Leave it alone, the way reconcileStanding leaves a587 // standing object with a deletionTimestamp alone.588 if play.Metadata.DeletionTimestamp != "" {589 return nil590 }591 now := time.Now()592 finished, stamped := finishedTime(play, now)593 if !stamped {594 if err := DeletePod(o.client, namespace, podName(name)); err != nil {595 return err596 }597 if err := DeleteResourceClaim(o.client, namespace, claimName(name)); err != nil {598 return err599 }600 }601 // Deleting the Play is the whole teardown. The ownerReferences collect602 // anything the two deletes above did not, and reclaimPlays clears the603 // retained topics on the same terms as a Play a person deleted: the next604 // pass reads the Play gone, so the run counts as stale and its grace605 // begins.606 if now.Sub(finished) >= playTTL(play) {607 return DeletePlay(o.client, namespace, name)608 }609 if stamped {610 return nil611 }612 status := play.Status613 status.FinishedAt = finished.UTC().Format(time.RFC3339)614 return writePlayStatus(o.client, play, status)615}616617// playTTL is how long this Play stands after it finishes: the seconds its618// spec states, or defaultTTLSecondsAfterFinished when it states none. The619// pointer is what tells a spec that asked for zero from a spec that asked620// for nothing, and zero deletes the Play on the pass that sees it finished.621func playTTL(play *Play) time.Duration {622 if play.Spec.TTLSecondsAfterFinished == nil {623 return defaultTTLSecondsAfterFinished * time.Second624 }625 return time.Duration(*play.Spec.TTLSecondsAfterFinished) * time.Second626}627628// finishedTime reads the moment this run finished from the status, and629// reports whether the status carried a stamp at all. A Play with no stamp630// finished as far as this operator can tell right now, so its window starts631// at the time the caller passes in. A stamp this operator cannot parse632// counts as no stamp, so a garbled value starts the window over rather than633// holding a Finished Play forever.634func finishedTime(play *Play, now time.Time) (time.Time, bool) {635 stamped, err := time.Parse(time.RFC3339, play.Status.FinishedAt)636 if err != nil {637 return now, false638 }639 return stamped, true640}641642// reclaimPlays clears the retained topics a deleted Play leaves on the643// bus. The desk reports the runs it has seen a message for that no longer644// exist; the operator holds each for playReclaimGrace, so a subscriber645// that reads just after the delete still sees the final state, then it646// clears the status and the availability with an empty retained publish647// and forgets the run. A run that reappears before the grace passes, a648// Play recreated under the same name, drops its timer and is not cleared.649func (o *operator) reclaimPlays(live map[string]bool) {650 now := time.Now()651 for _, key := range o.reports.stale(live) {652 if _, tracked := o.playReclaim[key]; !tracked {653 o.playReclaim[key] = now654 }655 if now.Sub(o.playReclaim[key]) < playReclaimGrace {656 continue657 }658 namespace, name := splitRunKey(key)659 o.bus.Publish(playStatusTopic(o.topicBase, namespace, name), nil, true)660 o.bus.Publish(playAvailabilityTopic(o.topicBase, namespace, name), nil, true)661 o.reports.forget(key)662 delete(o.playReclaim, key)663 }664 for key := range o.playReclaim {665 if live[key] {666 delete(o.playReclaim, key)667 }668 }669}670671// loadKeymaps reads every Keymap once per pass and indexes it by672// name. The pass holds them rather than compiling here, because a673// table belongs to a Remote: it is the base folded with that Remote's674// Keymap, and a Remote with no Keymap still needs the base.675func (o *operator) loadKeymaps() map[string]*Keymap {676 keymaps := map[string]*Keymap{}677 list, err := ListKeymaps(o.client)678 if err != nil {679 fmt.Fprintf(os.Stderr, "listing keymaps: %v\n", err)680 return keymaps681 }682 for index := range list.Items {683 keymap := &list.Items[index]684 keymaps[keymap.Metadata.Name] = keymap685 }686 return keymaps687}688689// publishKeys compiles one Remote's table and publishes it retained690// on that Remote's keys topic. The topic is retained, so an unchanged691// table is not republished and a new subscriber reads the current one692// from the broker. A Keymap that does not compile publishes nothing693// and leaves the last good table in place, so a broken edit does not694// stop a controller. The table this returns is what the status695// reports the gap against.696func (o *operator) publishKeys(remote *Remote, keymaps map[string]*Keymap, present map[string]bool) []compiledBinding {697 topic := remoteKeysTopic(o.topicBase, remote.Metadata.Namespace, remote.Metadata.Name)698 present[topic] = true699 table, err := compileTable(keymaps[remote.Spec.Keymap])700 if err != nil {701 fmt.Fprintf(os.Stderr, "compiling the key table for remote %s/%s: %v\n",702 remote.Metadata.Namespace, remote.Metadata.Name, err)703 return nil704 }705 payload, err := json.Marshal(table)706 if err != nil {707 fmt.Fprintf(os.Stderr, "marshaling the key table for remote %s/%s: %v\n",708 remote.Metadata.Namespace, remote.Metadata.Name, err)709 return nil710 }711 if o.keysPublished[topic] != string(payload) {712 o.bus.Publish(topic, payload, true)713 o.keysPublished[topic] = string(payload)714 }715 return table716}717718// reconcilePlayers writes every Player's status, publishes the same state719// to the bus, and ensures every Player's standing idle pod, from the720// Players and Plays the pass already read. A Player's status is721// relational, derived from the Plays that name it, so the pass lists the722// Players once and hands the slice here and to reconcileFocus rather than723// listing twice. The idle pod stands whether or not a Play runs, so its724// reconcile is per Player and not derived from the Plays.725//726// The Kubernetes status and the bus status carry the same activity from727// the same derivation. The API server holds what exists and what is728// desired, and the bus carries the presentable now, which is why the idle729// screen reads a topic and holds no API credentials.730func (o *operator) reconcilePlayers(players []Player, plays []Play, timeZone string, defaultIdle *IdlePolicy) {731 published := make(map[string]bool, len(players))732 live := make(map[string]bool, len(players))733 // The units whose idle screen something draws. A unit under734 // media.liken.sh/none leaves this set, and the panel desks treat735 // it the way they treat a deleted unit: its desire is dropped and736 // a dark panel it left behind takes a lift.737 drawn := make(map[string]bool, len(players))738 // One screens for the pass, so the driver's ResourceSlices739 // are listed at most once however many units the cluster holds.740 lookup := newScreens(o.client)741 for index := range players {742 player := &players[index]743 key := playerKey(player.Metadata.Namespace, player.Metadata.Name)744 live[key] = true745 desired := derivePlayerStatus(player, plays, o.reports)746 idle := resolveIdle(player.Spec.Idle, defaultIdle, o.idleImage)747 // The panel state is what the screen's Display last748 // observed, so the status reports the hardware and not what749 // the media layer asked for.750 if idle.Controller == idleControllerNone {751 // Nothing draws, so no desire is settled and the panel752 // reports nothing. The retained desire is cleared once,753 // while the desk still holds it, so a restarted operator754 // does not read the idle client pod's last word back off755 // the broker after that pod is gone.756 if o.panels.stateFor(key) != "" {757 o.bus.Publish(playerPanelTopic(o.topicBase, player.Metadata.Namespace, player.Metadata.Name), nil, true)758 }759 } else {760 drawn[key] = true761 desired.Panel = o.reconcilePanel(player, key, lookup, idle.OffMode)762 }763 // The idle block is what a delegate reads to draw this764 // unit's screen, so it goes on the status before the write.765 desired.Idle = deriveIdleStatus(player, idle.Controller, o.busAddress, o.topicBase,766 o.idleClaimFor(player), idle, gatherIdleRemotes(player, o.topicBase))767 o.seedVolume(player, key)768 // The status goes out before the re-present, and the order is769 // what the returning idle screen draws from. The client770 // animates the return only when it reads the Idle status before771 // the re-present, because the status is what says the film is772 // over. The client subscribes to the retained status topic773 // itself and reads the re-present off the commands topic, so774 // the order the operator writes is the order the client reads.775 published[o.publishPlayerStatus(player, desired, plays)] = true776 // A Play that ends destroys the film's surface, and the idle777 // client's surface under it stays hidden. Weston's kiosk-shell778 // reveals a lower surface only along a code path gated on a seat,779 // and liken's compositor has none, so the idle clock does not return780 // on its own. On the edge from any active state to idle, the781 // operator publishes a re-present. The client reads it off the782 // commands topic, maps a fresh surface, and kiosk reveals that783 // one. The edge reads the stored status against the784 // derived one, so the re-present goes out once as the Player settles785 // to idle and not on every backstop pass while it stays idle. A786 // status write that fails leaves the stored status unchanged, so the787 // next pass reads the edge again and publishes a second re-present.788 // The client maps a fresh surface over one that is already up, and789 // the screen shows the same clock.790 if player.Status.Activity != playerIdle && desired.Activity == playerIdle {791 o.publishRePresent(player.Metadata.Namespace, player.Metadata.Name)792 }793 if err := writePlayerStatus(o.client, player, desired); err != nil {794 fmt.Fprintf(os.Stderr, "writing player %s/%s status: %v\n",795 player.Metadata.Namespace, player.Metadata.Name, err)796 }797 if err := o.reconcileIdle(player, timeZone, defaultIdle); err != nil {798 fmt.Fprintf(os.Stderr, "reconciling idle for player %s/%s: %v\n",799 player.Metadata.Namespace, player.Metadata.Name, err)800 }801 }802 // The panel desk shrinks to the units something draws, the way803 // the codes desk shrinks to its Remotes.804 o.panels.retain(drawn)805 // The overrides shrink the same way, and a unit dropped806 // while its panel was dark takes a lift on the way out.807 o.retainPanels(drawn)808 // The volume desk shrinks the same way. The retained level itself809 // stays on the broker, so a Player recreated under the same name810 // keeps the level the room was left at.811 o.volumes.retain(live)812 // A topic whose Player no longer exists has its retained value cleared813 // with an empty publish, so a deleted Player leaves no unit on the bus814 // for a subscriber to draw.815 for topic := range o.playerStatusPublished {816 if !published[topic] {817 o.bus.Publish(topic, nil, true)818 delete(o.playerStatusPublished, topic)819 }820 }821}822823// publishPlayerStatus writes one unit's presentable state to its retained824// status topic and returns the topic it wrote, so the caller records which825// topics this pass still owns. The topic is retained, so republishing an826// unchanged payload is churn a new subscriber does not need: it reads the827// current value from the broker. The publish happens only when the payload828// differs from the last one this operator wrote, which is what keeps the829// backstop tick off the bus while a unit sits idle.830func (o *operator) publishPlayerStatus(player *Player, desired PlayerStatus, plays []Play) string {831 topic := playerStatusTopic(o.topicBase, player.Metadata.Namespace, player.Metadata.Name)832 payload, err := json.Marshal(derivePlayerBusStatus(player, desired, plays, o.peripherals, o.focus))833 if err != nil {834 fmt.Fprintf(os.Stderr, "marshaling player %s/%s bus status: %v\n",835 player.Metadata.Namespace, player.Metadata.Name, err)836 return topic837 }838 if o.playerStatusPublished[topic] == string(payload) {839 return topic840 }841 o.bus.Publish(topic, payload, true)842 o.playerStatusPublished[topic] = string(payload)843 return topic844}845846// seedVolume writes unity to a unit whose level the broker holds847// nothing for, so the state is always readable off the bus and no848// reader carries a default. It never writes over a level that849// stands: the desk answers that, and a duplicate seed from a racing850// pass writes the same value, so the race settles itself. A Player851// with no sinks is not seeded, because a unit with nothing to hear852// has no level to mean anything.853func (o *operator) seedVolume(player *Player, key string) {854 if len(player.Spec.Sinks) == 0 || time.Now().Before(o.volumeSeedAfter) {855 return856 }857 if _, held := o.volumes.stateFor(key); held {858 return859 }860 o.publishVolume(player.Metadata.Namespace, player.Metadata.Name, defaultVolumeState())861}862863// writeThroughVolume lays a Play's declared starting state over the864// unit's current one and publishes the result, retained, before the865// pod exists. The override becomes the Player's state, and everything866// after it is the ordinary path. It runs on the creating pass alone:867// a republish on a later pass of the same run would write the Play's868// level over every press a person made during the film.869func (o *operator) writeThroughVolume(play *Play) {870 if play.Spec.Volume == nil {871 return872 }873 namespace, name := play.Metadata.Namespace, playerName(play)874 key := playerKey(namespace, name)875 current, held := o.volumes.stateFor(key)876 if !held {877 current = defaultVolumeState()878 }879 o.publishVolume(namespace, name, current.mergedWith(play.Spec.Volume))880}881882// publishVolume writes one unit's level to its topic, retained, and883// records it on the desk at once. Recording the operator's own write884// keeps the next pass from seeding the same unit again before the885// broker echoes the message back.886func (o *operator) publishVolume(namespace, name string, state volumeState) {887 payload, err := marshalVolumeState(state)888 if err != nil {889 fmt.Fprintf(os.Stderr, "publishing player %s/%s volume: %v\n", namespace, name, err)890 return891 }892 o.bus.Publish(playerVolumeTopic(o.topicBase, namespace, name), payload, true)893 o.volumes.setState(playerKey(namespace, name), state)894}895896// volumeFor is the level the pod's mpv starts at, and whether the897// broker holds one at all. A unit nothing has answered for carries898// no level onto the pod, so mpv keeps its own default and the899// subscription sets the level a moment later.900func (o *operator) volumeFor(play *Play) (volumeState, bool) {901 return o.volumes.stateFor(playerKey(play.Metadata.Namespace, playerName(play)))902}903904// publishRePresent publishes the re-present to a Player's commands905// topic, not retained, because a re-present is an event and not a906// state. The idle screen client subscribes to that topic and maps a907// fresh surface, so the clock shows again after a Play ends.908func (o *operator) publishRePresent(namespace, name string) {909 payload, err := json.Marshal(mediaCommand{Action: actionRePresent})910 if err != nil {911 return912 }913 o.bus.Publish(playerCommandsTopic(o.topicBase, namespace, name), payload, false)914}915916// reconcileRemotes reconciles a standing pod for every Remote in the917// cluster and writes each Remote's status. A Remote's pod runs whether or918// not anything plays, so the pass reads the whole collection and hands it919// here rather than deriving it from the Plays. It runs after reconcileFocus920// on the same pass, so the status reports the mark this pass settled.921func (o *operator) reconcileRemotes(remotes []Remote, claims map[string]claimRead, keymaps map[string]*Keymap) {922 live := make(map[string]bool, len(remotes))923 present := make(map[string]bool, len(remotes))924 for index := range remotes {925 remote := &remotes[index]926 key := controllerKey(remote.Metadata.Namespace, remote.Metadata.Name)927 live[key] = true928 if err := o.reconcileRemote(remote, claims[key]); err != nil {929 fmt.Fprintf(os.Stderr, "reconciling remote %s/%s: %v\n",930 remote.Metadata.Namespace, remote.Metadata.Name, err)931 }932 table := o.publishKeys(remote, keymaps, present)933 // The one status this pass builds carries the mark and the gap934 // together, because two writers of one status would alternate935 // and each write wakes the watch.936 desired := RemoteStatus{937 Player: o.focus.markFor(key),938 Peripheral: o.peripherals.peripheralFor(key),939 }940 // A table that did not compile reports no gap, because the gap is941 // what the live table leaves unbound, and this pass has no live942 // table to subtract.943 if declared, held := o.codes.codesFor(key); held && table != nil {944 desired.Unbound = unboundCodes(declared, table)945 }946 if err := writeRemoteStatus(o.client, remote, desired); err != nil {947 fmt.Fprintf(os.Stderr, "writing remote %s/%s status: %v\n",948 remote.Metadata.Namespace, remote.Metadata.Name, err)949 }950 }951 // The codes desk holds a key per controller it has heard from, so it952 // shrinks to the Remotes the cluster still holds.953 o.codes.retain(live)954 // A Remote that is gone leaves its retained table behind, so the955 // pass clears the topic with an empty payload. The map is the record956 // of what this operator wrote, so it is the one place that knows957 // which topics to clear.958 for topic := range o.keysPublished {959 if !present[topic] {960 o.bus.Publish(topic, nil, true)961 delete(o.keysPublished, topic)962 }963 }964}965966// writeRemoteStatus follows the same two rules as the Play's and the967// Player's status writers: an unchanged status is not written, and a968// conflict earns one retry. The operator watches Remotes, so a needless969// write would wake the loop that just wrote it, a pass per pass forever.970func writeRemoteStatus(c *Client, remote *Remote, desired RemoteStatus) error {971 same, err := sameRemoteStatus(remote.Status, desired)972 if err != nil {973 return err974 }975 if same {976 return nil977 }978979 remote.Status = desired980 _, err = PutRemoteStatus(c, remote)981 if !errors.Is(err, ErrConflict) {982 return err983 }984985 // A conflict means the Remote changed between the read and the write.986 // The fresh copy carries the resourceVersion the API server accepts,987 // and the desired status still reports the mark this pass settled, so988 // it goes on unchanged.989 fresh, err := GetRemote(c, remote.Metadata.Namespace, remote.Metadata.Name)990 if err != nil {991 return err992 }993 same, err = sameRemoteStatus(fresh.Status, desired)994 if err != nil || same {995 return err996 }997 fresh.Status = desired998 _, err = PutRemoteStatus(c, fresh)999 return err1000}10011002// sameRemoteStatus compares the marshaled forms, the way the Play's1003// and the Player's writers compare, because the marshaled form is what1004// the API server stores and what omitempty decides.1005func sameRemoteStatus(current, desired RemoteStatus) (bool, error) {1006 was, err := json.Marshal(current)1007 if err != nil {1008 return false, err1009 }1010 wants, err := json.Marshal(desired)1011 if err != nil {1012 return false, err1013 }1014 return string(was) == string(wants), nil1015}10161017// reconcile takes one Play from the Player it names to the status it1018// earns. The order matters: nothing is created until the Player is read1019// and every URI resolves, so a Play that can never run leaves no1020// half-built objects behind.1021func (o *operator) reconcile(play *Play, defaults *MediaPreferences) error {1022 namespace, name := play.Metadata.Namespace, play.Metadata.Name1023 // The default tier's spec, or nil when the cluster has no default.1024 var defaultSpec *MediaPreferencesSpec1025 if defaults != nil {1026 defaultSpec = &defaults.Spec1027 }1028 if playerName(play) == "" {1029 return writePlayStatus(o.client, play, PlayStatus{1030 Phase: phaseFailed,1031 Message: "the Play names no Player",1032 })1033 }10341035 player, err := GetPlayer(o.client, namespace, playerName(play))1036 if errors.Is(err, ErrNotFound) {1037 prefs := resolvePreferences(&play.Spec, nil, defaultSpec)1038 return writePlayStatus(o.client, play, derivePlayStatus(play, nil, nil, nil, nil, prefs))1039 }1040 if err != nil {1041 return err1042 }1043 prefs := resolvePreferences(&play.Spec, &player.Spec, defaultSpec)10441045 resolved, resolveErr := resolvePlay(play.Spec.Items)1046 if resolveErr != nil {1047 return writePlayStatus(o.client, play, derivePlayStatus(play, player, resolveErr, nil, nil, prefs))1048 }10491050 // A missing Remote fails the Play only while there is still no pod.1051 // Once the pod exists, its container set is fixed and no edit to the1052 // Player's remotes can reach this run, so a Remote deleted mid-film1053 // must not fail the film. A Keymap never reaches this gather: it is1054 // compiled and published on the bus per Remote by reconcileRemotes,1055 // and a broken Keymap edit leaves the last good table in place1056 // instead.1057 remotes, remoteErr := gatherRemotes(o.client, player)1058 if remoteErr != nil {1059 _, err := GetPod(o.client, namespace, podName(name))1060 if errors.Is(err, ErrNotFound) {1061 return writePlayStatus(o.client, play, derivePlayStatus(play, player, remoteErr, nil, nil, prefs))1062 }1063 if err != nil {1064 return err1065 }1066 remotes = nil1067 }1068 // The operator fills each remote's two topics here, because the1069 // topic base lives with the operator and not the gather. Both carry1070 // the Remote's namespace and name.1071 for index := range remotes {1072 remotes[index].EventsTopic = remoteEventsTopic(o.topicBase, namespace, remotes[index].Name)1073 remotes[index].FocusTopic = remoteFocusTopic(o.topicBase, namespace, remotes[index].Name)1074 }10751076 claim := buildClaim(play, player)1077 pod, fresh, err := o.ensurePlayback(play, claim, resolved, prefs, remotes, remoteErr != nil)1078 if err != nil {1079 return err1080 }1081 // A genuinely new Play steals its controllers, the most-recent-steals1082 // default. A graceful recreate resumes the same Play, so it steals1083 // nothing and the mark stays where a person left it.1084 if fresh && len(remotes) > 0 {1085 o.stealFocus(play, remotes)1086 }1087 status := derivePlayStatus(play, player, nil, pod, o.reports.latestFor(namespace, name), prefs)1088 // A run that keeps failing reads the backoff note in place of the pod's1089 // own failure message, but only once the recreates repeat and only while1090 // the pod is Failed, so a run that recovers reads a clean status.1091 if status.Phase == phaseFailed {1092 if note, backing := o.backoffNote(runKey(namespace, name)); backing {1093 status.Message = note1094 }1095 }1096 return o.writePlay(play, status)1097}10981099// writePlay writes a Play's status through the position throttle. A1100// change in phase, pause, item, or message writes at once. A change that1101// is only a position advance waits positionWriteInterval, so the resource1102// keeps a coarse clock while the bus carries the live one, and a position1103// write does not wake the operator's own watch a second later.1104func (o *operator) writePlay(play *Play, desired PlayStatus) error {1105 key := runKey(play.Metadata.Namespace, play.Metadata.Name)1106 if onlyPositionChanged(play.Status, desired) &&1107 time.Since(o.positionWrites[key]) < positionWriteInterval {1108 return nil1109 }1110 if err := writePlayStatus(o.client, play, desired); err != nil {1111 return err1112 }1113 o.positionWrites[key] = time.Now()1114 return nil1115}11161117// ensurePlayback brings the running pod into line with the pod the1118// current Player would produce. A Play with no pod yet gets its claim1119// and its pod. A gather that failed keeps an existing pod as it is,1120// because the container set is fixed once it runs and a Keymap broken1121// mid-film must not fail the film. A running pod its Player reshaped is1122// recreated at the film's place, and a running pod its Player left1123// alone is kept.1124//1125// The bool reports a genuinely new pod, true only in the no-existing-pod1126// branch. A fresh Play uses it to steal its controllers, and a recreate,1127// which returns false, leaves the focus mark alone.1128func (o *operator) ensurePlayback(play *Play, claim *ResourceClaim, resolved resolution, prefs resolvedPreferences, remotes []boundRemote, keepExisting bool) (*Pod, bool, error) {1129 namespace, name := play.Metadata.Namespace, play.Metadata.Name1130 key := runKey(namespace, name)1131 running, err := GetPod(o.client, namespace, podName(name))1132 if errors.Is(err, ErrNotFound) {1133 // Nothing answers for the pod: a taint evicted it, its node was lost,1134 // or the Play never had one. A run with a saved place resumes without1135 // stealing its controllers back, and a run with no saved place starts1136 // at spec.start and steals them. Both wait out the recreate backoff.1137 _, resuming := o.resumePoint(play)1138 if !o.mayResume(key) {1139 return nil, false, nil1140 }1141 if err := ensureClaim(o.client, claim); err != nil {1142 return nil, false, err1143 }1144 // A run that starts here for the first time is the one pass a1145 // Play's declared level is written through on. A run that1146 // resumes skips it, the way the recreate paths below do,1147 // because a run that already played must keep the level a1148 // person set while it played. The claim carries the speaker1149 // gate: a Play against a unit with no sinks writes no level1150 // through, the same gate the seed reads off the Player.1151 if !resuming && claimHasSink(claim) {1152 o.writeThroughVolume(play)1153 }1154 pod, err := o.createPodAtStash(play, claim, resolved, prefs, remotes)1155 return pod, !resuming, err1156 }1157 if err != nil {1158 return nil, false, err1159 }1160 if keepExisting {1161 return running, false, nil1162 }1163 if running.Status.Phase == podFailed {1164 // mpv exited non-zero. Recreate the pod at the film's place the way a1165 // Job restarts a failed pod, bounded by the recreate backoff so a1166 // file that crashes at the same place does not recreate without end.1167 if !o.mayResume(key) {1168 return running, false, nil1169 }1170 pod, err := o.recreateForResume(play, claim, resolved, prefs, remotes)1171 return pod, false, err1172 }11731174 claimChanged, err := o.claimDiverged(claim)1175 if err != nil {1176 return nil, false, err1177 }1178 desired := buildPod(play, claim, resolved, o.image, o.sidecarImage, o.busAddress, o.topicBase, remotes, prefs)1179 if !claimChanged && sameRemoteSet(running, desired) {1180 return running, false, nil1181 }1182 pod, err := o.recreate(play, claim, resolved, prefs, remotes, claimChanged)1183 return pod, false, err1184}11851186// recreate replaces a running pod its Player reshaped and keeps the1187// film's place. It deletes the pod, deletes and recreates the claim only1188// when the claim itself diverged, and creates the replacement so mpv1189// starts where the film was. The image is already on the machine, so the1190// film resumes within about a second.1191func (o *operator) recreate(play *Play, claim *ResourceClaim, resolved resolution, prefs resolvedPreferences, remotes []boundRemote, claimChanged bool) (*Pod, error) {1192 namespace, name := play.Metadata.Namespace, play.Metadata.Name1193 if err := DeletePod(o.client, namespace, podName(name)); err != nil {1194 return nil, err1195 }1196 if claimChanged {1197 if err := DeleteResourceClaim(o.client, namespace, claimName(name)); err != nil {1198 return nil, err1199 }1200 if err := ensureClaim(o.client, claim); err != nil {1201 return nil, err1202 }1203 }1204 return o.createPodAtStash(play, claim, resolved, prefs, remotes)1205}12061207// recreateForResume replaces a pod that failed and keeps the film's place.1208// The claim outlives the pod, so this ensures the claim, deletes the dead1209// pod, and creates the replacement at the film's place.1210func (o *operator) recreateForResume(play *Play, claim *ResourceClaim, resolved resolution, prefs resolvedPreferences, remotes []boundRemote) (*Pod, error) {1211 namespace, name := play.Metadata.Namespace, play.Metadata.Name1212 if err := ensureClaim(o.client, claim); err != nil {1213 return nil, err1214 }1215 if err := DeletePod(o.client, namespace, podName(name)); err != nil {1216 return nil, err1217 }1218 return o.createPodAtStash(play, claim, resolved, prefs, remotes)1219}12201221// createPodAtStash creates the playback pod with mpv's start set to the1222// film's saved place. A genuinely new run has no saved place, so the start1223// falls back to spec.start.1224func (o *operator) createPodAtStash(play *Play, claim *ResourceClaim, resolved resolution, prefs resolvedPreferences, remotes []boundRemote) (*Pod, error) {1225 resume := *play1226 resume.Spec.Start = o.stashedPosition(play)1227 // The copy carries the unit's current level, not the override the1228 // Play declared, so the pod builder reads one field and never1229 // reads the bus. It is the same move the saved place above makes:1230 // the pod is built from the Play as the run stands right now.1231 resume.Spec.Volume = nil1232 if volume, held := o.volumeFor(play); held {1233 resume.Spec.Volume = volume.asPlayVolume()1234 }1235 return o.createPod(&resume, claim, resolved, prefs, remotes)1236}12371238// createPod creates one playback pod and reads it back on a 409,1239// because another pass, or another copy of this operator, created the1240// pod first.1241func (o *operator) createPod(play *Play, claim *ResourceClaim, resolved resolution, prefs resolvedPreferences, remotes []boundRemote) (*Pod, error) {1242 namespace, name := play.Metadata.Namespace, play.Metadata.Name1243 created, err := CreatePod(o.client, buildPod(play, claim, resolved, o.image, o.sidecarImage, o.busAddress, o.topicBase, remotes, prefs))1244 if errors.Is(err, ErrConflict) {1245 return GetPod(o.client, namespace, podName(name))1246 }1247 if err != nil {1248 return nil, err1249 }1250 return created, nil1251}12521253// stashedPosition reads the film's place for a recreate. It prefers the1254// run's own resume point, and falls back to the Play's spec.start for a1255// run that never reported a position, which a startup edit reshaped or a1256// brand-new Play that just started.1257func (o *operator) stashedPosition(play *Play) string {1258 if position, ok := o.resumePoint(play); ok {1259 return position1260 }1261 return play.Spec.Start1262}12631264// resumePoint reads the place a run reached, from the retained bus status1265// first and the Play's status second, and reports whether it found one. A1266// spec.start is not a resume point, so a run that never reported a position1267// starts fresh rather than resumes.1268func (o *operator) resumePoint(play *Play) (string, bool) {1269 namespace, name := play.Metadata.Namespace, play.Metadata.Name1270 if report := o.reports.latestFor(namespace, name); report != nil && report.Position != "" {1271 return report.Position, true1272 }1273 if play.Status.Position != "" {1274 return play.Status.Position, true1275 }1276 return "", false1277}12781279// backoffState holds one run's recreate count and the two times that bound1280// its rate: last is when it last recreated, and next is the earliest it may1281// recreate again.1282type backoffState struct {1283 count int1284 last time.Time1285 next time.Time1286}12871288// The recreate backoff follows the kubelet's CrashLoopBackOff: the first1289// recreate is immediate, and each recreate after it doubles the wait from1290// the base to the cap. A run that stays up for the reset window starts the1291// count over. They are variables so a test drives them in milliseconds.1292var (1293 recreateBackoffBase = 10 * time.Second1294 recreateBackoffCap = 5 * time.Minute1295 recreateBackoffReset = 10 * time.Minute1296)12971298// backoffNoteThreshold is how many recreates a run reaches before its status1299// reads the repeated-failure note in place of the pod's own message.1300const backoffNoteThreshold = 213011302// mayResume reports whether a run may recreate its dead pod now, and1303// advances the backoff when it may. On a yes it schedules one wake at the1304// deadline, so the loop resumes when the wait ends rather than on a backstop1305// tick. On a no a wake from the last yes is already pending, so the caller1306// waits for it.1307func (o *operator) mayResume(key string) bool {1308 now := time.Now()1309 state := o.recreateBackoff[key]1310 if !state.last.IsZero() && now.Sub(state.last) > recreateBackoffReset {1311 state = backoffState{}1312 }1313 if now.Before(state.next) {1314 return false1315 }1316 state.count++1317 state.last = now1318 state.next = now.Add(backoffDelay(state.count))1319 o.recreateBackoff[key] = state1320 o.requeueAfter(time.Until(state.next))1321 return true1322}13231324// backoffDelay returns the wait after the count-th recreate: the base1325// doubled once per recreate, up to the cap. The doubling runs in a loop and1326// stops at the cap, so a long run of failures never overflows the duration.1327func backoffDelay(count int) time.Duration {1328 delay := recreateBackoffBase1329 for range count - 1 {1330 delay *= 21331 if delay >= recreateBackoffCap {1332 return recreateBackoffCap1333 }1334 }1335 return delay1336}13371338// backoffNote returns the status message for a run that keeps failing, and1339// reports whether the count reached the threshold. The caller shows it only1340// while the pod is Failed.1341func (o *operator) backoffNote(key string) (string, bool) {1342 if o.recreateBackoff[key].count < backoffNoteThreshold {1343 return "", false1344 }1345 return "the playback pod keeps failing; the operator is retrying with a growing delay", true1346}13471348// requeueAfter schedules one wake at delay from now, the requeue-after1349// idiom: the timer fires once and pokes the shared wake, which coalesces1350// with any queued wake, so a run waiting out its backoff wakes when the wait1351// ends.1352func (o *operator) requeueAfter(delay time.Duration) {1353 if o.wake == nil {1354 return1355 }1356 time.AfterFunc(delay, func() { poke(o.wake) })1357}
panel.go 100.0%
1package main23// The panel desk is the boundary between the bus and the4// reconcile loop for one unit's panel, the way the codes desk is5// for a controller. The idle screen client holds no API credentials, so6// it publishes its desire for the panel, and the pass turns the desk's7// newest desire into an override on the screen's Display.89import "sync"1011// The two desires an idle screen client states. They are the values on12// the panel topic, not the states the Player status carries.13const (14 panelDesireOn = "on"15 panelDesireOff = "off"16)1718// The retained panel topic carries a desire and not a report.19// The unit it belongs to is named by the topic, not by the body.20type panelDesire struct {21 Desire string `json:"desire"`22}2324// panelFromDisplay is the Player status word for what the25// display-operator last observed on the panel. The power word is one26// of on, standby, suspend, off, and hardOff, and every one but on is27// a panel held down, so all four read Off. A Display that observed28// nothing yet folds to no panel field at all.29func panelFromDisplay(observed DisplayObserved) string {30 switch {31 case observed.Power != "" && observed.Power != displayPowerOn:32 return panelOff33 case observed.Brightness != nil && *observed.Brightness == 0:34 return panelBacklightOff35 case observed.Power == "" && observed.Brightness == nil:36 return ""37 }38 return panelOn39}4041// panelDesk holds the newest state per unit under one mutex, because42// the bus handler runs on the bus reader's goroutine and the loop43// runs on its own.44type panelDesk struct {45 mutex sync.Mutex46 state map[string]string47 wake chan<- struct{}48}4950func newPanelDesk(wake chan<- struct{}) *panelDesk {51 return &panelDesk{state: map[string]string{}, wake: wake}52}5354// playerKey is the one key shape for a unit, the same namespace and55// name the topic carries.56func playerKey(namespace, name string) string {57 return namespace + "/" + name58}5960// setState folds one desire. A desire that changed is a write61// the next pass owes the screen's Display, so it wakes the loop at62// once. A repeat of the desire already held wakes nothing, because63// the retained topic delivers the same message again on every64// reconnect.65func (p *panelDesk) setState(key, state string) {66 p.mutex.Lock()67 previous, had := p.state[key]68 p.state[key] = state69 p.mutex.Unlock()70 if !had || previous != state {71 poke(p.wake)72 }73}7475// stateFor is one unit's panel desire, empty for a unit no76// sidecar has stated one for, and the pass then writes no override77// and reads no Display.78func (p *panelDesk) stateFor(key string) string {79 p.mutex.Lock()80 defer p.mutex.Unlock()81 return p.state[key]82}8384// retain drops the units the cluster no longer holds, so a85// long-running operator does not accumulate a key per deleted86// Player.87func (p *panelDesk) retain(live map[string]bool) {88 p.mutex.Lock()89 defer p.mutex.Unlock()90 for key := range p.state {91 if !live[key] {92 delete(p.state, key)93 }94 }95}
peripherals.go 100.0%
1package main23// Reading the bluetooth-operator's Peripherals.4//5// The bluetooth-operator publishes one cluster-scoped Peripheral per6// bonded device, and writes on it the two facts this layer draws: the7// Connected condition, which is the record of the controller's link,8// and the charge the device reports. A Peripheral is named by the9// device's lowercase dashed address, which is the same name a claim's10// allocation result carries as the device for the bluetooth.liken.sh11// driver. So a Remote's standing claim names the Peripheral of the12// controller it holds, and the operator resolves the claim's allocation13// to reach it. This layer reads a Peripheral and never writes one.1415import (16 "fmt"17 "os"18)1920// The group the bluetooth-operator serves. A Peripheral is21// cluster-scoped, because a bonded device belongs to no namespace.22const peripheralAPIVersion = "bluetooth.liken.sh/v1alpha1"2324// The DRA driver whose devices are bonded Bluetooth peripherals. An25// allocation result from this driver names its Peripheral by the device26// name it carries.27const bluetoothDriver = "bluetooth.liken.sh"2829// The condition that carries the link, and the value that means the link30// is up.31const (32 peripheralConnected = "Connected"33 conditionTrue = "True"34)3536// A Peripheral carries only what this operator reads: the link and the37// charge the device reports.38type Peripheral struct {39 Metadata ObjectMeta `json:"metadata"`40 Status PeripheralStatus `json:"status"`41}4243type PeripheralList struct {44 Metadata ListMeta `json:"metadata"`45 Items []Peripheral `json:"items"`46}4748type PeripheralStatus struct {49 Battery *PeripheralBattery `json:"battery,omitempty"`50 Conditions []PeripheralCondition `json:"conditions,omitempty"`51}5253// The charge the device reports. A device that reports no level carries54// no battery block at all.55type PeripheralBattery struct {56 Percentage int `json:"percentage,omitempty"`57}5859// One condition on a Peripheral. The operator reads the type and the60// status alone.61type PeripheralCondition struct {62 Type string `json:"type"`63 Status string `json:"status"`64}6566// peripheralDesk holds what one pass read about the controllers: the67// Peripherals by name, and the Peripheral each Remote's standing claim68// allocated, by controller key. The reconcile pass reads it when it69// builds a Player's bus status. Only the pass goroutine touches it, so70// it carries no mutex, unlike the desks the bus thread writes. A71// Peripheral change wakes the loop through the watch, and the pass then72// reads the collection again.73type peripheralDesk struct {74 held map[string]Peripheral75 named map[string]string76}7778func newPeripheralDesk() *peripheralDesk {79 return &peripheralDesk{80 held: map[string]Peripheral{},81 named: map[string]string{},82 }83}8485// hold replaces what the desk holds with what this pass read. The maps go86// in whole, so a Peripheral the cluster no longer holds and a controller87// whose claim lost its allocation leave no entry behind, and the desk88// needs no separate shrink.89func (p *peripheralDesk) hold(peripherals []Peripheral, named map[string]string) {90 held := make(map[string]Peripheral, len(peripherals))91 for _, peripheral := range peripherals {92 held[peripheral.Metadata.Name] = peripheral93 }94 p.held = held95 if named == nil {96 named = map[string]string{}97 }98 p.named = named99}100101// peripheralFor names the Peripheral one controller's standing claim102// allocated. It is empty for a controller whose claim carries no103// allocation, and for one whose device comes from another driver.104func (p *peripheralDesk) peripheralFor(key string) string {105 return p.named[key]106}107108// connectedFor reports one Peripheral's link and whether the desk holds an109// answer. A Peripheral the cluster does not hold, and one that carries no110// Connected condition, is neither connected nor disconnected, so the111// status it appears in carries no connected key at all.112func (p *peripheralDesk) connectedFor(name string) (connected, held bool) {113 peripheral, standing := p.held[name]114 if !standing {115 return false, false116 }117 for _, condition := range peripheral.Status.Conditions {118 if condition.Type == peripheralConnected {119 return condition.Status == conditionTrue, true120 }121 }122 return false, false123}124125// batteryFor is the charge one Peripheral reports. A device that reports126// no level answers nil, and the status it appears in carries no battery127// key.128func (p *peripheralDesk) batteryFor(name string) *int {129 peripheral, standing := p.held[name]130 if !standing || peripheral.Status.Battery == nil {131 return nil132 }133 percentage := peripheral.Status.Battery.Percentage134 return &percentage135}136137// observePeripherals reads the cluster's Peripherals and resolves which138// one each Remote holds. It runs before the pass writes any Player139// status, because a unit's bus status carries its controllers' links.140// It reads each Remote's standing claim, which is the one read of that141// claim the pass makes, and returns those reads by controller key so142// the standing reconcile makes none of its own. A claim read that fails143// has no entry, and the reconcile then reads that one claim itself. A144// Peripherals list that fails leaves the desk holding what it had, so145// one failed read does not blank every controller on the idle screen.146func (o *operator) observePeripherals(remotes []Remote) map[string]claimRead {147 claims := make(map[string]claimRead, len(remotes))148 named := make(map[string]string, len(remotes))149 for index := range remotes {150 remote := &remotes[index]151 key := controllerKey(remote.Metadata.Namespace, remote.Metadata.Name)152 read, err := o.readClaim(remote.Metadata.Namespace,153 remoteClaimName(remote.Metadata.Name))154 if err != nil {155 fmt.Fprintf(os.Stderr, "reading the claim of remote %s/%s: %v\n",156 remote.Metadata.Namespace, remote.Metadata.Name, err)157 continue158 }159 claims[key] = read160 if name, held := peripheralOf(read.claim); held {161 named[key] = name162 }163 }164 list, err := ListPeripherals(o.client)165 if err != nil {166 fmt.Fprintf(os.Stderr, "listing peripherals: %v\n", err)167 return claims168 }169 o.peripherals.hold(list.Items, named)170 return claims171}172173// peripheralOf names the Peripheral a standing claim's allocation174// holds. The allocation result for the bluetooth.liken.sh driver names175// the device, and that device name is the Peripheral's own name,176// because the bluetooth-operator names both from the device's address.177// A claim the scheduler has not allocated names none, and so does a178// controller some other driver publishes.179func peripheralOf(claim *ResourceClaim) (string, bool) {180 if claim == nil || claim.Status == nil || claim.Status.Allocation == nil {181 return "", false182 }183 for _, result := range claim.Status.Allocation.Devices.Results {184 if result.Driver == bluetoothDriver {185 return result.Device, true186 }187 }188 return "", false189}
player.go 46.4%
1package main23// The player mode is the playback pod's entrypoint shim. mpv reads no4// options from the environment, but the display claim delivers the5// surface's app-id in the environment at run time, after the pod spec6// is fixed. So the shim reads that one variable, builds the rest of7// mpv's arguments the way the pod's Play declares them, and execs mpv.8// Because it execs, the shim replaces itself, so mpv is the pod's own9// process. The kubelet then sends mpv the grace-period SIGTERM and10// reads its exit code, and a zero code is a Play that ran to the end.1112import (13 "encoding/json"14 "fmt"15 "os"16 "os/exec"17 "strings"18 "syscall"19)2021// mpvBinary is a variable rather than a constant because the pod runs22// the mpv its image carries, and a test points it at a stand-in that23// needs no display and no sound card.24var mpvBinary = "mpv"2526// displayAppIDVariable arrives from the display claim's CDI spec, not27// from the operator. The display operator writes it into the28// container's environment at run time, after the pod spec is fixed, so29// the operator could not know the value to set. mpv has no environment30// mechanism for options, so the shim adds the --wayland-app-id flag31// itself when the claim delivered an id.32const displayAppIDVariable = "DISPLAY_APP_ID"3334// The path of the display script directory inside the image, the one mpv loads35// with --script. It is a variable rather than a constant so a test can point it36// at a stand-in, the way mpvBinary is.37var displayScriptDir = "/display"3839// overlayFont is the family the overlay draws in, installed as two OTF40// files in the player image and named here for mpv's own OSD. The brand41// crate carries the same family for the idle screen, and display/theme.lua42// names it in its ASS tags, which is what draws the overlay's text.43const overlayFont = "Source Sans 3"4445// runPlayer builds mpv's argument vector and execs mpv, so mpv becomes46// the container's process. On any failure it writes the reason to47// stderr and exits nonzero, which the kubelet reads as a pod that48// failed to start its player.49func runPlayer(items []string) {50 // The shim reads the same blocks the command sidecar reads, because the51 // block is where an item declares its shape, and the album expansion52 // needs that declaration before mpv sees any argument.53 blocks := parsePresentations(os.Getenv(presentationsVariable))54 entries, err := expandItems(items, blocks)55 if err != nil {56 fmt.Fprintf(os.Stderr, "player: %v\n", err)57 os.Exit(1)58 }59 argv, err := playerArgv(entries, blocks)60 if err != nil {61 fmt.Fprintf(os.Stderr, "player: %v\n", err)62 os.Exit(1)63 }64 // syscall.Exec replaces this process with mpv, so it returns only65 // when the exec itself fails. The resolved path is argv[0] because66 // the kernel runs the file the path names, not a PATH lookup.67 if err := syscall.Exec(argv[0], argv, os.Environ()); err != nil {68 fmt.Fprintf(os.Stderr, "player: exec %s: %v\n", argv[0], err)69 os.Exit(1)70 }71}7273// playerArgv is the whole of how mpv is told to play. The gpu video74// output with the Wayland EGL context, because the display claim75// delivers a compositor socket and Debian's mpv segfaults in76// --vo=dmabuf-wayland. VAAPI, because the render claim delivers the77// node that decodes. The PipeWire audio output, because Wayland78// carries no audio and the sink claim delivers that socket. The IPC79// server stays because the command sidecar drives that same socket to80// run each named command and read the report. The display script directory81// loads with --script, and the command sidecar drives it over that same IPC82// socket. --osc=no turns off mpv's built-in on-screen controller,83// because the display draws its own. The window's app-id routes84// it to the allocated output when the display claim delivered one.85//86// The list ends with -- because a media path that starts with a dash87// would otherwise read as a flag.88//89// --no-input-terminal is deliberately absent. mpv installs its SIGTERM90// handler only on the terminal-input path, and without the handler the91// kubelet's SIGTERM ends the player the hard way instead of letting it92// quit.93//94// argv[0] is the resolved binary path, so a test that points mpvBinary95// at a stand-in reads back the path it set. exec.LookPath fails before96// mpv runs when the image carries no mpv, which the shim reports rather97// than execing nothing.98func playerArgv(items []string, blocks []json.RawMessage) ([]string, error) {99 path, err := exec.LookPath(mpvBinary)100 if err != nil {101 return nil, fmt.Errorf("find %s: %w", mpvBinary, err)102 }103 argv := []string{104 path,105 "--vo=gpu",106 "--gpu-context=wayland",107 "--hwdec=vaapi",108 "--fullscreen",109 "--ao=pipewire",110 "--input-ipc-server=" + mpvSocketPath,111 "--script=" + displayScriptDir,112 "--osc=no",113 // mpv's own OSD messages draw in the brand family; libass resolves114 // it through fontconfig against the two OTF files the image installs.115 "--osd-font=" + overlayFont,116 }117 // A run of nothing but music draws no video, so the display owns the118 // whole frame instead of annotating the cover art mpv would frame. One119 // item that is not music keeps video on for the whole run.120 //121 // --force-window=yes holds a window over the blanked video. Without it122 // mpv opens no window at all for a run with no video track, and the123 // display draws nothing.124 if allMusic(blocks, len(items)) {125 argv = append(argv, "--vid=no", "--force-window=yes")126 }127 if applicationID := os.Getenv(displayAppIDVariable); applicationID != "" {128 argv = append(argv, "--wayland-app-id="+applicationID)129 }130 // The declared start applies to the first file mpv loads and to no131 // later playlist entry, which is exactly what spec.start means: the132 // run begins here, and later items begin at their own start.133 if start := os.Getenv(playStartVariable); start != "" {134 argv = append(argv, "--start="+start)135 }136 // The operator resolved these language and subtitle flags and joined them with137 // newlines. The shim forwards each one to mpv, unread.138 if options := os.Getenv(playerOptionsVariable); options != "" {139 for _, option := range strings.Split(options, "\n") {140 if option != "" {141 argv = append(argv, option)142 }143 }144 }145 argv = append(argv, "--")146 return append(argv, items...), nil147}
playerstatus.go 85.7%
1package main23// A Player's status is relational: it comes from the Plays that name4// the Player, not from the Player itself. So the operator writes it5// on the same pass that reconciles the Plays, from the same list it6// already read. A Player that no Play names is idle, and one that a7// Play runs on is playing.89import (10 "encoding/json"11 "errors"12)1314// derivePlayerStatus reads the whole pass's Plays and returns what15// this Player is doing. A Play running on the Player wins over one16// still starting, because a person wants the run in progress named17// first; among equals, the earliest name is chosen, so the answer is18// the same on every pass. A Play in a terminal phase is over and19// names nothing.20//21// A Play whose sidecar reported the ending names nothing either, though22// its pod still runs and its phase still reads Running. The pod takes23// seconds to terminate, and the film is over for every one of them, so24// the unit is idle from the mark and the idle screen returns in bus25// time. The desk is a parameter, so the derivation stays a function of26// its arguments the way the rest of this operator's derivations are.27func derivePlayerStatus(player *Player, plays []Play, desk *reports) PlayerStatus {28 var running, starting string29 for index := range plays {30 play := &plays[index]31 if play.Metadata.Namespace != player.Metadata.Namespace {32 continue33 }34 if playerName(play) != player.Metadata.Name {35 continue36 }37 if desk.endedFor(play.Metadata.Namespace, play.Metadata.Name) {38 continue39 }40 switch play.Status.Phase {41 case phaseRunning:42 if running == "" || play.Metadata.Name < running {43 running = play.Metadata.Name44 }45 case phasePending:46 if starting == "" || play.Metadata.Name < starting {47 starting = play.Metadata.Name48 }49 }50 }51 if running != "" {52 return PlayerStatus{Activity: playerPlaying, Play: running}53 }54 if starting != "" {55 return PlayerStatus{Activity: playerStarting, Play: starting}56 }57 return PlayerStatus{Activity: playerIdle}58}5960// deriveIdleStatus reports what a delegate wires its client from: the61// resolved controller, the standing claim in the Player's namespace,62// that claim's request names in claim order, the two resolved windows,63// and the bus the client joins. A delegate acts on the status and never64// on the spec, because the spec may inherit its controller from65// MediaPreferences and only this operator resolves the tiers.66//67// A nil claim is a Player that drives no screen, or a cluster that68// names no display-draw class, and such a unit reports no idle block at69// all. Under media.liken.sh/none nothing draws and no claim stands, so70// the block carries the controller alone.71//72// The bus block goes with the claim, under every controller but73// media.liken.sh/none. The broker and the topic base are this74// operator's configuration, so the status is where a delegate's client75// learns both. The remotes list is in spec.remotes order, because that76// position is the index a focus moment carries and the order the status77// topic lists the parts in.78func deriveIdleStatus(79 player *Player, controller, busAddress, topicBase string,80 claim *ResourceClaim, idle resolvedIdle, remotes []idleRemoteTopics,81) *PlayerIdleStatus {82 if claim == nil {83 return nil84 }85 if controller == idleControllerNone {86 return &PlayerIdleStatus{Controller: controller}87 }88 namespace, name := player.Metadata.Namespace, player.Metadata.Name89 return &PlayerIdleStatus{90 Controller: controller,91 Claim: claim.Metadata.Name,92 Requests: claimRequests(claim),93 FadeAfterSeconds: idle.FadeAfterSeconds,94 OffAfterSeconds: idle.OffAfterSeconds,95 Bus: &PlayerIdleBus{96 Address: busAddress,97 StatusTopic: playerStatusTopic(topicBase, namespace, name),98 VolumeTopic: idleVolumeTopic(player, topicBase),99 CommandsTopic: playerCommandsTopic(topicBase, namespace, name),100 PanelTopic: playerPanelTopic(topicBase, namespace, name),101 Remotes: idleBusRemotes(remotes),102 },103 }104}105106// idleBusRemotes turns the topics the pod carries into the list the107// status carries, one entry per controller, in the same spec.remotes108// order. A unit with no controllers reports no list.109func idleBusRemotes(remotes []idleRemoteTopics) []PlayerIdleRemote {110 if len(remotes) == 0 {111 return nil112 }113 list := make([]PlayerIdleRemote, len(remotes))114 for index, remote := range remotes {115 list[index] = PlayerIdleRemote{Events: remote.Events, Focus: remote.Focus}116 }117 return list118}119120// playerBusStatus is the presentable state of one unit, the whole of what121// the operator says to the idle screen. The Kubernetes status stays the122// record of what the Player is doing; this is the same fact plus the words123// a screen draws, so the display formats one message and resolves nothing.124//125// The Player it belongs to is named by the topic, not by the body, the way126// a Play's report is.127type playerBusStatus struct {128 DisplayName string `json:"displayName"`129 Activity string `json:"activity"`130 Play *playerBusPlay `json:"play,omitempty"`131 Components []playerBusComponent `json:"components,omitempty"`132}133134// playerBusPlay names the Play that runs or starts on the unit. Name is135// the object a person finds with kubectl, and Title is the one line the136// screen draws.137type playerBusPlay struct {138 Name string `json:"name"`139 Title string `json:"title"`140}141142// playerBusComponent is one part of the unit: its friendly name, its kind,143// its link when the part has one, the charge it reports when it runs on a144// battery, and, for a remote, whether the focus mark names this unit.145// Connected is a pointer so a part with no live state carries no key at146// all, and the display draws it at full brightness always. Focused is a147// pointer for the same reason: it appears only on the remote whose mark148// names this Player, so exactly one unit draws the marker for a controller149// that several units list. Battery is a pointer because a device that150// reports no level must not read as an empty one.151type playerBusComponent struct {152 Name string `json:"name"`153 Kind string `json:"kind"`154 Connected *bool `json:"connected,omitempty"`155 Battery *int `json:"battery,omitempty"`156 Focused *bool `json:"focused,omitempty"`157}158159// The three kinds of part the idle screen draws. The kind is the display's160// whole vocabulary for a part, so it says what to draw and not which161// DeviceClass the part came from.162const (163 displayComponent = "display"164 sinkComponent = "sink"165 remoteComponent = "remote"166)167168// derivePlayerBusStatus builds the message the idle screen reads from169// the Player, the activity the same pass derived, the Peripherals the170// desk holds, and the marks the focus desk holds. The parts come from171// the spec in the order the screen shows them: the display first, then172// each sink, then each remote. Only a remote carries a link, a charge,173// and focus, because a wired screen and its speakers report none of174// them, and a controller a person carries comes and goes and drives one175// unit at a time. A remote whose standing claim named no Peripheral176// carries neither a link nor a charge, and its focus is unchanged,177// because the mark comes from the focus desk.178func derivePlayerBusStatus(player *Player, activity PlayerStatus, plays []Play, peripherals *peripheralDesk, focus *focusDesk) playerBusStatus {179 status := playerBusStatus{180 DisplayName: idlePlayerName(player),181 Activity: activity.Activity,182 }183 if play := findPlay(plays, player.Metadata.Namespace, activity.Play); play != nil {184 status.Play = &playerBusPlay{Name: play.Metadata.Name, Title: playTitle(play)}185 }186 if player.Spec.Display != nil {187 status.Components = append(status.Components, playerBusComponent{188 Name: deviceDisplayName(*player.Spec.Display),189 Kind: displayComponent,190 })191 }192 for _, sink := range player.Spec.Sinks {193 status.Components = append(status.Components, playerBusComponent{194 Name: deviceDisplayName(sink),195 Kind: sinkComponent,196 })197 }198 for _, remote := range player.Spec.Remotes {199 key := controllerKey(player.Metadata.Namespace, remote.Name)200 component := playerBusComponent{201 Name: remoteDisplayName(remote),202 Kind: remoteComponent,203 }204 if name := peripherals.peripheralFor(key); name != "" {205 if connected, held := peripherals.connectedFor(name); held {206 component.Connected = &connected207 }208 component.Battery = peripherals.batteryFor(name)209 }210 if focus.markFor(key) == player.Metadata.Name {211 focused := true212 component.Focused = &focused213 }214 status.Components = append(status.Components, component)215 }216 return status217}218219// findPlay returns the Play the activity names, or nil when the activity220// names none. An idle Player names no Play, so its status carries no play221// block and the screen draws the clock alone.222func findPlay(plays []Play, namespace, name string) *Play {223 if name == "" {224 return nil225 }226 for index := range plays {227 play := &plays[index]228 if play.Metadata.Namespace == namespace && play.Metadata.Name == name {229 return play230 }231 }232 return nil233}234235// playTitle resolves the one line the idle screen draws for a Play. The236// first item's Presentation is what the library that fed liken said the237// item is, so a Series names the show a person put on, a Title names a238// film, an Album names a record, and a Play whose first item declares none239// of them falls back to the Play's own name. The operator resolves it here240// so the display formats one string and reads no Presentation of its own.241func playTitle(play *Play) string {242 if len(play.Spec.Items) > 0 && play.Spec.Items[0].Presentation != nil {243 presentation := play.Spec.Items[0].Presentation244 if presentation.Series != "" {245 return presentation.Series246 }247 if presentation.Title != "" {248 return presentation.Title249 }250 if presentation.Album != "" {251 return presentation.Album252 }253 }254 return play.Metadata.Name255}256257// writePlayerStatus follows the same two rules as the Play's status258// writer: an unchanged status is not written, and a conflict earns259// one retry. Nothing watches Players, so a needless write wakes no260// loop, but the skip still spares the API server a write per settled261// Player every pass.262func writePlayerStatus(c *Client, player *Player, desired PlayerStatus) error {263 same, err := samePlayerStatus(player.Status, desired)264 if err != nil {265 return err266 }267 if same {268 return nil269 }270271 player.Status = desired272 _, err = PutPlayerStatus(c, player)273 if !errors.Is(err, ErrConflict) {274 return err275 }276277 // A conflict means the Player changed between the read and the278 // write. The fresh copy carries the resourceVersion the API279 // server accepts, and the desired status still describes the280 // same Plays, so it goes on unchanged.281 fresh, err := GetPlayer(c, player.Metadata.Namespace, player.Metadata.Name)282 if err != nil {283 return err284 }285 same, err = samePlayerStatus(fresh.Status, desired)286 if err != nil || same {287 return err288 }289 fresh.Status = desired290 _, err = PutPlayerStatus(c, fresh)291 return err292}293294// samePlayerStatus compares the marshaled form, because that is what295// the API server stores and what omitempty decides.296func samePlayerStatus(current, desired PlayerStatus) (bool, error) {297 was, err := json.Marshal(current)298 if err != nil {299 return false, err300 }301 wants, err := json.Marshal(desired)302 if err != nil {303 return false, err304 }305 return string(was) == string(wants), nil306}
pod.go 95.5%
1package main23// The playback pod is the whole of what a Play becomes at run time.4// The trust split shows in what the pod does not get: it decodes media5// from the network, so it carries no ServiceAccount and no API6// credentials. mpv is the pod's own process, and the kubelet sends it7// the grace-period signal and reads its exit code. One native sidecar8// is the pod's bus client, and everything the pod says goes over the9// bus, which the operator alone reads onto a Play's status.1011import (12 "encoding/json"13 "slices"14 "strconv"15 "strings"16)1718// The container names, and the claim's pod-local name the container's19// resources.claims entries repeat.20const (21 playerContainer = "player"22 commandContainer = "command"23 podClaimName = "devices"24)2526// Every playback pod carries this label, so the pod watch selects the27// operator's own pods and nothing else on the node.28const (29 playbackLabelKey = "media.liken.sh/component"30 playbackLabelValue = "playback"31)3233// An init container with restartPolicy Always is what Kubernetes calls a34// native sidecar: the kubelet starts it before the player, keeps it35// beside the player, and restarts it alone when it exits. An ordinary36// container would not do, because the pod's restart policy is Never and a37// sidecar that exited would stay down; and the pod must still end when38// the player ends, which a second ordinary container would also prevent.39const sidecarRestartPolicy = "Always"4041// playbackGracePeriod is five seconds because mpv exits on the SIGTERM42// the kubelet sends, measured near one second, and nothing here has43// state to flush.44const playbackGracePeriod = 54546func podName(play string) string {47 return play + "-playback"48}4950// buildPod writes the pod a person wrote by hand before this operator51// existed. restartPolicy is Never because the pod's end is the play's52// end: a finished film is not a failure to restart. The player image's53// entrypoint shim execs mpv, so the arguments are nothing but the54// resolved playlist in spec order.55func buildPod(56 play *Play, claim *ResourceClaim, resolved resolution, image, sidecarImage, busAddress, topicBase string,57 remotes []boundRemote, prefs resolvedPreferences,58) *Pod {59 grace := int64(playbackGracePeriod)60 // The IPC volume is unconditional, so mpv serves its socket at one61 // path whether or not this pod binds a remote. The mount list is62 // built fresh rather than appended to resolved.Mounts, so the63 // resolution's own slice is never written through.64 mounts := make([]VolumeMount, 0, len(resolved.Mounts)+2)65 mounts = append(mounts, resolved.Mounts...)66 mounts = append(mounts, artMount(), ipcMount())6768 blocks := presentationBlocks(play.Spec.Items, resolved.Logos, resolved.Trickplays, resolved.Arts)6970 container := Container{71 Name: playerContainer,72 Image: image,73 // The image's entrypoint shim runs mpv, so the pod supplies74 // only what to play.75 Args: resolved.Items,76 VolumeMounts: mounts,77 // The shim reads the same blocks the command sidecar reads: the block78 // declares an item's shape, and the shim expands a music album into79 // one timeline before mpv sees any argument.80 Env: []EnvVar{{Name: presentationsVariable, Value: blocks}},81 }82 // The start is added only when the spec declares one, so an83 // ordinary run's pod carries nothing extra. The shim reads it and84 // turns it into mpv's --start.85 if play.Spec.Start != "" {86 container.Env = append(container.Env,87 EnvVar{Name: playStartVariable, Value: play.Spec.Start})88 }89 // Pass the resolved preference flags to the shim. Nothing is passed when no90 // tier stated a preference, so mpv keeps its own default and the feature adds91 // no behavior.92 //93 // The unit's level joins the same list, so mpv starts at the94 // level the unit already holds rather than at unity, and nothing95 // drops a moment later when the subscription catches up. The96 // subscription is the live authority from there.97 options := append(mpvPreferenceOptions(prefs), mpvVolumeOptions(play.Spec.Volume)...)98 if len(options) > 0 {99 container.Env = append(container.Env,100 EnvVar{Name: playerOptionsVariable, Value: strings.Join(options, "\n")})101 }102 // The display clock reads TZ against the image's tz database. Set it only103 // when the household stated a zone, so an unset zone leaves the pod104 // unchanged and the clock stays on UTC.105 if prefs.TimeZone != "" {106 container.Env = append(container.Env,107 EnvVar{Name: timeZoneVariable, Value: prefs.TimeZone})108 }109 // The player container holds every request the claim asks for,110 // because the playback claim holds the player's roles alone.111 for _, request := range claimRequests(claim) {112 container.Resources.Claims = append(container.Resources.Claims,113 ContainerClaim{Name: podClaimName, Request: request})114 }115116 volumes := make([]Volume, 0, len(resolved.Volumes)+2)117 volumes = append(volumes, resolved.Volumes...)118 volumes = append(volumes, artVolume(), Volume{Name: ipcVolumeName, EmptyDir: &EmptyDirVolumeSource{}})119120 // The pod's one sidecar is the command sidecar. It owns the mpv121 // socket and reads every controller the unit names, and the operator122 // reads its events-topic list back off the pod to tell whether a123 // Player reshaped this pod.124 initContainers := []Container{125 commandSidecar(play, claim, blocks, resolved.Mounts, sidecarImage, busAddress, topicBase, remotes),126 }127128 return &Pod{129 APIVersion: podAPIVersion,130 Kind: "Pod",131 Metadata: ObjectMeta{132 Name: podName(play.Metadata.Name),133 Namespace: play.Metadata.Namespace,134 Labels: map[string]string{playbackLabelKey: playbackLabelValue},135 OwnerReferences: []OwnerReference{playOwner(play)},136 },137 Spec: PodSpec{138 RestartPolicy: "Never",139 TerminationGracePeriodSeconds: &grace,140 ResourceClaims: []PodResourceClaim{{141 Name: podClaimName,142 ResourceClaimName: claim.Metadata.Name,143 }},144 InitContainers: initContainers,145 Containers: []Container{container},146 Volumes: volumes,147 },148 }149}150151// mpvPreferenceOptions maps the resolved preferences to mpv flags. It passes a152// flag only for a field that resolved, and adds --subs-match-os-language=no153// only when the feature is otherwise active.154func mpvPreferenceOptions(prefs resolvedPreferences) []string {155 var options []string156 if len(prefs.AudioLanguages) > 0 {157 options = append(options, "--alang="+strings.Join(prefs.AudioLanguages, ","))158 }159 if len(prefs.SubtitleLanguages) > 0 {160 options = append(options, "--slang="+strings.Join(prefs.SubtitleLanguages, ","))161 }162 switch prefs.Subtitles {163 case subtitlesOn:164 options = append(options, "--sub-visibility=yes", "--subs-with-matching-audio=yes")165 case subtitlesOff:166 options = append(options, "--sid=no")167 case subtitlesAuto:168 options = append(options, "--subs-with-matching-audio=no")169 }170 if len(options) == 0 {171 return nil172 }173 return append(options, "--subs-match-os-language=no")174}175176// mpvVolumeOptions turns the level the pod starts at into mpv's own177// flags. The operator fills the block with the unit's current state178// before it creates the pod, so what reaches mpv here is a snapshot,179// and the volume topic stays the authority.180func mpvVolumeOptions(volume *PlayVolume) []string {181 if volume == nil {182 return nil183 }184 var options []string185 if volume.Level != nil {186 options = append(options, "--volume="+strconv.Itoa(*volume.Level))187 }188 if volume.Muted != nil {189 options = append(options, "--mute="+mpvYesNo(*volume.Muted))190 }191 return options192}193194// commandSidecar is the playback pod's owner of the mpv IPC socket:195// the sidecar image in its command mode, holding no device claim. It196// subscribes to the Play's commands topic, drives mpv through the197// shared socket, and publishes the Play's status. It mounts the IPC198// volume, because it is the one container besides mpv that reaches the199// socket.200func commandSidecar(201 play *Play, claim *ResourceClaim, blocks string, mediaMounts []VolumeMount,202 sidecarImage, busAddress, topicBase string, remotes []boundRemote,203) Container {204 interval := play.Spec.TrickplayInterval205 if interval == "" {206 interval = defaultTrickplayInterval207 }208 env := []EnvVar{209 {Name: playNamespaceVariable, Value: play.Metadata.Namespace},210 {Name: playNameVariable, Value: play.Metadata.Name},211 {Name: busAddressVariable, Value: busAddress},212 {Name: topicBaseVariable, Value: topicBase},213 {Name: presentationsVariable, Value: blocks},214 {Name: trickplayIntervalVariable, Value: interval},215 }216 // The Player this Play runs on, which is the value a focus mark must217 // hold for a controller's press to reach this film, and the two218 // index-aligned topic lists of the unit's controllers. A Play on a219 // Player that names no Remote carries neither list, so its sidecar220 // subscribes to no controller at all.221 env = append(env, EnvVar{Name: playerNameVariable, Value: playerName(play)})222 if len(remotes) > 0 {223 events := make([]string, len(remotes))224 focuses := make([]string, len(remotes))225 for index, remote := range remotes {226 events[index] = remote.EventsTopic227 focuses[index] = remote.FocusTopic228 }229 env = append(env,230 EnvVar{Name: remoteEventsTopicsVariable, Value: strings.Join(events, "\n")},231 EnvVar{Name: remoteFocusTopicsVariable, Value: strings.Join(focuses, "\n")})232 }233 // The volume topic travels only for a unit that has speakers, and234 // the claim answers that: it holds a sink request only for a235 // Player that states sinks. A unit with nothing to hear names no236 // topic, and its sidecar answers no volume press.237 if claimHasSink(claim) {238 env = append(env, EnvVar{239 Name: playerVolumeTopicVariable,240 Value: playerVolumeTopic(topicBase, play.Metadata.Namespace, playerName(play)),241 })242 }243 return Container{244 Name: commandContainer,245 Image: sidecarImage,246 Command: []string{"/media-operator", commandMode},247 Env: env,248 // The command sidecar reads mpv's socket on the IPC volume and writes249 // decoded art on the art volume, and mpv reads that art back through the250 // same art volume. It also holds the player's media mounts, because the251 // source art, a logo or a trickplay sheet, sits in the film's own folder252 // and the sidecar opens it from there.253 VolumeMounts: append([]VolumeMount{ipcMount(), artMount()}, mediaMounts...),254 RestartPolicy: sidecarRestartPolicy,255 }256}257258// presentationBlocks bakes every item's block into one JSON array in259// spec order, so playlist position i indexes item i's block. An item260// with no presentation becomes an empty object, so every position has a261// definite value the sidecar forwards as it is.262//263// Each block carries the resolved logo for its item, so the bridge reads an264// nfs or claim logo by an in-pod path and mpv fetches an https logo by its265// URL. The cover art resolves the same way.266func presentationBlocks(items []PlayItem, logos, trickplays, arts []string) string {267 blocks := make([]json.RawMessage, len(items))268 for index, item := range items {269 if item.Presentation == nil {270 blocks[index] = json.RawMessage(emptyPresentation)271 continue272 }273 block := *item.Presentation274 if index < len(logos) {275 block.Logo = logos[index]276 }277 if index < len(trickplays) {278 block.Trickplay = trickplays[index]279 }280 if index < len(arts) {281 block.Art = arts[index]282 }283 encoded, err := json.Marshal(block)284 if err != nil {285 blocks[index] = json.RawMessage(emptyPresentation)286 continue287 }288 blocks[index] = encoded289 }290 array, err := json.Marshal(blocks)291 if err != nil {292 return "[]"293 }294 return string(array)295}296297func ipcMount() VolumeMount {298 return VolumeMount{Name: ipcVolumeName, MountPath: ipcMountPath}299}300301func artMount() VolumeMount {302 return VolumeMount{Name: artVolumeName, MountPath: artMountPath}303}304305// artVolume is disk-backed, not memory, so a decoded logo never counts against306// the pod's memory. Its sizeLimit caps it, because the bridge writes into it307// and a runaway decode must not fill the node disk.308func artVolume() Volume {309 return Volume{Name: artVolumeName, EmptyDir: &EmptyDirVolumeSource{SizeLimit: artSizeLimit}}310}311312// sameRemoteSet reports whether two pods carry the same controllers,313// by the events topics the command sidecar subscribes to. It reads no314// keymap, so a Keymap edit is bus state and not a shape change and315// recreates no pod. Only what the Player controls, the claim and the316// set of controllers, reshapes a running film.317func sameRemoteSet(current, desired *Pod) bool {318 return slices.Equal(podRemoteTopics(current), podRemoteTopics(desired))319}320321// podRemoteTopics reads the events topics the command sidecar322// subscribes to, in order, off its environment. Adding or removing a323// controller changes this list, and the recreate follows.324func podRemoteTopics(pod *Pod) []string {325 for _, container := range pod.Spec.InitContainers {326 if container.Name != commandContainer {327 continue328 }329 for _, variable := range container.Env {330 if variable.Name == remoteEventsTopicsVariable {331 return splitTopicLines(variable.Value)332 }333 }334 }335 return nil336}
remote.go 58.5%
1package main23// The reader mode is the standing remote pod's whole process. It4// reads the controller its claim delivered, folds each event through5// the table the operator publishes on this Remote's keys topic, and6// publishes the kernel's name for the control on the events topic. It7// holds no API credentials.8//9// The standing pod outlives every sleep of the controller. The claim10// tolerates the disconnected taint with no limit, so a controller a11// person puts down keeps its allocation and the pod keeps running. The12// node the claim delivers survives a sleep, so a person's press reaches13// the bus with no pod restart. A node can still end, on a pod restart or14// an unprepare, so the reader runs an outer loop: wait for the nodes,15// publish until they vanish, then wait again. Only the kubelet's SIGTERM16// ends the process. The pod publishes no link state, because the17// Peripheral the bluetooth-operator writes is the record of the link.18//19// The pod also publishes what its controller declares, on the retained20// codes topic, at every node open, and the operator reports the codes21// the Keymap leaves unbound on the Remote's status. A Remote in22// discovery keeps every node the claim delivered and logs each event23// the way a Keymap names it, so a person maps unknown hardware from24// the pod log.2526import (27 "context"28 "encoding/json"29 "fmt"30 "io"31 "os"32 "os/signal"33 "path/filepath"34 "slices"35 "sync"36 "syscall"37 "time"38)3940// The claim's CDI spec mounts the controller's event nodes at their41// kernel paths, and nothing tells this process which paths those are.42// So it globs the whole directory, and the capability test in evdev.go43// picks the node that carries the buttons. A variable so a test can44// point the glob at a directory of its own.45var inputNodePattern = "/dev/input/event*"4647// nodePollDelay is how long the reader waits between two scans of48// /dev/input while no node is there to read. A pod that starts before its49// claim is prepared finds an empty directory, which is ordinary, so the50// reader scans again every two seconds until the nodes appear.51var nodePollDelay = 2 * time.Second5253// reader holds the standing pod's bus client, the topics it publishes,54// and the declared codes it last published. The bus calls onConnect on its55// own goroutine and the node loop publishes on the pod's, so a mutex56// guards the document the two share.57type reader struct {58 bus *Bus59 eventsTopic string60 availabilityTopic string61 codesTopic string6263 // The retained topic the operator publishes this Remote's compiled64 // table on, and the fold state that reads it. The pod subscribes to65 // its own table alone.66 keysTopic string67 keys keyState6869 // The run's context, which every synthesised repeat runs under, so70 // no repeat outlives the process.71 repeatCtx context.Context7273 // The teaching mode the Remote's spec asks for. Discovery keeps74 // every node the claim delivered and logs each event the way a75 // Keymap names it, so a person reads an unknown controller's codes76 // out of the pod log.77 discovery bool7879 // Where the verdict and discovery lines go. It is a field so a test80 // reads what the pod would print.81 log io.Writer8283 // The verdict lines the last scan logged. The reader scans every84 // two seconds while it waits for a node, so it logs only a changed85 // picture. Only the node loop touches this field.86 verdicts []string8788 mutex sync.Mutex89 // The declared-codes document last published, so a reconnect90 // republishes it. A nil document is the cleared value.91 codes []byte92}9394// runReader finds the controller, connects to the bus, and publishes95// each event to the Remote's events topic. Setup that cannot succeed96// ends the process, so a pod the operator built wrong shows the failure97// in kubectl instead of publishing to a malformed topic.98func runReader() {99 namespace := os.Getenv(remoteNamespaceVariable)100 name := os.Getenv(remoteNameVariable)101 busAddress := os.Getenv(busAddressVariable)102 if namespace == "" || name == "" || busAddress == "" {103 fmt.Fprintf(os.Stderr,104 "remote: %s, %s, and %s must all be set; the operator sets them on every standing remote pod\n",105 remoteNamespaceVariable, remoteNameVariable, busAddressVariable)106 os.Exit(1)107 }108 base := os.Getenv(topicBaseVariable)109 if base == "" {110 base = defaultTopicBase111 }112113 // This process is its container's PID 1, and the kernel runs no114 // default action for a signal sent to PID 1, so the kubelet's115 // SIGTERM ends the pod's grace period unanswered unless it is116 // handled here.117 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)118 defer stop()119120 r := &reader{121 eventsTopic: remoteEventsTopic(base, namespace, name),122 availabilityTopic: remoteAvailabilityTopic(base, namespace, name),123 codesTopic: remoteCodesTopic(base, namespace, name),124 keysTopic: remoteKeysTopic(base, namespace, name),125 // The operator sets the variable only where the Remote asks for126 // discovery, so a pod that reads nothing here runs the ordinary127 // selection rule.128 discovery: os.Getenv(remoteDiscoveryVariable) == discoveryOn,129 log: os.Stdout,130 // The pod starts on the base, the same rows compiled into this131 // binary, so a controller behaves as an unmapped one from the first132 // press. The retained table arrives moments later and replaces it,133 // and a Keymap's rows take effect then.134 keys: keyState{table: baseKeys},135 }136 // The reader reads one topic off the bus, its own key table. It also137 // names a Last Will on its availability topic, the pattern the138 // playback sidecar uses: the broker publishes offline on any139 // disconnect this pod does not make cleanly, so a pod the kubelet140 // killed leaves no retained document that reads as a live one. The141 // Bus manages its own connection and reconnects with142 // backoff, so a broker restart costs a gap in events and not the143 // reader.144 r.bus = newBus(busAddress, "remote-"+namespace+"-"+name,145 &busWill{Topic: r.availabilityTopic, Payload: []byte(availabilityOffline), Retained: true},146 r.onConnect, r.handle)147 // The table topic is retained, so the current table arrives the148 // instant the pod connects, and the Bus re-sends the filter on every149 // reconnect. The subscription is made once.150 r.bus.Subscribe(r.keysTopic)151 go r.bus.Run(ctx)152153 r.publishEvents(ctx)154 os.Exit(0)155}156157// handle folds one inbound message. The pod subscribes to one topic,158// its own table, so the topic is the whole dispatch.159func (r *reader) handle(topic string, payload []byte) {160 if topic == r.keysTopic {161 r.setTable(payload)162 }163}164165// onConnect refills the broker the moment a session reaches a CONNACK. The166// Bus remembers subscriptions across a reconnect but not publishes, and a167// fresh broker session holds none of the retained state this pod owns, so168// the availability and the declared codes both go out again here. A pod169// holding no nodes republishes the cleared value.170func (r *reader) onConnect(bus *Bus) {171 bus.Publish(r.availabilityTopic, []byte(availabilityOnline), true)172 r.mutex.Lock()173 codes := r.codes174 r.mutex.Unlock()175 bus.Publish(r.codesTopic, codes, true)176}177178// publishCodes writes what the nodes declare to the retained codes179// topic and keeps it for a later reconnect. The operator subtracts the180// Keymap's bindings from it and reports the gap on the Remote's181// status.182func (r *reader) publishCodes(codes remoteCodes) {183 // A struct of two integer slices marshals unconditionally, so the184 // error is dropped.185 payload, _ := json.Marshal(codes)186 r.mutex.Lock()187 r.codes = payload188 r.mutex.Unlock()189 r.bus.Publish(r.codesTopic, payload, true)190}191192// clearCodes empties the retained codes topic when the nodes vanish.193// The empty payload is the cleared retained value, the same clear the194// operator writes on the keys topic of a Remote that is gone.195func (r *reader) clearCodes() {196 r.mutex.Lock()197 r.codes = nil198 r.mutex.Unlock()199 r.bus.Publish(r.codesTopic, nil, true)200}201202// publishEvents is the standing pod's outer loop. It waits for the203// controller's nodes, publishes every bindable event until the nodes204// vanish, then waits again. The nodes can end on an unprepare or when205// the claim's device changes, and the pod keeps running across that, so206// only ctx ending stops this loop. Each batch of nodes publishes the207// codes it declares and clears them when it ends.208func (r *reader) publishEvents(ctx context.Context) {209 for ctx.Err() == nil {210 nodes, err := r.awaitNodes(ctx)211 if err != nil {212 return213 }214 r.publishCodes(declaredCodes(nodes))215 r.readAndPublish(ctx, nodes)216 r.clearCodes()217 }218}219220// awaitNodes polls for a node the mode keeps. The nodes appear when the221// claim is prepared, which can be minutes after this pod schedules, so an222// empty directory is ordinary and only ctx ends the wait.223func (r *reader) awaitNodes(ctx context.Context) ([]openNode, error) {224 for {225 nodes := r.matchingNodes()226 if len(nodes) > 0 {227 return nodes, nil228 }229 select {230 case <-ctx.Done():231 return nil, ctx.Err()232 case <-time.After(nodePollDelay):233 }234 }235}236237// matchingNodes opens every event node and keeps the ones the mode238// keeps: the nodes that pass the capability test, or every node the239// claim delivered in discovery. The open is syscall.Open because the240// ioctls in inspectNode need the raw descriptor before any *os.File241// exists. A node that will not open or answer is skipped.242func (r *reader) matchingNodes() []openNode {243 paths, err := filepath.Glob(inputNodePattern)244 if err != nil {245 return nil246 }247 var nodes []openNode248 var verdicts []string249 for _, path := range paths {250 descriptor, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC, 0)251 if err != nil {252 continue253 }254 node, answered := inspectNode(descriptor, path)255 if !answered {256 syscall.Close(descriptor)257 continue258 }259 // Discovery keeps every node the claim delivered, because the260 // point of discovery is to see what the rule rejects.261 keep := r.discovery || controllerBitmaps(node.keys, node.axes)262 verdicts = append(verdicts, node.verdict(keep).line())263 if !keep {264 syscall.Close(descriptor)265 continue266 }267 node.file = os.NewFile(uintptr(descriptor), path)268 nodes = append(nodes, node)269 }270 r.logVerdicts(verdicts)271 return nodes272}273274// logVerdicts writes one line per node, and only when the picture275// changed, because the two-second scan of an empty directory would276// otherwise repeat the same report forever.277func (r *reader) logVerdicts(verdicts []string) {278 if slices.Equal(verdicts, r.verdicts) {279 return280 }281 r.verdicts = verdicts282 for _, line := range verdicts {283 fmt.Fprintf(r.log, "remote: %s\n", line)284 }285}286287// declaredCodes is what the kept nodes declare, as the union over the288// batch. It is the set the operator subtracts a Keymap's bindings289// from.290func declaredCodes(nodes []openNode) remoteCodes {291 var codes remoteCodes292 for _, node := range nodes {293 codes.Keys = union(codes.Keys, declaredKeyCodes(node.keys))294 codes.Axes = union(codes.Axes, declaredHatAxes(node.axes))295 }296 return codes297}298299// union folds one node's codes into the batch's, in code order and300// with no repeat, because two of a controller's nodes can declare one301// code.302func union(held, arriving []uint16) []uint16 {303 for _, code := range arriving {304 if index, found := slices.BinarySearch(held, code); !found {305 held = slices.Insert(held, index, code)306 }307 }308 return held309}310311func closeNodes(nodes []openNode) {312 for _, node := range nodes {313 node.file.Close()314 }315}316317// readAndPublish reads the controller's nodes and publishes each318// bindable event to the events topic. It returns when the nodes vanish, so319// the outer loop waits for the ones that come back. The process keeps320// running.321func (r *reader) readAndPublish(ctx context.Context, nodes []openNode) {322 defer closeNodes(nodes)323 // Every repeat this batch of nodes starts runs under the run's324 // context, and the nodes vanishing ends them all: a node that ends325 // mid-hold sends no release.326 r.repeatCtx = ctx327 defer r.stopAllRepeats()328329 reading, stopReading := context.WithCancel(ctx)330 defer stopReading()331 // A reader blocked in read(2) ends only when its file closes, so the332 // context's end must close the nodes to unblock it.333 defer context.AfterFunc(reading, func() { closeNodes(nodes) })()334335 events := readNodes(reading, nodes, stopReading)336 for {337 select {338 case <-reading.Done():339 return340 case event, open := <-events:341 if !open {342 return343 }344 if !publishable(event.event) {345 continue346 }347 // Discovery logs each raw event, and the fold below is348 // unchanged, so a controller in discovery still drives its349 // unit and still wakes a faded screen.350 if r.discovery {351 for _, line := range discoveryLines(event.node, event.event) {352 fmt.Fprintf(r.log, "remote: %s\n", line)353 }354 }355 r.fold(event.event)356 }357 }358}359360// publishable is the gate before the fold. Every EV_KEY event reaches361// it. Of EV_ABS only the two hat axes do, because the analog sticks362// report at 250Hz. EV_SYN and EV_MSC carry no control at all.363func publishable(event inputEvent) bool {364 switch event.Type {365 case evKey:366 return true367 case evAbs:368 for _, code := range axisCodes {369 if code == event.Code {370 return true371 }372 }373 }374 return false375}376377// readNodes starts one reader per matched node and merges their events378// into one channel. The first reader to end cancels the whole batch,379// because a vanished node means the claim's nodes changed and the outer380// loop must wait for the ones that come back.381func readNodes(ctx context.Context, nodes []openNode, ended context.CancelFunc) <-chan nodeEvent {382 events := make(chan nodeEvent, 64)383 var reading sync.WaitGroup384 for _, node := range nodes {385 reading.Add(1)386 go func() {387 defer reading.Done()388 defer ended()389 readNode(ctx, node, events)390 }()391 }392 go func() {393 reading.Wait()394 close(events)395 }()396 return events397}398399// readNode delivers one node's events until the node ends. A read error400// is not retried and the node is not reopened, because the error means the401// node is gone; the outer loop finds the new ones when they appear.402func readNode(ctx context.Context, node openNode, events chan<- nodeEvent) {403 label := node.label()404 buffer := make([]byte, inputEventSize*64)405 for {406 read, err := node.file.Read(buffer)407 if err != nil {408 return409 }410 for _, event := range parseInputEvents(buffer[:read]) {411 select {412 case events <- nodeEvent{node: label, event: event}:413 case <-ctx.Done():414 return415 }416 }417 }418}419420// nodeEvent is one event and the node it came from, so a discovery421// line names the node beside the code. The label is built once per422// node, not once per event.423type nodeEvent struct {424 node string425 event inputEvent426}
remotekeys.go 96.6%
1package main23// The standing pod's normalising half. Normalisation runs here and4// nowhere else, because this is the one process beside the device, it5// is where hwdb runs on any Linux machine, and one pod serves every6// consumer at once. The pod holds the table the operator publishes on7// this Remote's keys topic, folds each raw evdev event through it,8// publishes the kernel's name for the control, and synthesises the9// repeat a device that never autorepeats cannot report.1011import (12 "context"13 "encoding/json"14 "sync"15 "time"16)1718// maxRepeatWindow caps one synthesised repeat. A controller that19// sleeps mid-hold publishes no release, and without the cap the repeat20// would run until the film ended. No person holds a control this21// long.22var maxRepeatWindow = 30 * time.Second2324// The ceiling on the milliseconds a table off the bus can name. It is25// past any rate a person holds a control at and past the window above,26// and it keeps a value that overflows a Duration from panicking the27// ticker.28const maxRepeatMillis = 600002930// A control is one physical thing on the controller: the event type31// with the code. The type belongs in the key because a hat axis and a32// key share the number space, so the code alone would collide.33type control struct {34 eventType uint1635 code uint1636}3738// keyState is what the fold holds: the table the keys topic39// delivered, the key each hat axis is holding down, and one cancel40// per control that repeats. The axis needs holding because the41// release of a hat carries value 0 and no direction, so the name of42// the key to release comes from the press.43type keyState struct {44 mu sync.Mutex45 table []compiledBinding46 held map[uint16]string47 repeats map[control]context.CancelFunc48}4950// setTable replaces the held table. A payload that does not decode51// leaves the last good table in place, so a cleared or malformed table52// does not stop a controller mid-film. The repeat milliseconds are53// clamped, because a table off the bus is not necessarily the54// operator's own compile.55func (r *reader) setTable(payload []byte) {56 var table []compiledBinding57 if err := json.Unmarshal(payload, &table); err != nil {58 return59 }60 for index := range table {61 table[index].RepeatDelay = clampMillis(table[index].RepeatDelay)62 table[index].RepeatInterval = clampMillis(table[index].RepeatInterval)63 }64 r.keys.mu.Lock()65 r.keys.table = table66 r.keys.mu.Unlock()67}6869// clampMillis holds one value under the ceiling. A value at or below70// zero passes through: an interval below zero is a row that does not71// repeat, and a delay that low starts the repeat at once.72func clampMillis(millis int) int {73 if millis > maxRepeatMillis {74 return maxRepeatMillis75 }76 return millis77}7879// fold is the whole of what the pod does with one event, and it has80// three answers. A control the table names publishes that name. A81// KEY_* code with no row publishes itself. A control the table maps to82// none, or the kernel does not name, publishes nothing. The value is83// the kernel's, and a row with a repeat block makes the press start84// the synthesised stream the release ends.85func (r *reader) fold(event inputEvent) {86 if event.Type == evAbs {87 r.foldAxis(event)88 return89 }90 row, mapped := lookupKey(r.table(), event)91 name := keyCodeNames[event.Code]92 if mapped {93 name = row.Key94 }95 if name == "" || name == keyNone {96 return97 }98 switch {99 case event.Value == 0:100 r.stopRepeat(control{eventType: evKey, code: event.Code})101 r.publishKey(name, 0)102 case event.Value == 1:103 r.publishKey(name, 1)104 r.startRepeat(control{eventType: evKey, code: event.Code}, name, row)105 default:106 // The kernel's own autorepeat on a keyboard key. It passes as it107 // arrived, and the pod synthesises nothing beside it.108 r.publishKey(name, event.Value)109 }110}111112// foldAxis is the hat's half. It is separate because a hat reports its113// direction in the value: the press names the key and the release114// carries no direction at all, so the pod holds the key the press115// published to name the release.116func (r *reader) foldAxis(event inputEvent) {117 if event.Value == 0 {118 name := r.releaseAxis(event.Code)119 if name == "" {120 return121 }122 r.stopRepeat(control{eventType: evAbs, code: event.Code})123 r.publishKey(name, 0)124 return125 }126 row, mapped := lookupKey(r.table(), event)127 if !mapped || row.Key == keyNone {128 return129 }130 r.holdAxis(event.Code, row.Key)131 r.publishKey(row.Key, 1)132 r.startRepeat(control{eventType: evAbs, code: event.Code}, row.Key, row)133}134135func (r *reader) table() []compiledBinding {136 r.keys.mu.Lock()137 defer r.keys.mu.Unlock()138 return r.keys.table139}140141// holdAxis records the key one hat direction is holding down. A142// second press with no release between them replaces the first,143// because the two directions of one axis are one control.144func (r *reader) holdAxis(code uint16, key string) {145 r.keys.mu.Lock()146 defer r.keys.mu.Unlock()147 if r.keys.held == nil {148 r.keys.held = map[uint16]string{}149 }150 r.keys.held[code] = key151}152153// releaseAxis names the key a hat's return to center releases, and154// forgets it. A center with no press before it names nothing, which155// is the ordinary case of an axis the table drops.156func (r *reader) releaseAxis(code uint16) string {157 r.keys.mu.Lock()158 defer r.keys.mu.Unlock()159 key := r.keys.held[code]160 delete(r.keys.held, code)161 return key162}163164// publishKey writes one key event to the Remote's events topic, not165// retained, because a press is an event and not a state.166func (r *reader) publishKey(key string, value int32) {167 // A string and an integer marshal unconditionally, so the error is168 // dropped.169 payload, _ := json.Marshal(keyEvent{Key: key, Value: value})170 r.bus.Publish(r.eventsTopic, payload, false)171}172173// startRepeat runs one held control's synthesised repeat. A row with174// no interval starts nothing. A second press of the same control175// replaces a running repeat, because a second press means the release176// was missed or the hat changed direction.177func (r *reader) startRepeat(held control, key string, row compiledBinding) {178 if row.RepeatInterval <= 0 {179 return180 }181 ctx, cancel := context.WithCancel(r.repeatCtx)182 r.keys.mu.Lock()183 if previous, running := r.keys.repeats[held]; running {184 previous()185 }186 if r.keys.repeats == nil {187 r.keys.repeats = map[control]context.CancelFunc{}188 }189 r.keys.repeats[held] = cancel190 r.keys.mu.Unlock()191 go runRepeat(ctx,192 time.Duration(row.RepeatDelay)*time.Millisecond,193 time.Duration(row.RepeatInterval)*time.Millisecond,194 func() { r.publishKey(key, 2) })195}196197// stopRepeat ends the repeat a release names. A release for a control198// with no repeat is the ordinary case and does nothing.199func (r *reader) stopRepeat(held control) {200 r.keys.mu.Lock()201 if cancel, running := r.keys.repeats[held]; running {202 cancel()203 delete(r.keys.repeats, held)204 }205 r.keys.mu.Unlock()206}207208// stopAllRepeats ends every synthesised repeat at once, for the moment209// the controller's nodes vanish. A controller that slept mid-hold210// sends no release, and nothing may keep publishing for a device that211// is gone.212func (r *reader) stopAllRepeats() {213 r.keys.mu.Lock()214 for held, cancel := range r.keys.repeats {215 cancel()216 delete(r.keys.repeats, held)217 }218 r.keys.held = nil219 r.keys.mu.Unlock()220}221222// runRepeat is the clock a held control ticks on. It waits the delay,223// which is what separates a tap from a hold, then fires every224// interval. It ends on the context, which the release cancels, or on225// the safety window.226func runRepeat(ctx context.Context, delay, interval time.Duration, fire func()) {227 select {228 case <-ctx.Done():229 return230 case <-time.After(delay):231 }232 window := time.NewTimer(maxRepeatWindow)233 defer window.Stop()234 ticker := time.NewTicker(interval)235 defer ticker.Stop()236 for {237 select {238 case <-ctx.Done():239 return240 case <-window.C:241 return242 case <-ticker.C:243 fire()244 }245 }246}
remotepod.go 100.0%
1package main23// Each Remote reconciles into one standing pod, owned by the Remote4// through an owner reference, so deleting the Remote tears the pod5// down. The pod holds the controller's device claim, which pins it to6// the machine that owns the radio and runs it whether or not anything7// plays.8//9// The container is the sidecar image in its reader mode, which reads10// the claim's event nodes and publishes each event to the Remote's11// events topic.1213// The one container in the standing pod. The pod runs a single reader,14// so its name is the job it does.15const remoteReaderContainer = "reader"1617// remotePodName is the name of a Remote's standing pod, the Remote's18// name plus its job, so a person reading either object finds the other.19func remotePodName(remote string) string {20 return remote + "-remote"21}2223// remoteClaimName is the standing pod's claim, the pod's name plus the24// suffix the playback claim uses, so a person reading either object25// finds the other.26func remoteClaimName(remote string) string {27 return remotePodName(remote) + "-devices"28}2930// remoteOwner is the ownerReference that makes deleting the Remote the31// whole teardown: the garbage collector deletes the claim and the pod32// the Remote owns. The operator deletes either one itself only to33// replace an object that no longer matches the template.34func remoteOwner(remote *Remote) OwnerReference {35 return OwnerReference{36 APIVersion: mediaAPIVersion,37 Kind: "Remote",38 Name: remote.Metadata.Name,39 UID: remote.Metadata.UID,40 Controller: true,41 }42}4344// buildRemoteClaim turns one Remote into the standing claim for its45// controller: one request, named for the Remote behind the remote46// prefix, for the device the Remote's spec selects.47//48// The claim tolerates bluetooth.liken.sh/disconnected with no limit, so49// a controller that sleeps keeps its allocation and the pod keeps50// running. It does not tolerate bluetooth.liken.sh/no-input-node, which51// is NoSchedule, so the pod stays Pending until the controller first52// connects, then runs across every sleep after.53func buildRemoteClaim(remote *Remote) *ResourceClaim {54 claim := &ResourceClaim{55 APIVersion: claimAPIVersion,56 Kind: "ResourceClaim",57 Metadata: ObjectMeta{58 Name: remoteClaimName(remote.Metadata.Name),59 Namespace: remote.Metadata.Namespace,60 OwnerReferences: []OwnerReference{remoteOwner(remote)},61 },62 }63 claim.add(64 remoteRequestName(remote.Metadata.Name),65 PlayerDevice{Class: remote.Spec.Device.Class, Selector: remote.Spec.Device.Selector},66 tolerateForever(remoteDisconnectedTaint),67 )68 return claim69}7071// buildRemotePod writes the standing pod: the sidecar image in its72// reader mode, holding the controller's claim and publishing to the73// bus.74//75// restartPolicy is Always because the pod is a service and not a job: a76// crash restarts it, and the pod ends only when the Remote is deleted.77// It carries no IPC volume, because the reader drives no mpv socket.78func buildRemotePod(remote *Remote, claim *ResourceClaim, sidecarImage, busAddress, topicBase string) *Pod {79 container := Container{80 Name: remoteReaderContainer,81 Image: sidecarImage,82 Command: []string{"/media-operator", remoteMode},83 Env: []EnvVar{84 {Name: remoteNamespaceVariable, Value: remote.Metadata.Namespace},85 {Name: remoteNameVariable, Value: remote.Metadata.Name},86 {Name: busAddressVariable, Value: busAddress},87 {Name: topicBaseVariable, Value: topicBase},88 },89 }90 // The discovery variable is set only where the Remote asks for the91 // mode, so an ordinary Remote's pod carries the spec it always92 // carried and does not roll for a field it does not use.93 if remote.Spec.Discovery {94 container.Env = append(container.Env,95 EnvVar{Name: remoteDiscoveryVariable, Value: discoveryOn})96 }97 // The one container holds the claim's one request, the controller98 // this Remote selects.99 for _, request := range claimRequests(claim) {100 container.Resources.Claims = append(container.Resources.Claims,101 ContainerClaim{Name: podClaimName, Request: request})102 }103104 return &Pod{105 APIVersion: podAPIVersion,106 Kind: "Pod",107 Metadata: ObjectMeta{108 Name: remotePodName(remote.Metadata.Name),109 Namespace: remote.Metadata.Namespace,110 OwnerReferences: []OwnerReference{remoteOwner(remote)},111 },112 Spec: PodSpec{113 RestartPolicy: "Always",114 ResourceClaims: []PodResourceClaim{{115 Name: podClaimName,116 ResourceClaimName: claim.Metadata.Name,117 }},118 Containers: []Container{container},119 },120 }121}122123// reconcileRemote reconciles one Remote into its standing claim and124// pod, both owned by the Remote.125//126// The pair follows the template: an edit to the Remote's device127// selector, or a release that changes the sidecar image, deletes the128// stale object and the next pass creates the replacement.129//130// The reader pod drops controller input for the few seconds the131// replacement takes, and it holds no other state a rebuild would have to132// recover. A guard that held the roll while a person watched a film would133// trade those seconds for a pod of unpredictable age, so the pass rolls134// the pod whenever the template changes.135func (o *operator) reconcileRemote(remote *Remote, known claimRead) error {136 claim := buildRemoteClaim(remote)137 pod := buildRemotePod(remote, claim, o.sidecarImage, o.busAddress, o.topicBase)138 return o.reconcileStanding(standingPair(claim, pod), known)139}
report.go 100.0%
1package main23// The report desk is the boundary between the bus and the reconcile4// loop. The operator subscribes to every Play's status and availability5// topic, and the bus handler folds each message in here. The reconcile6// pass reads the newest report per Play and writes it into the Play's7// status. The playback pod holds no API credentials, so this desk is8// the only path a report takes to the control plane.910import (11 "strings"12 "sync"13)1415// reports holds the newest report per run and the wake the loop reads.16// One mutex covers the maps, because the bus handler runs on the bus17// reader's goroutine and the loop runs on its own.18//19// seen holds every run the desk has taken any bus message for, online or20// offline. latest drops a run the moment it goes offline, so it cannot21// answer which runs still have a retained topic on the broker; seen can,22// and it is how the operator finds a deleted Play's gravestone to clear.23//24// ended holds the ending mark of each run that reported one. It is kept25// beside latest rather than read out of it, because the sidecar publishes26// the ending and then goes offline, and offline drops the latest report.27// The mark has to outlive that drop: the pod lives on for seconds after28// the film is over, and the Player is idle for every one of them.29type reports struct {30 mutex sync.Mutex31 latest map[string]playReport32 ended map[string]bool33 seen map[string]bool34 wake chan<- struct{}35}3637func newReports(wake chan<- struct{}) *reports {38 return &reports{39 latest: map[string]playReport{},40 ended: map[string]bool{},41 seen: map[string]bool{},42 wake: wake,43 }44}4546// runKey is the one key shape for a run. Namespace and name identify a47// run everywhere in this operator, and one shape keeps the map and the48// pass in step.49func runKey(namespace, name string) string {50 return namespace + "/" + name51}5253// splitRunKey reverses runKey. A namespace and a name hold no slash, so54// the first one separates the two.55func splitRunKey(key string) (namespace, name string) {56 namespace, name, _ = strings.Cut(key, "/")57 return namespace, name58}5960// fold records the newest report for a run. A report is a whole61// observation, so the newest one says everything an older one did.62//63// The ending, a pause, or an item change is what a person waits to see,64// so each wakes the loop at once. The ending is the one a person is65// looking at the screen for: the pass it wakes is what turns the idle66// screen back on. A position that only advances updates the desk and67// wakes nothing: the reconcile pass reads the current position off the68// desk on its next backstop tick, so a steadily playing film writes its69// resource on that interval and not on every report. This is the70// throttle that keeps a one-second bus cadence from becoming a71// one-second write to etcd.72//73// The mark follows the report rather than latching, so a Play recreated74// under the same name reports a run of its own and starts not ended.75func (r *reports) fold(namespace, name string, report playReport) {76 key := runKey(namespace, name)77 r.mutex.Lock()78 previous, had := r.latest[key]79 r.latest[key] = report80 r.ended[key] = report.Ended81 r.seen[key] = true82 r.mutex.Unlock()83 if !had || report.Ended != previous.Ended ||84 report.Paused != previous.Paused || report.Item != previous.Item {85 poke(r.wake)86 }87}8889// availability marks a Play online or offline. Either way the run is one90// the desk has now seen, so it joins seen. Offline also drops the latest91// report, because the retained status the broker still holds describes a92// pod that is gone, and a stale report must not read as a live Play; the93// wake lets the pass rewrite the status the drop changed.94func (r *reports) availability(namespace, name string, online bool) {95 key := runKey(namespace, name)96 r.mutex.Lock()97 r.seen[key] = true98 if !online {99 delete(r.latest, key)100 }101 r.mutex.Unlock()102 if !online {103 poke(r.wake)104 }105}106107// latestFor returns the newest report, the only one kept, or nil when108// the desk holds none.109func (r *reports) latestFor(namespace, name string) *playReport {110 r.mutex.Lock()111 defer r.mutex.Unlock()112 report, held := r.latest[runKey(namespace, name)]113 if !held {114 return nil115 }116 return &report117}118119// endedFor reports whether the run's sidecar has said the run is over.120// The Play and its pod still exist while this is true, and the Play's121// phase still reads from the pod; only the Player's presentable state122// reads the mark.123func (r *reports) endedFor(namespace, name string) bool {124 r.mutex.Lock()125 defer r.mutex.Unlock()126 return r.ended[runKey(namespace, name)]127}128129// retain drops the latest report of a deleted Play. The pass hands over130// the set of runs that still exist, and the map shrinks to match, so the131// desk never serves a report for a run the collection no longer holds.132// seen is left alone: the operator still needs it to find and clear the133// deleted Play's retained topics.134func (r *reports) retain(live map[string]bool) {135 r.mutex.Lock()136 defer r.mutex.Unlock()137 for key := range r.latest {138 if !live[key] {139 delete(r.latest, key)140 }141 }142 // The ending mark goes with the report it came on. A Play that no143 // longer exists names nothing, and a Play later created under the same144 // name must not start life ended.145 for key := range r.ended {146 if !live[key] {147 delete(r.ended, key)148 }149 }150}151152// stale returns the runs the desk has seen a bus message for that no153// longer exist. Each is a deleted Play whose retained status and154// availability the broker still holds, and which the operator clears155// after a grace period.156func (r *reports) stale(live map[string]bool) []string {157 r.mutex.Lock()158 defer r.mutex.Unlock()159 var gone []string160 for key := range r.seen {161 if !live[key] {162 gone = append(gone, key)163 }164 }165 return gone166}167168// forget drops every trace of one run, after the operator has cleared169// its retained topics, so the desk does not offer it as stale again.170func (r *reports) forget(key string) {171 r.mutex.Lock()172 delete(r.latest, key)173 delete(r.ended, key)174 delete(r.seen, key)175 r.mutex.Unlock()176}
resolve.go 98.3%
1package main23// The resolver is the extension point the design names: a new4// scheme becomes an entry here, and the Play spec never changes5// shape. Of the three schemes today, https:// costs the pod nothing6// but an argument, nfs:// costs it a volume the kubelet mounts with7// the kernel's NFS client, and claim:// costs it a claim the kubelet8// resolves to whatever storage backs it.9//10// The resolver reads the media and the art URIs in one pass. It groups every11// nfs:// URI by server, mounts each server's common ancestor once, and12// rewrites each file's path under that mount. So a film and its logo in one13// folder become one read-only mount of that folder. A claim:// URI groups by14// claim name and mounts the claim at its own root, because the claim already15// bounds what the pod can read. The servers and the claims share one run of16// mount numbers, in the order the playlist first names each of them.1718import (19 "fmt"20 "net/url"21 "strconv"22 "strings"23)2425// mediaMountPrefix is where the media mounts land in the playback26// pod, NFS directories and claims alike, numbered by first appearance27// in the playlist. A directory or claim name would need escaping to28// be a mount path; an ordinal never does.29const mediaMountPrefix = "/media/"3031// resolution is one resolved playlist: the player's arguments in32// spec order, and the volumes and mounts the pod needs to reach33// them.34//35// Logos is the resolved logo for each item, in spec order. An item with no36// logo has an empty string.37type resolution struct {38 Items []string39 Logos []string40 // Trickplays is the resolved trickplay reference for each item, in spec41 // order. An item with no trickplay has an empty string.42 Trickplays []string43 // Arts is the resolved cover reference for each item, in spec order. It44 // resolves the way the logo does, and an item with no art has an empty45 // string.46 Arts []string47 Volumes []Volume48 Mounts []VolumeMount49}5051// nfsRef is one parsed nfs:// URI: the server it names, and the path segments52// below the server's root. The last segment is the file.53type nfsRef struct {54 server string55 segments []string56}5758// claimRef is one parsed claim:// URI: the claim it names, and the path59// segments under the claim's root. The last segment is the file, or an60// album's folder.61type claimRef struct {62 claim string63 segments []string64}6566// resolvedRef is one URI classified for the pod. A passthrough is an https://67// URL, or an empty art field the pod uses as it is. An nfs reference rewrites68// to a path under its server's mount, and a claim reference to a path under69// its claim's mount.70type resolvedRef struct {71 passthrough string72 nfs *nfsRef73 claim *claimRef74}7576// mountKey names one mount the pod needs: an NFS server or a claim. Both77// kinds key one map and one run of ordinals, so a mount's number says when78// the playlist first named it and nothing about its kind.79type mountKey struct {80 scheme string81 name string82}8384// mount reports the mount a reference needs and the path segments under it.85// A passthrough reports none, because the pod uses the URL as it is.86func (r resolvedRef) mount() (mountKey, []string, bool) {87 switch {88 case r.nfs != nil:89 return mountKey{scheme: schemeNFS, name: r.nfs.server}, r.nfs.segments, true90 case r.claim != nil:91 return mountKey{scheme: schemeClaim, name: r.claim.claim}, r.claim.segments, true92 }93 return mountKey{}, nil, false94}9596// resolvePlay turns the spec's items into what the pod needs. An https97// URI resolves to nothing but itself, because any machine can stream it98// and seek in it with range requests.99//100// An nfs URI is ambiguous about where the export ends and the path inside it101// begins. So the resolver mounts the common ancestor of every nfs URI on one102// server, and passes each file's path under that mount. Mounting a wider103// subtree than one file is safe, because the mount is read-only.104func resolvePlay(items []PlayItem) (resolution, error) {105 mediaRefs := make([]resolvedRef, len(items))106 logoRefs := make([]resolvedRef, len(items))107 trickRefs := make([]resolvedRef, len(items))108 artRefs := make([]resolvedRef, len(items))109110 var mountOrder []mountKey111 seen := map[mountKey]bool{}112 dirLists := map[mountKey][][]string{}113 register := func(ref resolvedRef) {114 key, segments, ok := ref.mount()115 if !ok {116 return117 }118 if !seen[key] {119 seen[key] = true120 mountOrder = append(mountOrder, key)121 }122 // Only an nfs reference narrows its mount, to the common123 // ancestor of its directories. A claim mounts at its root,124 // so it contributes no directory.125 if ref.nfs != nil {126 dirLists[key] = append(dirLists[key], segments[:len(segments)-1])127 }128 }129130 for index, item := range items {131 // A directory item is refused unless its block marks it as an album.132 // The pod expands an album into one timeline and one playlist entry,133 // and a directory mpv expands itself would add entries the operator134 // never counted, so every later item's block and art would land on135 // the wrong track.136 if strings.HasSuffix(item.URI, "/") && !albumItem(item) {137 return resolution{}, fmt.Errorf(138 "the URI %q names a directory; mark the item as an album with type music and hint album, or name a file",139 item.URI)140 }141 media, err := parseRef(item.URI)142 if err != nil {143 return resolution{}, err144 }145 mediaRefs[index] = media146 register(media)147148 logo, trickplay, cover := "", "", ""149 if item.Presentation != nil {150 logo = item.Presentation.Logo151 trickplay = item.Presentation.Trickplay152 cover = item.Presentation.Art153 }154 if logo != "" {155 art, err := parseRef(logo)156 if err != nil {157 return resolution{}, err158 }159 logoRefs[index] = art160 register(art)161 }162 if trickplay != "" {163 trick, err := parseRef(trickplay)164 if err != nil {165 return resolution{}, err166 }167 trickRefs[index] = trick168 register(trick)169 }170 if cover != "" {171 art, err := parseRef(cover)172 if err != nil {173 return resolution{}, err174 }175 artRefs[index] = art176 register(art)177 }178 }179180 ancestor := map[mountKey][]string{}181 ordinal := map[mountKey]int{}182 var resolved resolution183 for index, key := range mountOrder {184 anc := commonPrefix(dirLists[key])185 ord := index + 1186 ancestor[key] = anc187 ordinal[key] = ord188 volume := Volume{Name: "media-" + strconv.Itoa(ord)}189 switch key.scheme {190 case schemeNFS:191 volume.NFS = &NFSVolumeSource{Server: key.name, Path: "/" + strings.Join(anc, "/"), ReadOnly: true}192 case schemeClaim:193 volume.PersistentVolumeClaim = &PersistentVolumeClaimVolumeSource{ClaimName: key.name, ReadOnly: true}194 }195 resolved.Volumes = append(resolved.Volumes, volume)196 // Read-only in both places, on the volume and on the mount,197 // because a player never writes to a library.198 resolved.Mounts = append(resolved.Mounts, VolumeMount{199 Name: volume.Name,200 MountPath: mountPathFor(ord),201 ReadOnly: true,202 })203 }204205 rewrite := func(ref resolvedRef) string {206 key, segments, ok := ref.mount()207 if !ok {208 return ref.passthrough209 }210 remainder := segments[len(ancestor[key]):]211 return mountPathFor(ordinal[key]) + "/" + strings.Join(remainder, "/")212 }213 resolved.Items = make([]string, len(items))214 resolved.Logos = make([]string, len(items))215 resolved.Trickplays = make([]string, len(items))216 resolved.Arts = make([]string, len(items))217 for index := range items {218 resolved.Items[index] = rewrite(mediaRefs[index])219 resolved.Logos[index] = rewrite(logoRefs[index])220 resolved.Trickplays[index] = rewrite(trickRefs[index])221 resolved.Arts[index] = rewrite(artRefs[index])222 }223 return resolved, nil224}225226// albumItem says whether one item's block marks it as an album, the one shape227// of item the pod expands from a directory.228func albumItem(item PlayItem) bool {229 return item.Presentation != nil && isAlbum(*item.Presentation)230}231232// resolvedPreferences is the settled value of each preference field, after233// the three tiers resolve.234type resolvedPreferences struct {235 AudioLanguages []string236 SubtitleLanguages []string237 Subtitles string238 // The resolved wall-clock zone, from the default tier alone.239 TimeZone string240}241242// resolvePreferences settles each field on its own, Play then Player then the243// default. A nil source is a tier that does not exist and is skipped, and a244// field no tier states resolves to nothing.245func resolvePreferences(play *PlaySpec, player *PlayerSpec, defaults *MediaPreferencesSpec) resolvedPreferences {246 var audio, subs [][]string247 var mode []string248 if play != nil {249 audio = append(audio, play.AudioLanguages)250 subs = append(subs, play.SubtitleLanguages)251 mode = append(mode, play.Subtitles)252 }253 if player != nil {254 audio = append(audio, player.AudioLanguages)255 subs = append(subs, player.SubtitleLanguages)256 mode = append(mode, player.Subtitles)257 }258 if defaults != nil {259 audio = append(audio, defaults.AudioLanguages)260 subs = append(subs, defaults.SubtitleLanguages)261 mode = append(mode, defaults.Subtitles)262 }263 // The timezone is a household setting, one per cluster, so it reads the264 // default tier alone, not the Play or the Player.265 var zone string266 if defaults != nil {267 zone = defaults.TimeZone268 }269 return resolvedPreferences{270 AudioLanguages: firstStatedList(audio),271 SubtitleLanguages: firstStatedList(subs),272 Subtitles: firstStatedString(mode),273 TimeZone: zone,274 }275}276277// firstStatedList returns the first list a tier states. A nil list is unset,278// and an empty non-nil list is a tier stating no preference, which overrides a279// lower tier.280func firstStatedList(tiers [][]string) []string {281 for _, list := range tiers {282 if list != nil {283 return list284 }285 }286 return nil287}288289// defaultFadeAfterSeconds is the fade window a cluster that states290// nothing takes: ten minutes of quiet, then the idle screen fades.291const defaultFadeAfterSeconds int64 = 600292293// Darkening hardware is opt-in, so a cluster that states no294// window keeps its panels lit.295const defaultOffAfterSeconds int64 = 0296297// The built-in mode is the backlight, because a panel at zero298// backlight still answers DDC and the restore cannot strand.299const defaultOffMode = offModeBacklight300301// The two controller names this operator answers to.302// idleControllerOwn is the built-in default, and this operator stands303// the claim and the idle client pod for it.304// Under idleControllerNone nothing draws an idle screen on the unit:305// this operator stands no claim and no pod, and it leaves the panel306// alone. The precedent is kubernetes.io/no-provisioner. Every other307// name is a delegate, and this operator compares a name to these two308// and to nothing else.309const (310 idleControllerOwn = "media.liken.sh/idle-screen"311 idleControllerNone = "media.liken.sh/none"312)313314// resolvedIdle is one Player's settled idle policy. The two windows315// are set on the idle pod as plain values, and the off mode stays316// with the operator, which writes the override.317type resolvedIdle struct {318 // The image the idle client runs, always set.319 // The idle client at the operator's own version is the floor320 // under both tiers.321 Image string322323 // The name of the operator that draws this unit's idle324 // screen, always set, because idleControllerOwn is the floor under325 // both tiers.326 Controller string327328 FadeAfterSeconds int64329330 // The settled off window, and the override the operator331 // applies when it runs out.332 OffAfterSeconds int64333 OffMode string334}335336// resolveIdle settles each idle field on its own: the Player's block,337// then the household default, then the built-in. Field by field, so a338// Player that states one field still inherits the rest.339// image is the idle client at the operator's own version, the last340// tier under the two the cluster states.341func resolveIdle(player, defaults *IdlePolicy, image string) resolvedIdle {342 var fade, off []*int64343 var mode, images, controllers []string344 if player != nil {345 fade = append(fade, player.FadeAfterSeconds)346 off = append(off, player.OffAfterSeconds)347 mode = append(mode, player.OffMode)348 images = append(images, player.Image)349 controllers = append(controllers, player.Controller)350 }351 if defaults != nil {352 fade = append(fade, defaults.FadeAfterSeconds)353 off = append(off, defaults.OffAfterSeconds)354 mode = append(mode, defaults.OffMode)355 images = append(images, defaults.Image)356 controllers = append(controllers, defaults.Controller)357 }358 return resolvedIdle{359 Image: firstStatedString(append(images, image)),360 Controller: firstStatedString(append(controllers, idleControllerOwn)),361 FadeAfterSeconds: firstStatedSeconds(fade, defaultFadeAfterSeconds),362 OffAfterSeconds: firstStatedSeconds(off, defaultOffAfterSeconds),363 OffMode: firstStatedIdleMode(mode),364 }365}366367// firstStatedIdleMode is firstStatedString with the built-in368// mode as the floor, so an off mode no tier states is the backlight.369func firstStatedIdleMode(tiers []string) string {370 if mode := firstStatedString(tiers); mode != "" {371 return mode372 }373 return defaultOffMode374}375376// firstStatedSeconds returns the first value a tier states. A nil377// pointer is unset, and an explicit zero is a statement, so a Player378// can turn a window off rather than only shorten it.379func firstStatedSeconds(tiers []*int64, fallback int64) int64 {380 for _, value := range tiers {381 if value != nil {382 return *value383 }384 }385 return fallback386}387388// firstStatedString returns the first value a tier states. Subtitles has no389// empty enum value, so an empty string is unset.390func firstStatedString(tiers []string) string {391 for _, value := range tiers {392 if value != "" {393 return value394 }395 }396 return ""397}398399const (400 schemeNFS = "nfs"401 schemeClaim = "claim"402)403404// parseRef classifies one URI. An https URI passes through. An nfs URI parses405// into a server and a path, and a claim URI into a claim name and a path.406// Any other scheme, or a missing one, fails the whole Play, so a Play that407// can never run leaves no half-built objects behind.408func parseRef(raw string) (resolvedRef, error) {409 parsed, err := url.Parse(raw)410 if err != nil {411 return resolvedRef{}, fmt.Errorf("the URI %q does not parse: %v", raw, err)412 }413 switch parsed.Scheme {414 case "https":415 return resolvedRef{passthrough: raw}, nil416 case schemeNFS:417 ref, err := parseNFS(parsed, raw)418 if err != nil {419 return resolvedRef{}, err420 }421 return resolvedRef{nfs: ref}, nil422 case schemeClaim:423 ref, err := parseClaim(parsed, raw)424 if err != nil {425 return resolvedRef{}, err426 }427 return resolvedRef{claim: ref}, nil428 case "":429 return resolvedRef{}, fmt.Errorf("the URI %q carries no scheme; the operator resolves https://, nfs://, and claim://", raw)430 default:431 return resolvedRef{}, fmt.Errorf(432 "the scheme %s:// is not one the operator resolves; it resolves https://, nfs://, and claim://",433 parsed.Scheme)434 }435}436437// parseNFS reads the server and the path segments from one nfs URI. A URI that438// names no server, or no directory to mount, fails, because neither resolves439// to a mount with the item under it.440//441// The last segment may name a file or a folder, because a music album is442// one item and one folder. A trailing slash changes nothing, because the443// empty last segment drops out of the split.444func parseNFS(parsed *url.URL, raw string) (*nfsRef, error) {445 if parsed.Host == "" {446 return nil, fmt.Errorf("the URI %q names no NFS server", raw)447 }448 segments := splitPath(parsed.Path)449 if len(segments) < 2 {450 return nil, fmt.Errorf("the URI %q names no directory to mount", raw)451 }452 return &nfsRef{server: parsed.Host, segments: segments}, nil453}454455// parseClaim reads the claim name and the path segments from one claim URI.456// A URI that names no claim, or no path inside it, fails, because neither457// names a file the pod can reach. One segment is enough, because the claim's458// own root is the mount and no directory has to be chosen.459func parseClaim(parsed *url.URL, raw string) (*claimRef, error) {460 if parsed.Host == "" {461 return nil, fmt.Errorf("the URI %q names no claim", raw)462 }463 segments := splitPath(parsed.Path)464 if len(segments) == 0 {465 return nil, fmt.Errorf("the URI %q names no path in the claim", raw)466 }467 return &claimRef{claim: parsed.Host, segments: segments}, nil468}469470// splitPath breaks a URL path into its non-empty segments.471func splitPath(path string) []string {472 var segments []string473 for _, segment := range strings.Split(path, "/") {474 if segment != "" {475 segments = append(segments, segment)476 }477 }478 return segments479}480481// commonPrefix returns the longest run of leading segments every list shares.482// Two files in one directory share that directory. Two files in sibling483// directories share the parent.484func commonPrefix(lists [][]string) []string {485 if len(lists) == 0 {486 return nil487 }488 prefix := lists[0]489 for _, list := range lists[1:] {490 length := 0491 for length < len(prefix) && length < len(list) && prefix[length] == list[length] {492 length++493 }494 prefix = prefix[:length]495 }496 return prefix497}498499func mountPathFor(ordinal int) string {500 return mediaMountPrefix + strconv.Itoa(ordinal)501}
screen.go 94.1%
1package main23// This file is the media layer's half of the display-operator's4// Display resource. The media layer never writes a panel. It finds5// the Display that names the screen a unit's idle pod draws on, and6// it applies or lifts spec.override there. The types are hand-written7// for the same reason the Kubernetes types in api.go are, and the8// display-operator's Go module is not a dependency.910import (11 "errors"12 "fmt"13 "os"14)1516// The group the display-operator serves. A Display is17// cluster-scoped, because a panel is physical and belongs to no18// namespace.19const displayAPIVersion = "display.liken.sh/v1alpha1"2021// The attribute both of a connector's devices carry. The22// display-operator names each Display by this value, so the allocated23// draw device names the Display through it.24const monitorIDAttribute = "monitor.liken.sh/id"2526// The one override value the media layer writes, and the one27// observed power word that means a lit panel. The panel comes back28// when the block is deleted, so nothing here writes an on value: the29// display-operator answers the lift by restoring what it captured.30const (31 displayPowerOff = "off"32 displayPowerOn = "on"33)3435// The field manager this operator applies under. Server-side36// apply keeps it to spec.override, so the cluster owner's resting37// fields never conflict with it.38const displayFieldManager = "media-operator"3940// A Display carries only what this operator reads or writes:41// the override it applies, and the observed values it folds into the42// Player's status.43type Display struct {44 APIVersion string `json:"apiVersion,omitempty"`45 Kind string `json:"kind,omitempty"`46 Metadata ObjectMeta `json:"metadata"`47 Spec DisplaySpec `json:"spec"`48 Status DisplayStatus `json:"status"`49}5051// The spec half this operator writes is the override alone. A52// nil override is the apply that lifts one.53type DisplaySpec struct {54 Override *DisplayOverride `json:"override,omitempty"`55}5657// The temporary layer over the panel's resting settings. A backlight58// at zero still answers DDC. Power off stops some panels from59// answering DDC at all, so a Player states it only for a panel the60// drill proved wakes.61type DisplayOverride struct {62 Backlight string `json:"backlight,omitempty"`63 Power string `json:"power,omitempty"`64}6566// The body of an apply. It carries the spec alone, because the67// status is the display-operator's to write.68type displayApply struct {69 APIVersion string `json:"apiVersion"`70 Kind string `json:"kind"`71 Metadata ObjectMeta `json:"metadata"`72 Spec DisplaySpec `json:"spec"`73}7475type DisplayStatus struct {76 Observed DisplayObserved `json:"observed,omitempty"`77}7879// What the display-operator last read from the panel. It is80// last-known and never live, because a DDC read is itself a wake81// stimulus on some panels.82type DisplayObserved struct {83 Brightness *int `json:"brightness,omitempty"`84 Power string `json:"power,omitempty"`85}8687// The claim status the scheduler writes. The operator reads it88// for two questions: which device the draw request took, and which pods89// hold the claim now.90type ResourceClaimStatus struct {91 Allocation *DeviceAllocationResult `json:"allocation,omitempty"`9293 // ReservedFor names every object that holds an allocated94 // claim. An allocated claim carries the delete-protection finalizer,95 // so a delete stays in Terminating until every holder is gone.96 ReservedFor []ClaimConsumer `json:"reservedFor,omitempty"`97}9899// One holder of an allocated claim. The field names match DRA's100// ResourceClaimConsumerReference. Resource is the plural resource name,101// and the operator acts on pods alone.102type ClaimConsumer struct {103 APIGroup string `json:"apiGroup,omitempty"`104 Resource string `json:"resource,omitempty"`105 Name string `json:"name,omitempty"`106 UID string `json:"uid,omitempty"`107}108109type DeviceAllocationResult struct {110 Devices DeviceAllocationDevices `json:"devices,omitempty"`111}112113type DeviceAllocationDevices struct {114 Results []DeviceRequestAllocationResult `json:"results,omitempty"`115}116117// One allocated device. The three coordinates name it in the118// driver's ResourceSlices: the driver, the pool, and the device name119// inside that pool.120type DeviceRequestAllocationResult struct {121 Request string `json:"request,omitempty"`122 Driver string `json:"driver,omitempty"`123 Pool string `json:"pool,omitempty"`124 Device string `json:"device,omitempty"`125}126127// A ResourceSlice is the driver's published inventory. This128// operator reads it for the attributes on one allocated device.129type ResourceSlice struct {130 Metadata ObjectMeta `json:"metadata"`131 Spec ResourceSliceSpec `json:"spec"`132}133134type ResourceSliceList struct {135 Metadata ListMeta `json:"metadata"`136 Items []ResourceSlice `json:"items"`137}138139type ResourceSliceSpec struct {140 Driver string `json:"driver,omitempty"`141 Pool ResourceSlicePool `json:"pool"`142 Devices []ResourceSliceItem `json:"devices,omitempty"`143}144145type ResourceSlicePool struct {146 Name string `json:"name,omitempty"`147}148149type ResourceSliceItem struct {150 Name string `json:"name,omitempty"`151 Attributes map[string]DeviceAttribute `json:"attributes,omitempty"`152}153154// An attribute is one of four types, and one field of the four155// is set. This operator reads the string form alone.156type DeviceAttribute struct {157 String *string `json:"string,omitempty"`158}159160// screens resolves each unit's screen to the monitor id that161// names its Display. It lists the driver's ResourceSlices at most once162// a pass, because one list answers every unit.163type screens struct {164 client *Client165 slices []ResourceSlice166 listed bool167}168169func newScreens(client *Client) *screens {170 return &screens{client: client}171}172173// monitorFor is the whole lookup: the unit's standing idle174// claim carries the allocation, the allocation names the draw device,175// and the device's attributes carry the monitor id. A claim the176// scheduler has not allocated yet names no screen, and the pass then177// writes no override.178func (s *screens) monitorFor(player *Player) (string, bool) {179 claim, err := GetResourceClaim(s.client,180 player.Metadata.Namespace, idleClaimName(player.Metadata.Name))181 if err != nil || claim.Status == nil || claim.Status.Allocation == nil {182 return "", false183 }184 for _, result := range claim.Status.Allocation.Devices.Results {185 if result.Request != idleDrawRequest {186 continue187 }188 return s.monitorOf(result)189 }190 return "", false191}192193// monitorOf reads the monitor id off the allocated device. The194// driver, the pool, and the device name are what a ResourceSlice entry195// is keyed by.196func (s *screens) monitorOf(result DeviceRequestAllocationResult) (string, bool) {197 if !s.listed {198 s.listed = true199 list, err := ListResourceSlices(s.client)200 if err != nil {201 fmt.Fprintf(os.Stderr, "listing resource slices: %v\n", err)202 return "", false203 }204 s.slices = list.Items205 }206 for _, slice := range s.slices {207 if slice.Spec.Driver != result.Driver || slice.Spec.Pool.Name != result.Pool {208 continue209 }210 for _, device := range slice.Spec.Devices {211 if device.Name != result.Device {212 continue213 }214 attribute, held := device.Attributes[monitorIDAttribute]215 if !held || attribute.String == nil {216 return "", false217 }218 return *attribute.String, true219 }220 }221 return "", false222}223224// panelOverride is what this operator last wrote for one unit: the225// desire it applied, and the monitor it applied it to. The monitor is226// held because a deleted Player takes its idle claim with it, and the227// claim was the only path from the unit to its screen. The lift a228// dark panel is still owed goes to the remembered monitor.229type panelOverride struct {230 desire string231 monitor string232}233234// reconcilePanel settles one unit's panel and answers the235// state its status carries. The desire is the idle screen client's, the236// override is this operator's write, and the observed state is the237// display-operator's. A unit whose screen carries no Display keeps238// its panel lit: there is no second writer to fall back to.239func (o *operator) reconcilePanel(player *Player, key string, lookup *screens, mode string) string {240 desire := o.panels.stateFor(key)241 if desire == "" {242 return ""243 }244 monitor, found := lookup.monitorFor(player)245 if !found {246 // A screen the scheduler has not allocated is the one247 // silent fault, and only for the on desire: the panel is lit,248 // which is what the desire asks for.249 if desire == panelDesireOff {250 o.panelFault(key, fmt.Sprintf("player %s/%s: no allocated screen; the panel stays lit",251 player.Metadata.Namespace, player.Metadata.Name))252 }253 return ""254 }255 display, err := GetDisplay(o.client, monitor)256 if err != nil {257 o.panelFault(key, fmt.Sprintf("reading display %s: %v", monitor, err))258 return ""259 }260 if o.panelOverrides[key].desire != desire {261 if err := ApplyDisplayOverride(o.client, monitor, overrideFor(desire, mode)); err != nil {262 o.panelFault(key, fmt.Sprintf("overriding display %s: %v", monitor, err))263 return panelFromDisplay(display.Status.Observed)264 }265 o.panelOverrides[key] = panelOverride{desire: desire, monitor: monitor}266 }267 delete(o.panelFaults, key)268 return panelFromDisplay(display.Status.Observed)269}270271// retainPanels shrinks the record to the units the cluster272// still holds, and lifts the override of every unit it drops. A273// Player deleted while its panel is dark has no idle claim any more,274// so nothing would ever write that Display again and the panel would275// stay dark. A lift that fails keeps the entry, so the next pass276// writes it again.277func (o *operator) retainPanels(live map[string]bool) {278 for key, override := range o.panelOverrides {279 if live[key] {280 continue281 }282 if override.desire == panelDesireOff {283 // A Display that is gone carries no override, so the284 // lift it refuses has already landed. Every other failure285 // keeps the entry, because the block still stands.286 err := ApplyDisplayOverride(o.client, override.monitor, nil)287 if err != nil && !errors.Is(err, ErrNotFound) {288 o.panelFault(key, fmt.Sprintf("lifting the override on display %s: %v",289 override.monitor, err))290 continue291 }292 }293 delete(o.panelOverrides, key)294 delete(o.panelFaults, key)295 }296 // A fault outlives its unit only while that unit still owes297 // a lift, because the retry reports the same message once and not298 // once a pass.299 for key := range o.panelFaults {300 if _, owed := o.panelOverrides[key]; !live[key] && !owed {301 delete(o.panelFaults, key)302 }303 }304}305306// panelFault reports a panel this pass could not write. Each307// distinct message reports once per unit, so a cluster with no308// Display support does not log every pass.309func (o *operator) panelFault(key, message string) {310 if o.panelFaults[key] == message {311 return312 }313 o.panelFaults[key] = message314 fmt.Fprintln(os.Stderr, message)315}316317// overrideFor turns one desire and the resolved off mode into318// the override block. The on desire carries no block, and that apply319// is what deletes the one standing.320func overrideFor(desire, mode string) *DisplayOverride {321 if desire != panelDesireOff {322 return nil323 }324 if mode == offModePower {325 return &DisplayOverride{Power: displayPowerOff}326 }327 return &DisplayOverride{Backlight: displayPowerOff}328}
standing.go 86.1%
1package main23// The operator owns two kinds of standing objects: a Player's idle claim4// with its idle client pod, and a Remote's controller claim with its5// reader pod. Each one6// stands whether or not anything plays, and each follows the template the7// current pass would build. This file holds the hash that tells one8// template from another, and the rule every standing reconcile runs.9//10// A Deployment finds a stale pod by stamping a hash of the template it11// built and comparing that hash, never by comparing live specs, because12// the API server defaults fields the builder never set and a live13// comparison would either roll on every pass or grow a field-by-field14// allowlist. The operator does the same with one annotation on each15// object it stands up.1617import (18 "encoding/json"19 "errors"20 "hash/fnv"21 "strconv"22)2324// templateHashAnnotation carries the hash of the spec the operator built.25// The key is the operator's own group, because the value is this26// operator's record of its own output and no other program reads it.27const templateHashAnnotation = "media.liken.sh/template-hash"2829// templateHash reduces one built spec to the string the annotation30// carries. fnv-1a is enough here, and the whole job is to tell one pass's31// output from another's. Nothing signs the value and nothing outside this32// operator reads it, so the hash needs no collision resistance against an33// attacker.34//35// The input is the spec alone and never the metadata, so the annotation36// is not part of what it hashes and a stamped object hashes to the same37// value as the object before the stamp.38func templateHash(spec any) (string, error) {39 body, err := json.Marshal(spec)40 if err != nil {41 return "", err42 }43 sum := fnv.New64a()44 // A hash never fails a write, so the error is the interface's and not45 // a state this code can reach.46 _, _ = sum.Write(body)47 return strconv.FormatUint(sum.Sum64(), 16), nil48}4950// stampTemplateHash writes the hash of one built spec onto the object51// that carries it. The caller hands in the metadata and the spec of the52// same object, and the stamp is what a later pass compares against.53func stampTemplateHash(metadata *ObjectMeta, spec any) error {54 hash, err := templateHash(spec)55 if err != nil {56 return err57 }58 if metadata.Annotations == nil {59 metadata.Annotations = map[string]string{}60 }61 metadata.Annotations[templateHashAnnotation] = hash62 return nil63}6465// standing is what one pass wants for a claim and a pod that66// stand between runs: the namespace and the names to read in the67// cluster, and the objects this pass would build under those names. A68// nil object is one the pass no longer wants, and the name beside it is69// what the pass deletes. An empty claim name is a standing pod that70// holds no claim.71type standing struct {72 namespace string73 claimName string74 claim *ResourceClaim75 podName string76 pod *Pod77}7879// standingPair is the standing of a Remote or of the idle screen80// this operator draws itself: one claim and one pod, both built by this81// pass and both wanted.82func standingPair(claim *ResourceClaim, pod *Pod) standing {83 return standing{84 namespace: claim.Metadata.Namespace,85 claimName: claim.Metadata.Name,86 claim: claim,87 podName: pod.Metadata.Name,88 pod: pod,89 }90}9192// podsResource is the resource name a claim's status.reservedFor93// carries for a pod. This operator acts on that kind of holder alone.94const podsResource = "pods"9596// claimRead is what the pass already read about one standing claim.97// read says whether the pass read at all, and claim is what that read98// found. A read that found nothing carries read true and a nil claim,99// because an absent claim is a state and not a failure. The zero value100// is no read, which sends the reconcile to make its own.101type claimRead struct {102 claim *ResourceClaim103 read bool104}105106// readClaim reads one standing claim by name. An absent claim is not an107// error here: it is the state the create answers, so it returns a read108// that found nothing.109func (o *operator) readClaim(namespace, name string) (claimRead, error) {110 claim, err := GetResourceClaim(o.client, namespace, name)111 if errors.Is(err, ErrNotFound) {112 return claimRead{read: true}, nil113 }114 if err != nil {115 return claimRead{}, err116 }117 return claimRead{claim: claim, read: true}, nil118}119120// reconcileStanding brings one standing pair into line with the claim and121// the pod this pass would build. It stamps both with their template122// hashes, reads what the cluster holds, and replaces whichever object no123// longer matches. It creates whatever is missing, which is how a124// replacement comes back: the delete returns, and the next pass finds the125// object absent and creates it.126//127// The two divergences are not the same repair. A claim is immutable and128// the pod holds its allocation, so a changed claim replaces both. A129// changed pod replaces the pod alone, and the claim keeps its130// allocation, so an image-only release does not cost a sleeping131// controller the allocation it holds, and the reader pod comes back132// running instead of Pending.133//134// An object the pass no longer wants is deleted on the same two135// rules: an unwanted claim takes its holders with it, and an unwanted pod136// goes alone.137//138// A 409 on either create means another pass, or another copy of this139// operator, created the object first, which is success.140//141// known is the claim the pass already read for this object, and it142// saves the read here. The pass reads every Remote's standing claim143// once, to resolve the controller's Peripheral, and hands that same144// read down. A zero known is a caller with no read of its own, and the145// rule reads the claim itself.146func (o *operator) reconcileStanding(want standing, known claimRead) error {147 if want.claim != nil {148 if err := stampTemplateHash(&want.claim.Metadata, want.claim.Spec); err != nil {149 return err150 }151 }152 if want.pod != nil {153 if err := stampTemplateHash(&want.pod.Metadata, want.pod.Spec); err != nil {154 return err155 }156 }157 namespace := want.namespace158159 var liveClaim *ResourceClaim160 claimStands := false161 if want.claimName != "" {162 if !known.read {163 var err error164 if known, err = o.readClaim(namespace, want.claimName); err != nil {165 return err166 }167 }168 liveClaim, claimStands = known.claim, known.claim != nil169 }170171 livePod, err := GetPod(o.client, namespace, want.podName)172 if err != nil && !errors.Is(err, ErrNotFound) {173 return err174 }175 podStands := err == nil176177 // An object with a deletionTimestamp counts as still present. The178 // delete this operator sent is in progress, and the pass leaves the179 // whole pair alone until it completes, so one divergence causes one180 // delete and not one delete per pass.181 if claimStands && liveClaim.Metadata.DeletionTimestamp != "" {182 return nil183 }184 if podStands && livePod.Metadata.DeletionTimestamp != "" {185 return nil186 }187188 // A live object stamped with a different hash is stale, and so is a189 // live object with no stamp at all, which an older release created.190 // So the first release that carries the stamp rolls every standing191 // pod once, and the pass after that reads matching hashes and deletes192 // nothing.193 if claimStands && (want.claim == nil || !sameTemplate(&liveClaim.Metadata, &want.claim.Metadata)) {194 own := ""195 if podStands {196 own = want.podName197 }198 if err := o.deleteClaimHolders(liveClaim, own); err != nil {199 return err200 }201 return DeleteResourceClaim(o.client, namespace, want.claimName)202 }203 if podStands && (want.pod == nil || !sameTemplate(&livePod.Metadata, &want.pod.Metadata)) {204 return DeletePod(o.client, namespace, want.podName)205 }206207 if want.claim != nil && !claimStands {208 if _, err := CreateResourceClaim(o.client, want.claim); err != nil && !errors.Is(err, ErrConflict) {209 return err210 }211 }212 if want.pod != nil && !podStands {213 if _, err := CreatePod(o.client, want.pod); err != nil && !errors.Is(err, ErrConflict) {214 return err215 }216 }217 return nil218}219220// deleteClaimHolders deletes every pod that holds one claim,221// before the claim itself goes. A claim in use carries the222// delete-protection finalizer until every holder is gone, so deleting the223// claim under a running pod would leave it Terminating for as long as224// that pod runs. status.reservedFor is the holder list, and it is the225// one place this operator learns of a delegate's pod, which it did not226// create.227//228// own is this operator's own pod for the claim, which goes whether229// or not the list names it: a pod still Pending holds no reservation, and230// leaving it would let it schedule against the replacement claim. An231// absent pod is a holder that already went, which the delete reads as232// success.233func (o *operator) deleteClaimHolders(claim *ResourceClaim, own string) error {234 namespace := claim.Metadata.Namespace235 deleted := map[string]bool{}236 if claim.Status != nil {237 for _, holder := range claim.Status.ReservedFor {238 if holder.Resource != podsResource || deleted[holder.Name] {239 continue240 }241 if err := DeletePod(o.client, namespace, holder.Name); err != nil {242 return err243 }244 deleted[holder.Name] = true245 }246 }247 if own == "" || deleted[own] {248 return nil249 }250 return DeletePod(o.client, namespace, own)251}252253// sameTemplate reports whether a live object carries the hash the pass254// just stamped on the object it built. An absent annotation reads as an255// empty string, which never equals a hash, so an object from a release256// that stamped nothing counts as diverged.257func sameTemplate(live, desired *ObjectMeta) bool {258 return live.Annotations[templateHashAnnotation] == desired.Annotations[templateHashAnnotation]259}
status.go 90.6%
1package main23// Every status this operator writes comes from the one derivation in4// this file, and the derivation reads nothing but its arguments. The5// shape matters because the loop is level-triggered: a pass must6// reach the same status from the same facts, whatever order the7// events arrived in, and a function of its arguments cannot do8// otherwise.910import (11 "encoding/json"12 "errors"13 "fmt"14)1516// derivePlayStatus builds the phase-and-numbers status and stamps17// the activity word onto it, so every path through the derivation18// carries the one word a person reads without a second rule19// deciding it later.20func derivePlayStatus(play *Play, player *Player, buildErr error, pod *Pod, latest *playReport, prefs resolvedPreferences) PlayStatus {21 status := buildPlayStatus(play, player, buildErr, pod, latest)22 status.Activity = playActivity(status.Phase, status.Paused)23 // Report the resolved preferences on every status, the console-parity record24 // of what the three tiers settled on.25 status.AudioLanguages = prefs.AudioLanguages26 status.SubtitleLanguages = prefs.SubtitleLanguages27 status.Subtitles = prefs.Subtitles28 return status29}3031// buildPlayStatus takes the phase from the pod and the numbers from32// the pod's reports. The kubelet is the authority for whether the33// player process runs, and it cannot lie about an exit; only mpv34// knows where the playhead is, and it can only say so through the35// supervisor's reports. Each argument may be absent: a nil player is36// a Player not yet declared, a buildErr is a run no pod could ever37// perform, a nil pod is a run not yet started, and a nil report is a38// pod that has not spoken yet.39func buildPlayStatus(play *Play, player *Player, buildErr error, pod *Pod, latest *playReport) PlayStatus {40 if buildErr != nil {41 // A URI the resolver refuses, or a Remote whose Keymap will42 // not compile, fails the Play before any object exists,43 // because the pod it describes could never be built.44 return PlayStatus{Phase: phaseFailed, Message: buildErr.Error()}45 }46 if player == nil {47 // An absent Player is Pending rather than Failed. The Player48 // may arrive after the Play, and the run starts when it49 // does; Failed would end a run that never had its chance.50 return PlayStatus{51 Phase: phasePending,52 Message: fmt.Sprintf("the Player %s does not exist in this namespace", playerName(play)),53 }54 }55 if pod == nil {56 return PlayStatus{57 Phase: phasePending,58 Item: play.Status.Item,59 Position: play.Status.Position,60 Duration: play.Status.Duration,61 }62 }6364 // A position, once reported, stays in the status until a fresher65 // report advances it. The report desk drops a run's report when the66 // pod goes offline, and the resume reads the place from the status,67 // so a blank here would restart the film from the top.68 status := PlayStatus{69 Pod: pod.Metadata.Name,70 Item: play.Status.Item,71 Position: play.Status.Position,72 Duration: play.Status.Duration,73 // Carry the chosen track languages forward like the position, so a dropped74 // report does not blank them.75 AudioLanguage: play.Status.AudioLanguage,76 SubtitleLanguage: play.Status.SubtitleLanguage,77 }78 switch pod.Status.Phase {79 case podRunning:80 // Running covers a paused film too. The phase moves forward81 // only; the paused field beside the position says the rest.82 status.Phase = phaseRunning83 foldReport(&status, latest)84 status.Paused = latest != nil && latest.Paused85 case podSucceeded:86 // The last item ended: mpv exited zero and the pod87 // succeeded. The numbers stay as the last report left them,88 // so a finished season still shows which episode ended it89 // and where.90 status.Phase = phaseFinished91 foldReport(&status, latest)92 case podFailed:93 status.Phase = phaseFailed94 status.Message = podFailureMessage(pod)95 default:96 // A pod that has not started yet is a Play still Pending.97 // The ordinary hold is allocation: a Player whose devices no98 // one machine can satisfy parks its pod unschedulable, and99 // the pod's own message says which request wants. The100 // scheduler's holds, a claim that does not exist among them,101 // arrive on a condition instead, so the fold reads both.102 status.Phase = phasePending103 if pod.Status.Message != "" {104 status.Message = pod.Status.Message105 } else {106 status.Message = podConditionMessage(pod)107 }108 }109 return status110}111112// podConditionMessage is the scheduler's word on a pod it cannot place:113// the message of the first condition that is false. A claim that does114// not exist parks a pod this way, and the message names the claim.115func podConditionMessage(pod *Pod) string {116 for _, condition := range pod.Status.Conditions {117 if condition.Status == "False" && condition.Message != "" {118 return condition.Message119 }120 }121 return ""122}123124// foldReport passes the item, the position, and the duration through125// as the supervisor sent them. This operator never parses a time; it126// carries the supervisor's strings into the status unread.127func foldReport(status *PlayStatus, latest *playReport) {128 if latest == nil {129 return130 }131 status.Item = latest.Item132 status.Position = latest.Position133 status.Duration = latest.Duration134 status.AudioLanguage = latest.AudioLanguage135 status.SubtitleLanguage = latest.SubtitleLanguage136}137138// podFailureMessage prefers the container's terminated state,139// because it carries the exit code, which is the part a person acts140// on: 1 is the player refusing its input, 137 is the kernel ending a141// pod over its limit.142func podFailureMessage(pod *Pod) string {143 for _, container := range pod.Status.ContainerStatuses {144 if container.Name != playerContainer || container.State.Terminated == nil {145 continue146 }147 terminated := container.State.Terminated148 reason := terminated.Reason149 if reason == "" {150 reason = "the player exited"151 }152 return fmt.Sprintf("the playback pod failed: %s (exit code %d)", reason, terminated.ExitCode)153 }154 if pod.Status.Message != "" {155 return "the playback pod failed: " + pod.Status.Message156 }157 if pod.Status.Reason != "" {158 return "the playback pod failed: " + pod.Status.Reason159 }160 return "the playback pod failed"161}162163func playerName(play *Play) string {164 if len(play.Spec.Players) == 0 {165 return ""166 }167 return play.Spec.Players[0]168}169170// writePlayStatus follows the two rules that keep the write quiet:171// an unchanged status is not written at all, and a conflict earns172// exactly one retry.173//174// The unchanged case matters because every status write bumps the175// resourceVersion, and this operator watches its own collection. A176// write per pass would wake the watch that wakes the pass, and the177// ten-second backstop would become a write every ten seconds for178// every settled Play in the cluster.179func writePlayStatus(c *Client, play *Play, desired PlayStatus) error {180 same, err := sameStatus(play.Status, desired)181 if err != nil {182 return err183 }184 if same {185 return nil186 }187188 play.Status = desired189 _, err = PutPlayStatus(c, play)190 if !errors.Is(err, ErrConflict) {191 return err192 }193194 // A conflict means something wrote the Play between the read and195 // the write. The fresh copy carries the resourceVersion the API196 // server will accept, and the desired status still describes the197 // same facts, so it goes on unchanged.198 fresh, err := GetPlay(c, play.Metadata.Namespace, play.Metadata.Name)199 if err != nil {200 return err201 }202 same, err = sameStatus(fresh.Status, desired)203 if err != nil || same {204 return err205 }206 fresh.Status = desired207 _, err = PutPlayStatus(c, fresh)208 return err209}210211// onlyPositionChanged reports whether the desired status differs from the212// current one in nothing but the position and the duration. The operator213// throttles a change that is only a position advance, because the214// command sidecar publishes a live position to the bus every second, and215// each write to the resource wakes the operator's own plays watch, so an216// unthrottled position write would spin the loop against the API server.217func onlyPositionChanged(current, desired PlayStatus) bool {218 same, err := sameStatus(current, desired)219 if err != nil || same {220 return false221 }222 current.Position, desired.Position = "", ""223 current.Duration, desired.Duration = "", ""224 rest, err := sameStatus(current, desired)225 return err == nil && rest226}227228// sameStatus compares the marshaled form, because that is what the229// API server stores and what a field's omitempty decides: two230// statuses that marshal alike write alike.231func sameStatus(current, desired PlayStatus) (bool, error) {232 was, err := json.Marshal(current)233 if err != nil {234 return false, err235 }236 wants, err := json.Marshal(desired)237 if err != nil {238 return false, err239 }240 return string(was) == string(wants), nil241}
topics.go 97.4%
1package main23// The bus topic layout. Every message the operator, the playback4// pod, and the standing remote pod exchange lands on a topic built5// here, so this file is the public contract of the bus and the one6// place a Home Assistant integration reads to map a Play or a Remote7// onto an entity.8//9// Each topic extends a base the operator holds as one string,10// liken/media by default. The data topics stay under this one base,11// and Home Assistant's own discovery topics stay under12// homeassistant/, so neither tree constrains the other. A base that13// carries a cluster's name so several clusters share one broker is a14// later refinement the string already allows.1516import "strings"1718// splitTopicLines splits one of the newline-joined topic lists the19// operator sets on a pod. Blank lines are kept, because two lists stay20// aligned by position.21func splitTopicLines(value string) []string {22 if value == "" {23 return nil24 }25 return strings.Split(value, "\n")26}2728// defaultTopicBase is the base every topic extends when the operator29// sets none. zigbee2mqtt uses one configurable base the same way.30const defaultTopicBase = "liken/media"3132// The two words the availability topic carries. The sidecar names its33// availability topic as the MQTT Last Will with offline as the34// payload, and publishes online once it connects, so a retained35// status a killed pod left behind does not read as a live Play.36const (37 availabilityOnline = "online"38 availabilityOffline = "offline"39)4041// The kind at the end of a plays topic. parsePlayTopic returns one of42// these so the operator folds a status report and an availability43// signal through separate paths.44const (45 playStatusKind = "status"46 playAvailabilityKind = "availability"47)4849// The kinds at the end of the remotes topics the operator reads for a50// controller. They are separate constants from the plays kinds above,51// because the two trees carry different payloads under the same words.52const (53 remoteAvailabilityKind = "availability"54 // The second retained remotes kind, the codes one controller55 // declares.56 remoteCodesKind = "codes"57 // The third retained remotes kind, the compiled key table the58 // operator writes and the standing pod reads.59 remoteKeysKind = "keys"60)6162// The last segment of the players topic that carries the panel63// state, beside the commands and status topics of the same tree.64const playerPanelKind = "panel"6566// The last segment of the players topic that carries the listening67// level, beside the panel, commands, and status kinds of the same68// tree.69const playerVolumeKind = "volume"7071// remoteEventsTopic carries one Remote's key events. The standing72// remote pod publishes {"key": "KEY_UP", "value": 1} to it, not73// retained, because a press is an event and not a state. The pod74// normalised the event, so every consumer reads one vocabulary.75func remoteEventsTopic(base, namespace, name string) string {76 return base + "/remotes/" + namespace + "/" + name + "/events"77}7879// remoteFocusTopic carries the retained focus mark, the bare name of the80// Player that owns this controller now, in the Remote's own namespace. A81// mark may name an idle Player. The operator writes it, and every reader82// of the controller's presses gates on it. It is retained, so a press83// reaches the owning unit with the operator up or down.84func remoteFocusTopic(base, namespace, name string) string {85 return base + "/remotes/" + namespace + "/" + name + "/focus"86}8788// remoteFocusCycleTopic carries the cycle request a source press89// publishes. Only the holder of focus publishes it, the playback pod's90// command sidecar during a film and the idle screen client between91// films, and the operator reads it to advance the mark. It is not92// retained, because a cycle is an event and not a state.93func remoteFocusCycleTopic(base, namespace, name string) string {94 return base + "/remotes/" + namespace + "/" + name + "/focus/cycle"95}9697// remoteFocusFilter is the operator's subscription that reaches every98// controller's focus mark. The operator is the only writer of a mark, so99// this subscription reads its own retained writes back and recovers the100// current marks after a restart.101func remoteFocusFilter(base string) string {102 return base + "/remotes/+/+/focus"103}104105// remoteFocusCycleFilter is the operator's subscription that reaches every106// controller's cycle request. Each plus matches one level, so this filter107// covers the five-level cycle topics alone and stays disjoint from the108// four-level focus filter.109func remoteFocusCycleFilter(base string) string {110 return base + "/remotes/+/+/focus/cycle"111}112113// remoteAvailabilityTopic carries online or offline for the standing114// remote pod itself, the same two words the plays availability uses. The115// pod names this topic as its MQTT Last Will, so a pod the kubelet killed116// reads offline and the retained codes it left behind do not read as a117// live declaration.118func remoteAvailabilityTopic(base, namespace, name string) string {119 return base + "/remotes/" + namespace + "/" + name + "/" + remoteAvailabilityKind120}121122// remoteCodesTopic carries the codes one controller declares, as123// {"keys": [...], "axes": [...]}. The standing remote pod publishes it124// retained at every node open, because a declared set is a state and125// not an event, and clears it with an empty payload when the nodes126// vanish.127func remoteCodesTopic(base, namespace, name string) string {128 return base + "/remotes/" + namespace + "/" + name + "/" + remoteCodesKind129}130131// remoteCodesFilter is the operator's subscription that reaches every132// controller's declared codes.133func remoteCodesFilter(base string) string {134 return base + "/remotes/+/+/" + remoteCodesKind135}136137// parseRemoteCodesTopic maps a codes topic back to the controller it138// names.139func parseRemoteCodesTopic(base, topic string) (namespace, name string, ok bool) {140 return parseRemoteTopic(base, topic, remoteCodesKind)141}142143// remoteAvailabilityFilter is the operator's subscription that reaches144// every standing remote pod's availability signal.145func remoteAvailabilityFilter(base string) string {146 return base + "/remotes/+/+/" + remoteAvailabilityKind147}148149// parseRemoteAvailabilityTopic maps an availability topic back to the150// controller whose pod it names.151func parseRemoteAvailabilityTopic(base, topic string) (namespace, name string, ok bool) {152 return parseRemoteTopic(base, topic, remoteAvailabilityKind)153}154155// parseRemoteTopic maps one three-segment remotes topic back to the156// controller it names, for the kind its last segment carries. It matches157// the segment count as well as the kind, so a cycle topic, which carries a158// fourth segment, matches no three-segment kind.159func parseRemoteTopic(base, topic, kind string) (namespace, name string, ok bool) {160 prefix := base + "/remotes/"161 if !strings.HasPrefix(topic, prefix) {162 return "", "", false163 }164 parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")165 if len(parts) != 3 || parts[2] != kind {166 return "", "", false167 }168 if parts[0] == "" || parts[1] == "" {169 return "", "", false170 }171 return parts[0], parts[1], true172}173174// parseRemoteFocusTopic maps a focus topic back to the controller it175// names. It matches only the three-segment focus mark, so a cycle topic,176// which carries a fourth segment, does not read as a mark.177func parseRemoteFocusTopic(base, topic string) (namespace, name string, ok bool) {178 prefix := base + "/remotes/"179 if !strings.HasPrefix(topic, prefix) {180 return "", "", false181 }182 parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")183 if len(parts) != 3 || parts[2] != "focus" {184 return "", "", false185 }186 if parts[0] == "" || parts[1] == "" {187 return "", "", false188 }189 return parts[0], parts[1], true190}191192// parseRemoteFocusCycleTopic maps a cycle topic back to the controller it193// names. It matches only the four-segment cycle request, so a plain focus194// mark does not read as a cycle.195func parseRemoteFocusCycleTopic(base, topic string) (namespace, name string, ok bool) {196 prefix := base + "/remotes/"197 if !strings.HasPrefix(topic, prefix) {198 return "", "", false199 }200 parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")201 if len(parts) != 4 || parts[2] != "focus" || parts[3] != "cycle" {202 return "", "", false203 }204 if parts[0] == "" || parts[1] == "" {205 return "", "", false206 }207 return parts[0], parts[1], true208}209210// playCommandsTopic carries the commands any program on the bus may211// publish to drive one Play: play-pause, a seek, a volume step, and212// the rest of the vocabulary in input.go. It is not retained, because213// a command is an event and not a state. This is the one open surface214// a program joins a Play on in media terms, so a phone or a Home215// Assistant integration reaches the Play the same way the playback216// pod's own key table does.217func playCommandsTopic(base, namespace, name string) string {218 return base + "/plays/" + namespace + "/" + name + "/commands"219}220221// playerCommandsTopic carries one message, the re-present the operator222// sends when a Play ends. It is not retained, because a re-present is223// an event and not a state. The operator is the only writer: a client224// publishes nothing here, and no press is forwarded on it. It stays off225// the plays tree because it drives the standing idle screen, not a226// Play, and a controller sends nothing on it directly.227func playerCommandsTopic(base, namespace, name string) string {228 return base + "/players/" + namespace + "/" + name + "/commands"229}230231// playerStatusTopic carries one unit's presentable state: its friendly232// name, what it is doing, the Play it runs, and its parts with the link233// and charge of each. The operator is the only writer, and the topic is234// retained, so an idle pod that just started paints the live state the235// broker already holds and asks for nothing. It stays off the plays tree236// because it describes the equipment, which stands whether or not a Play237// runs.238func playerStatusTopic(base, namespace, name string) string {239 return base + "/players/" + namespace + "/" + name + "/status"240}241242// playerPanelTopic carries the panel state one unit's idle screen client243// publishes, retained, because the panel is a state and not an event.244// The client holds no API credentials, so the operator folds this245// topic into the Player's status.246func playerPanelTopic(base, namespace, name string) string {247 return base + "/players/" + namespace + "/" + name + "/" + playerPanelKind248}249250// playerPanelFilter is the operator's one subscription that reaches251// every unit's panel topic.252func playerPanelFilter(base string) string {253 return base + "/players/+/+/" + playerPanelKind254}255256// parsePlayerPanelTopic maps a panel topic back to the Player it257// names.258func parsePlayerPanelTopic(base, topic string) (namespace, name string, ok bool) {259 return parsePlayerTopic(base, topic, playerPanelKind)260}261262// parsePlayerTopic maps one three-segment players topic back to the263// unit it names, for the kind its last segment carries. It matches264// the segment count as well as the kind, the way parseRemoteTopic265// does, so a deeper topic under the same tree matches no kind.266func parsePlayerTopic(base, topic, kind string) (namespace, name string, ok bool) {267 prefix := base + "/players/"268 if !strings.HasPrefix(topic, prefix) {269 return "", "", false270 }271 parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")272 if len(parts) != 3 || parts[2] != kind {273 return "", "", false274 }275 if parts[0] == "" || parts[1] == "" {276 return "", "", false277 }278 return parts[0], parts[1], true279}280281// playerVolumeTopic carries the unit's listening level and its282// muted flag, retained, because the level is a state and not an283// event. Every pod for the unit subscribes and applies what it284// reads, and only a press or the operator publishes, so the topic is285// the authority and no observer ever writes back what it saw.286func playerVolumeTopic(base, namespace, name string) string {287 return base + "/players/" + namespace + "/" + name + "/" + playerVolumeKind288}289290// playerVolumeFilter is the operator's one subscription across291// every unit's level. The operator reads it to learn which units the292// broker already holds a level for, so the seed writes only where293// nothing stands.294func playerVolumeFilter(base string) string {295 return base + "/players/+/+/" + playerVolumeKind296}297298// parsePlayerVolumeTopic maps a volume topic back to the Player it299// names.300func parsePlayerVolumeTopic(base, topic string) (namespace, name string, ok bool) {301 return parsePlayerTopic(base, topic, playerVolumeKind)302}303304// remoteKeysTopic carries one Remote's compiled table, the base305// folded with its Keymap. It is per Remote and not per Keymap, because306// the standing pod that reads that one controller is the only307// subscriber, and a Remote with no Keymap still needs the base. It is308// retained, so the pod reads the current table the instant it309// connects, and an edit reaches it with no pod restart.310func remoteKeysTopic(base, namespace, name string) string {311 return base + "/remotes/" + namespace + "/" + name + "/" + remoteKeysKind312}313314// playStatusTopic carries one Play's report: the paused flag, the315// item, the position, and the duration. The playback pod's sidecar316// publishes it retained, so a restarted operator reads the current317// position back from the broker and does not lose a running Play's318// place.319func playStatusTopic(base, namespace, name string) string {320 return base + "/plays/" + namespace + "/" + name + "/" + playStatusKind321}322323// playAvailabilityTopic carries online or offline for the sidecar that324// publishes the status. The broker publishes offline on any disconnect325// the sidecar does not make cleanly.326func playAvailabilityTopic(base, namespace, name string) string {327 return base + "/plays/" + namespace + "/" + name + "/" + playAvailabilityKind328}329330// playStatusFilter is the subscription that reaches every Play's331// status, whatever namespace and name it carries. The two plus signs332// are the MQTT single-level wildcards for the namespace and the name.333func playStatusFilter(base string) string {334 return base + "/plays/+/+/" + playStatusKind335}336337// playAvailabilityFilter is the subscription that reaches every Play's338// availability signal.339func playAvailabilityFilter(base string) string {340 return base + "/plays/+/+/" + playAvailabilityKind341}342343// parsePlayTopic maps an inbound plays topic back to the Play it names344// and the kind of message it carries. The operator subscribes to the345// two plays filters and reads each message's topic to learn which Play346// it belongs to, because the wildcard subscription carries messages347// for every Play on one stream.348func parsePlayTopic(base, topic string) (namespace, name, kind string, ok bool) {349 prefix := base + "/plays/"350 if !strings.HasPrefix(topic, prefix) {351 return "", "", "", false352 }353 parts := strings.Split(strings.TrimPrefix(topic, prefix), "/")354 if len(parts) != 3 {355 return "", "", "", false356 }357 namespace, name, kind = parts[0], parts[1], parts[2]358 if namespace == "" || name == "" {359 return "", "", "", false360 }361 if kind != playStatusKind && kind != playAvailabilityKind {362 return "", "", "", false363 }364 return namespace, name, kind, true365}
trickplay.go 93.6%
1package main23// The bridge crops a trickplay tile from a Jellyfin sprite sheet, so a scrub4// shows the frame at the target time. The library precomputes the sheets, so5// the playback machine decodes no second video stream for a thumbnail. The6// display maps the scrub time to a tile and asks for it, and the bridge reads7// the geometry off the sheet, crops the cell, and scales it to the box.89import (10 "encoding/json"11 "fmt"12 "image"13 "os"14 "path/filepath"15 "strconv"16 "strings"17 "time"18)1920// serveTrickplay maps the cursor time to a tile, reads the sheet geometry off21// disk, crops the cell, scales it to the box, and replies. An item with no22// trickplay reference replies nothing.23func (c *commander) serveTrickplay(request artRequest) {24 intervalMs := trickplayIntervalMs()25 idx := request.timeMs / intervalMs2627 c.artMutex.Lock()28 item := c.artItem29 // A request that maps to the tile already written replies with that blob, so30 // the overlay does not churn during a scrub.31 if c.trickHave && c.trickItem == item && c.trickIdx == idx {32 blob := c.trickBlob33 c.artMutex.Unlock()34 c.replyArt(artKindTrickplay, blob)35 return36 }37 block := c.blockForItem(item)38 c.artMutex.Unlock()3940 dir := trickplayOf(block)41 if dir == "" {42 return43 }4445 layoutName, tileW, cols, rows, ok := findLayout(dir)46 if !ok {47 fmt.Fprintf(os.Stderr, "command: trickplay %q: no layout directory\n", dir)48 return49 }50 layoutDir := filepath.Join(dir, layoutName)5152 perSheet := cols * rows53 sheet := idx / perSheet54 cell := idx % perSheet55 row := cell / cols56 col := cell % cols57 // A time past the end of the film maps past the last sheet. The highest58 // sheet on disk is the last one, so clamp to it.59 if highest := highestSheet(layoutDir); sheet > highest {60 sheet = highest61 }6263 sheetImg, err := c.trickplaySheet(item, layoutDir, sheet)64 if err != nil {65 fmt.Fprintf(os.Stderr, "command: trickplay sheet %d in %q: %v\n", sheet, layoutDir, err)66 return67 }68 if sheetImg == nil {69 return70 }7172 bounds := sheetImg.Bounds()73 // The tile height is the sheet height over the rows. It is the film's aspect,74 // so a scope film gives a short wide tile, not a 16:9 one.75 tileH := bounds.Dy() / rows76 if tileH <= 0 {77 return78 }79 x0 := bounds.Min.X + col*tileW80 y0 := bounds.Min.Y + row*tileH81 region := image.Rect(x0, y0, x0+tileW, y0+tileH)8283 pixels, outW, outH, stride, err := scaleRegionToBGRA(sheetImg, region, request.width, request.height)84 if err != nil {85 fmt.Fprintf(os.Stderr, "command: trickplay crop in %q: %v\n", layoutDir, err)86 return87 }8889 path := filepath.Join(c.artDir, fmt.Sprintf("trick-%d-%d-%d-%dx%d.bgra", item, sheet, cell, request.width, request.height))90 if err := os.WriteFile(path, pixels, 0o644); err != nil {91 fmt.Fprintf(os.Stderr, "command: trickplay write %q: %v\n", path, err)92 return93 }94 blob := artBlob{path: path, width: outW, height: outH, stride: stride}9596 c.artMutex.Lock()97 if item != c.artItem {98 // The item swapped while the crop ran. Drop the blob, so the last item's99 // tile does not stay on the shared volume.100 c.artMutex.Unlock()101 os.Remove(blob.path)102 return103 }104 // Remove a tile file one step late. A reply tells mpv to map a tile on a105 // later turn, so removing the file the last reply named races that map and106 // drops the frame. The current tile becomes the prior tile, and only the107 // tile two steps back is removed. The guards skip the removal when the new108 // tile or the prior tile still names that file, so a scrub that reverses109 // never deletes a file in use.110 var stale artBlob111 haveStale := false112 if c.trickHave && c.trickItem == item && c.trickBlob.path != blob.path {113 if c.trickHavePrev && c.trickPrev.path != blob.path && c.trickPrev.path != c.trickBlob.path {114 stale = c.trickPrev115 haveStale = true116 }117 c.trickPrev = c.trickBlob118 c.trickHavePrev = true119 }120 c.trickHave = true121 c.trickItem = item122 c.trickIdx = idx123 c.trickBlob = blob124 c.artMutex.Unlock()125126 if haveStale {127 os.Remove(stale.path)128 }129 c.replyArt(artKindTrickplay, blob)130}131132// trickplaySheet returns the held sheet when the request names it, and decodes133// it once and holds it otherwise. It holds one sheet, because a scrub stays134// within one sheet for a long stretch, one hundred tiles at the interval. An135// item swap during the decode drops the result and returns nil.136func (c *commander) trickplaySheet(item int, layoutDir string, sheet int) (image.Image, error) {137 key := layoutDir + ":" + strconv.Itoa(sheet)138139 c.artMutex.Lock()140 if c.trickSheetKey == key && c.trickSheet != nil {141 held := c.trickSheet142 c.artMutex.Unlock()143 return held, nil144 }145 c.artMutex.Unlock()146147 path := filepath.Join(layoutDir, strconv.Itoa(sheet)+".jpg")148 file, err := os.Open(path)149 if err != nil {150 return nil, err151 }152 defer file.Close()153 decoded, _, err := image.Decode(file)154 if err != nil {155 return nil, err156 }157158 c.artMutex.Lock()159 defer c.artMutex.Unlock()160 if item != c.artItem {161 return nil, nil162 }163 c.trickSheet = decoded164 c.trickSheetKey = key165 return decoded, nil166}167168// trickplayIntervalMs reads the pod's interval as milliseconds. An empty or169// unparseable value, or one under a millisecond, falls back to170// defaultTrickplayInterval, because the tile mapping divides by this value.171func trickplayIntervalMs() int {172 duration, err := time.ParseDuration(os.Getenv(trickplayIntervalVariable))173 if err != nil || duration < time.Millisecond {174 duration, _ = time.ParseDuration(defaultTrickplayInterval)175 }176 return int(duration / time.Millisecond)177}178179// trickplayOf reads the trickplay directory path from one presentation block,180// the way logoOf reads the logo. An empty block, or one with no trickplay, has181// none.182func trickplayOf(block []byte) string {183 var presentation Presentation184 if err := json.Unmarshal(block, &presentation); err != nil {185 return ""186 }187 return presentation.Trickplay188}189190// findLayout finds the one layout directory inside a trickplay directory. Its191// name carries the tile width and the grid, like "320 - 10x10", so the bridge192// reads the geometry from the name and opens no sheet to learn it.193func findLayout(dir string) (name string, tileW, cols, rows int, ok bool) {194 entries, err := os.ReadDir(dir)195 if err != nil {196 return "", 0, 0, 0, false197 }198 for _, entry := range entries {199 if !entry.IsDir() {200 continue201 }202 tileW, cols, rows, ok = parseLayout(entry.Name())203 if ok {204 return entry.Name(), tileW, cols, rows, true205 }206 }207 return "", 0, 0, 0, false208}209210// parseLayout reads a layout name like "320 - 10x10" into the tile width, the211// columns, and the rows.212func parseLayout(name string) (tileW, cols, rows int, ok bool) {213 left, right, found := strings.Cut(name, " - ")214 if !found {215 return 0, 0, 0, false216 }217 tileW, err := strconv.Atoi(strings.TrimSpace(left))218 if err != nil || tileW <= 0 {219 return 0, 0, 0, false220 }221 columns, lines, found := strings.Cut(strings.TrimSpace(right), "x")222 if !found {223 return 0, 0, 0, false224 }225 cols, err = strconv.Atoi(columns)226 if err != nil || cols <= 0 {227 return 0, 0, 0, false228 }229 rows, err = strconv.Atoi(lines)230 if err != nil || rows <= 0 {231 return 0, 0, 0, false232 }233 return tileW, cols, rows, true234}235236// highestSheet returns the highest N among the N.jpg sheets. A time past the237// end of the film clamps to it.238func highestSheet(dir string) int {239 entries, err := os.ReadDir(dir)240 if err != nil {241 return 0242 }243 highest := 0244 for _, entry := range entries {245 if entry.IsDir() {246 continue247 }248 name := entry.Name()249 if !strings.HasSuffix(name, ".jpg") {250 continue251 }252 number, err := strconv.Atoi(strings.TrimSuffix(name, ".jpg"))253 if err != nil || number < 0 {254 continue255 }256 if number > highest {257 highest = number258 }259 }260 return highest261}
volume.go 100.0%
1package main23// The listening level as Player state. This file holds the retained4// payload every pod for one unit follows, the arithmetic of a press,5// the mpv words that apply a state, and the operator's record of what6// the broker holds per unit.78import (9 "encoding/json"10 "strconv"11 "sync"12)1314// The level runs 0 to 100, and 100 is unity, mpv's 0 dB and its own15// default. The cap sits at unity because the sinks play at unity on16// the audio side, and a software gain above unity only distorts.17const (18 minVolume = 019 unityVolume = 10020)2122// volumeState is the whole payload on a unit's volume topic. Both23// fields are always written, so a reader never needs a default for a24// field a message omits, and every pod applies the same state.25type volumeState struct {26 Level int `json:"level"`27 Muted bool `json:"muted"`28}2930// defaultVolumeState is the state a pod holds before any message31// reaches it, and the value the operator seeds a unit at.32func defaultVolumeState() volumeState {33 return volumeState{Level: unityVolume, Muted: false}34}3536// clamped holds the level inside 0 to 100. Every path that computes37// or accepts a level runs through here, so no arithmetic elsewhere38// bounds itself.39func (v volumeState) clamped() volumeState {40 if v.Level < minVolume {41 v.Level = minVolume42 }43 if v.Level > unityVolume {44 v.Level = unityVolume45 }46 return v47}4849// parseVolumeState reads one message off the topic. A payload that50// does not decode is no state at all. A level outside the range is51// clamped rather than refused, so another program's message cannot52// drive mpv past unity.53func parseVolumeState(payload []byte) (volumeState, bool) {54 var state volumeState55 if err := json.Unmarshal(payload, &state); err != nil {56 return volumeState{}, false57 }58 return state.clamped(), true59}6061// marshalVolumeState encodes the state for the topic. The clamp runs62// here too, so nothing this operator publishes is out of range.63func marshalVolumeState(state volumeState) ([]byte, error) {64 return json.Marshal(state.clamped())65}6667// isVolumeAction names the two actions that never reach mpv as68// commands. A press of either publishes the unit's next state, and69// the subscription applies it.70func isVolumeAction(action string) bool {71 return action == actionVolume || action == actionMute72}7374// nextVolume is the whole arithmetic of a press: the keymap's step,75// signed by the direction, and mute as a plain toggle. The state it76// reads is the last message from the topic, so two pods that press77// against the same message compute the same absolute value and the78// step lands once.79func nextVolume(state volumeState, command mediaCommand) volumeState {80 switch command.Action {81 case actionVolume:82 state.Level += command.Amount83 return state.clamped()84 case actionMute:85 state.Muted = !state.Muted86 return state.clamped()87 }88 return state89}9091// volumeCommands turns one state into mpv's words. Both carry92// no-osd, because the display draws the indicator and mpv's own text93// would be a second one over it. The level travels as a string,94// which is what mpv's set command takes.95func volumeCommands(state volumeState) [][]any {96 state = state.clamped()97 return [][]any{98 {"no-osd", "set", "volume", strconv.Itoa(state.Level)},99 {"no-osd", "set", "mute", mpvYesNo(state.Muted)},100 }101}102103// volumeChangedMessage is the display's only show trigger for the104// indicator. The display records mpv's volume and mute properties as105// they change and draws nothing until this message arrives, so a106// sidecar that applies the retained level at pod start pops no107// indicator on the screen.108const volumeChangedMessage = "volume-changed"109110// volumeChangedCommand is the script message that follows an applied111// press. It goes out on the same connection as the two sets above,112// after them, so the display draws the value it already holds.113func volumeChangedCommand() []any {114 return []any{"script-message", volumeChangedMessage}115}116117// mpvYesNo writes a flag the way mpv reads one, on the command line118// and on the IPC socket alike.119func mpvYesNo(value bool) string {120 if value {121 return "yes"122 }123 return "no"124}125126// mergedWith lays a Play's declared starting state over the unit's127// current one. A field the Play omits changes nothing, so the level128// a person left on the unit stands, and only what the Play states129// moves.130func (v volumeState) mergedWith(override *PlayVolume) volumeState {131 if override == nil {132 return v.clamped()133 }134 if override.Level != nil {135 v.Level = *override.Level136 }137 if override.Muted != nil {138 v.Muted = *override.Muted139 }140 return v.clamped()141}142143// asPlayVolume writes a resolved state back into a Play's spec144// block. The operator sets it on the copy of the Play it builds the145// pod from, the same way it sets the film's saved place there, so146// the pod builder reads one field and never reads the bus.147func (v volumeState) asPlayVolume() *PlayVolume {148 state := v.clamped()149 return &PlayVolume{Level: &state.Level, Muted: &state.Muted}150}151152// volumeDesk is the boundary between the bus and the reconcile loop153// for each unit's level, the way panelDesk is for each unit's panel.154// It answers the one question the seed asks: whether the broker155// already holds a state for this unit.156type volumeDesk struct {157 mutex sync.Mutex158 state map[string]volumeState159}160161func newVolumeDesk() *volumeDesk {162 return &volumeDesk{state: map[string]volumeState{}}163}164165// setState records one unit's state, whether it arrived on the bus166// or the operator just published it. Recording the operator's own167// publish keeps the next pass from seeding the same unit again168// before the broker echoes the message back.169func (d *volumeDesk) setState(key string, state volumeState) {170 d.mutex.Lock()171 defer d.mutex.Unlock()172 d.state[key] = state.clamped()173}174175// stateFor returns one unit's state, and whether the desk holds one176// at all. The bool is the seed's whole decision.177func (d *volumeDesk) stateFor(key string) (volumeState, bool) {178 d.mutex.Lock()179 defer d.mutex.Unlock()180 state, held := d.state[key]181 return state, held182}183184// retain drops the units the cluster no longer holds, so a185// long-running operator does not keep a key per deleted Player.186func (d *volumeDesk) retain(live map[string]bool) {187 d.mutex.Lock()188 defer d.mutex.Unlock()189 for key := range d.state {190 if !live[key] {191 delete(d.state, key)192 }193 }194}
watch.go 98.4%
1package main23// A watch is an ordinary GET with watch=true whose response never4// ends: the API server holds the connection open and writes one JSON5// event per change, the same protocol liken's own operators speak.6//7// This watch carries no object to the loop. Every pass re-lists, so8// a change here is only a wake, and the loop decides what to read.910import (11 "encoding/json"12 "fmt"13 "net/http"14 "os"15 "time"16)1718// watchRetryPause is how long the watch waits before it re-lists19// after a dropped stream, and a variable so a test drives a20// reconnect in milliseconds.21var watchRetryPause = 2 * time.Second2223// watchPlays resumes each stream from a resourceVersion, so no24// change is missed between reconnects. A 410 Gone and a routine25// stream end recover the same way: list the collection, wake the26// loop, and watch again from the list's own version.27//28// The list after every ended stream is what keeps the resume point29// current, so a bookmark's version matters only when that list30// itself fails. The loop asks for bookmarks anyway because they cost31// one line each and make that failure window resumable, where a32// relist costs one full read of the collection, small here, and the33// pass the wake triggers.34func watchPlays(c *Client, resourceVersion string, wake chan<- struct{}) {35 for {36 path := playsPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion37 resp, err := c.Do(http.MethodGet, path, nil)38 if err == nil && resp.StatusCode == http.StatusOK {39 resourceVersion = readWatchStream(resp, resourceVersion, wake)40 }41 if resp != nil {42 drain(resp.Body)43 }4445 // A failed watch is never fatal. The ticker keeps the passes46 // running while this loop is down, and a relist is the whole47 // recovery.48 time.Sleep(watchRetryPause)49 list, err := ListPlays(c)50 if err != nil {51 fmt.Fprintf(os.Stderr, "listing plays to resume the watch: %v\n", err)52 continue53 }54 resourceVersion = list.Metadata.ResourceVersion55 poke(wake)56 }57}5859// watchRemotes mirrors watchPlays for the Remote collection: a Remote60// change is a wake, and the loop reconciles a standing pod for every61// Remote on the pass that wake triggers. The recovery is the same as62// watchPlays: a dropped stream or a 410 Gone re-lists the collection,63// wakes the loop, and resumes the watch from the list's version.64func watchRemotes(c *Client, resourceVersion string, wake chan<- struct{}) {65 for {66 path := remotesAllPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion67 resp, err := c.Do(http.MethodGet, path, nil)68 if err == nil && resp.StatusCode == http.StatusOK {69 resourceVersion = readWatchStream(resp, resourceVersion, wake)70 }71 if resp != nil {72 drain(resp.Body)73 }7475 time.Sleep(watchRetryPause)76 list, err := ListAllRemotes(c)77 if err != nil {78 fmt.Fprintf(os.Stderr, "listing remotes to resume the watch: %v\n", err)79 continue80 }81 resourceVersion = list.Metadata.ResourceVersion82 poke(wake)83 }84}8586// watchPlayers wakes the loop on a Player change, the way watchPlays87// and watchRemotes wake it on theirs. The wake carries no object, so88// the pass it triggers re-reconciles every Play, and a Play whose89// Player reshaped its pod is recreated then. A dropped stream or a 41090// Gone recovers the same way: list the collection, wake the loop, and91// resume the watch from the list's version.92func watchPlayers(c *Client, resourceVersion string, wake chan<- struct{}) {93 for {94 path := playersPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion95 resp, err := c.Do(http.MethodGet, path, nil)96 if err == nil && resp.StatusCode == http.StatusOK {97 resourceVersion = readWatchStream(resp, resourceVersion, wake)98 }99 if resp != nil {100 drain(resp.Body)101 }102103 time.Sleep(watchRetryPause)104 list, err := ListPlayers(c)105 if err != nil {106 fmt.Fprintf(os.Stderr, "listing players to resume the watch: %v\n", err)107 continue108 }109 resourceVersion = list.Metadata.ResourceVersion110 poke(wake)111 }112}113114// watchPeripherals wakes the loop on a Peripheral change, the way the115// other watchers wake it on theirs. A controller that connects,116// disconnects, or reports a new charge changes its Peripheral, and the117// pass that wake triggers republishes the Player status the idle screen118// draws. The recovery is the same as the others: list the collection,119// wake the loop, and resume from the list's version.120func watchPeripherals(c *Client, resourceVersion string, wake chan<- struct{}) {121 for {122 path := peripheralsPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion123 resp, err := c.Do(http.MethodGet, path, nil)124 if err == nil && resp.StatusCode == http.StatusOK {125 resourceVersion = readWatchStream(resp, resourceVersion, wake)126 }127 if resp != nil {128 drain(resp.Body)129 }130131 time.Sleep(watchRetryPause)132 list, err := ListPeripherals(c)133 if err != nil {134 fmt.Fprintf(os.Stderr, "listing peripherals to resume the watch: %v\n", err)135 continue136 }137 resourceVersion = list.Metadata.ResourceVersion138 poke(wake)139 }140}141142// watchKeymaps wakes the loop on a Keymap change, so the pass it143// triggers recompiles and republishes every Remote's table, and an edit reaches144// a running standing pod within one pass rather than one backstop tick.145// The recovery mirrors the other watchers: a dropped stream or a 410146// Gone lists the collection, wakes the loop, and resumes the watch from147// the list's version.148func watchKeymaps(c *Client, resourceVersion string, wake chan<- struct{}) {149 for {150 path := keymapsPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion151 resp, err := c.Do(http.MethodGet, path, nil)152 if err == nil && resp.StatusCode == http.StatusOK {153 resourceVersion = readWatchStream(resp, resourceVersion, wake)154 }155 if resp != nil {156 drain(resp.Body)157 }158159 time.Sleep(watchRetryPause)160 list, err := ListKeymaps(c)161 if err != nil {162 fmt.Fprintf(os.Stderr, "listing keymaps to resume the watch: %v\n", err)163 continue164 }165 resourceVersion = list.Metadata.ResourceVersion166 poke(wake)167 }168}169170// watchMediaPreferences wakes the loop on a MediaPreferences edit, so the171// resolved fields on a running Play's status refresh within one pass. Recovery172// mirrors the other watchers.173func watchMediaPreferences(c *Client, resourceVersion string, wake chan<- struct{}) {174 for {175 path := mediaPrefsPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion176 resp, err := c.Do(http.MethodGet, path, nil)177 if err == nil && resp.StatusCode == http.StatusOK {178 resourceVersion = readWatchStream(resp, resourceVersion, wake)179 }180 if resp != nil {181 drain(resp.Body)182 }183184 time.Sleep(watchRetryPause)185 list, err := ListMediaPreferences(c)186 if err != nil {187 fmt.Fprintf(os.Stderr, "listing media preferences to resume the watch: %v\n", err)188 continue189 }190 resourceVersion = list.Metadata.ResourceVersion191 poke(wake)192 }193}194195// watchPods wakes the loop when k8s removes a playback pod or when one turns196// Failed, so an eviction or a crash reaches the reconcile at once instead of197// waiting for the backstop tick. Its recovery matches the other watchers.198func watchPods(c *Client, resourceVersion string, wake chan<- struct{}) {199 for {200 path := podsAllPath + "?watch=true&allowWatchBookmarks=true&" + playbackPodsQuery +201 "&resourceVersion=" + resourceVersion202 resp, err := c.Do(http.MethodGet, path, nil)203 if err == nil && resp.StatusCode == http.StatusOK {204 resourceVersion = readPodWatchStream(resp, resourceVersion, wake)205 }206 if resp != nil {207 drain(resp.Body)208 }209210 time.Sleep(watchRetryPause)211 list, err := ListPlaybackPods(c)212 if err != nil {213 fmt.Fprintf(os.Stderr, "listing playback pods to resume the watch: %v\n", err)214 continue215 }216 resourceVersion = list.Metadata.ResourceVersion217 poke(wake)218 }219}220221// readPodWatchStream reads one connection's pod events. It wakes the loop on222// a delete and on a change to the Failed phase, and on nothing else, because223// a routine update to a running pod needs no pass. The returned version is224// where the next watch resumes.225func readPodWatchStream(resp *http.Response, resourceVersion string, wake chan<- struct{}) string {226 decoder := json.NewDecoder(resp.Body)227 for {228 var event struct {229 Type string `json:"type"`230 Object struct {231 Metadata ObjectMeta `json:"metadata"`232 Status struct {233 Phase string `json:"phase"`234 } `json:"status"`235 } `json:"object"`236 }237 if err := decoder.Decode(&event); err != nil {238 return resourceVersion239 }240 if event.Type == "ERROR" {241 return resourceVersion242 }243 if event.Object.Metadata.ResourceVersion != "" {244 resourceVersion = event.Object.Metadata.ResourceVersion245 }246 switch event.Type {247 case "DELETED":248 poke(wake)249 case "MODIFIED":250 if event.Object.Status.Phase == podFailed {251 poke(wake)252 }253 }254 }255}256257// readWatchStream reads one connection's worth of events. The258// returned version is where the next watch resumes.259func readWatchStream(resp *http.Response, resourceVersion string, wake chan<- struct{}) string {260 decoder := json.NewDecoder(resp.Body)261 for {262 var event struct {263 Type string `json:"type"`264 Object struct {265 Metadata ObjectMeta `json:"metadata"`266 } `json:"object"`267 }268 if err := decoder.Decode(&event); err != nil {269 return resourceVersion270 }271 if event.Type == "ERROR" {272 // Usually a 410 Gone wrapped in an event: the server no273 // longer holds this resourceVersion. The relist in the274 // caller is the answer.275 return resourceVersion276 }277 if event.Object.Metadata.ResourceVersion != "" {278 resourceVersion = event.Object.Metadata.ResourceVersion279 }280 if event.Type == "BOOKMARK" {281 // A bookmark moves the resume point and reconciles282 // nothing, so it earns no wake.283 continue284 }285 poke(wake)286 }287}288289// poke never blocks, and the wake channel buffers exactly one. A290// wake already queued says everything a second one would say,291// because the pass that answers it reads the whole collection.292func poke(wake chan<- struct{}) {293 select {294 case wake <- struct{}{}:295 default:296 }297}
Rust (media-screen)
876 of 876 lines, 100.0%.
media-screen/src/lib.rs 100.0%
1//! Everything a screen client for one `Player` does with the bus, short2//! of drawing.3//!4//! The crate has two halves. [`Screen`] is pure and holds every rule:5//! the focus gate, the play gate, the quiet window and the shade, the6//! off window and the panel desire, the volume step, the cycle request,7//! and the re-present. [`Reader`] is the thread over the broker: it8//! subscribes, folds each message through the rules, runs the9//! deadlines, performs the publishes, and hands the client what it10//! draws.11//!12//! Two clients read it: this repository's idle screen, and the library13//! layer's media browser, which takes it as a git dependency pinned to14//! a release tag. The crate opens no window, holds no keymap of its15//! own, and names no toolkit.1617pub mod panel;18pub mod reader;19pub mod screen;20pub mod status;21pub mod volume;22pub mod wiring;2324pub use reader::{Bus, Reader, Waker};25pub use screen::{Effect, Moment, Publish, Screen};26pub use wiring::Wiring;2728/// Read one JSON object off a topic. Every payload on these topics is an29/// object, so a payload that is not one is not this operator's and it changes30/// nothing. The check is here because a derived reader also takes a JSON array31/// as the same fields in order, and a two-element array is not a status.32pub(crate) fn object<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Option<T> {33 let value: serde_json::Value = serde_json::from_slice(payload).ok()?;34 if !value.is_object() {35 return None;36 }37 serde_json::from_value(value).ok()38}
media-screen/src/panel.rs 100.0%
1// The retained panel topic carries a desire and not a report. The unit it2// belongs to is named by the topic, not by the body.3//4// A client states a desire instead of writing the panel, because a5// screen client holds no API credentials and no wire. The operator6// reads the desire off this topic and overrides the screen's `Display`,7// and the display-operator writes the hardware.89use serde::Serialize;1011/// The two desires a client states. They are the values on the panel topic,12/// not the states the `Player` status carries.13pub const ON: &str = "on";14pub const OFF: &str = "off";1516/// The whole payload on the panel topic.17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]18pub struct Desire<'a> {19 pub desire: &'a str,20}2122impl Desire<'_> {23 /// The desire as it travels on the topic.24 pub fn payload(self) -> Vec<u8> {25 // A word always encodes, so the error is the interface's and not a26 // state this code reaches.27 serde_json::to_vec(&self).unwrap_or_default()28 }29}3031#[cfg(test)]32mod tests {33 use super::*;3435 #[test]36 fn each_desire_is_one_word_on_the_topic() {37 assert_eq!(Desire { desire: ON }.payload(), br#"{"desire":"on"}"#);38 assert_eq!(Desire { desire: OFF }.payload(), br#"{"desire":"off"}"#);39 }40}
media-screen/src/reader.rs 100.0%
1//! The socket half of the crate: the thread that holds the connection, the2//! subscribe it sends on every session, the clock that runs the two windows,3//! and the publishes the rules ask for.4//!5//! The client sees none of this. It holds a [`Reader`], calls6//! [`Bus::drain`] on every wake of its loop, and draws what comes back.7//! Every publish this crate makes goes out on the connection these8//! threads already hold, so the client opens nothing and names no topic9//! of the crate's.1011use std::sync::mpsc;12use std::sync::{Arc, Mutex, Weak};13use std::time::{Duration, Instant};1415// `rumqttc`'s client type is imported under the broker's name, because this16// crate holds a `Screen` of its own and one file must not read as if it spoke17// about both.18use rumqttc::{19 Client as Broker, ConnectionError, Event, MqttOptions, Packet, QoS, SubscribeFilter,20};2122use crate::screen::{Effect, Moment, Publish, Screen};23use crate::wiring::Wiring;2425/// The port a broker answers on when the address names none.26const DEFAULT_PORT: u16 = 1883;2728/// The keepalive this client asks for. It is the interval `media-operator`'s29/// own bus client asks for, so every client of one broker keeps the same30/// clock.31const KEEPALIVE: Duration = Duration::from_secs(30);3233/// The wait after a failed session, so a broker that is down is no tight34/// reconnect loop.35const RECONNECT_WAIT: Duration = Duration::from_secs(1);3637/// The longest the clock sleeps between two reads of the armed window. The38/// client asks for the shade on its own thread, and that request arms the off39/// window, so the clock reads the deadline again on this cadence rather than40/// sleeping out the window it started on. One wake a second is the rate the41/// idle screen already redraws its clock at.42const TICK: Duration = Duration::from_secs(1);4344/// The capacity of `rumqttc`'s outbound request queue, which carries the45/// subscribes and every publish the rules ask for. The inbound path to the46/// client is an unbounded channel, and it drops nothing.47const QUEUE_DEPTH: usize = 64;4849/// A handle that wakes the client's event loop from any thread.50pub type Waker = Arc<dyn Fn() + Send + Sync>;5152/// What a client needs from the bus. It is a trait so a client's own tests53/// fold real moments and see a real request with no socket under them.54pub trait Bus: std::fmt::Debug {55 /// Every moment that arrived since the last call. The call never blocks.56 fn drain(&self) -> Vec<Moment>;5758 /// Ask for the shade, from the client's own reading of a press. The stock59 /// idle client asks on back; a client with levels asks at its top level,60 /// because only the client knows whether back has anywhere to go.61 fn sleep(&self);6263 /// Publish one payload on a topic of the client's own, on the64 /// connection this crate already holds. A client with a request of65 /// its own, such as the library layer's browser asking for a66 /// `Play`, must not open a second connection to the same broker67 /// under a second identifier. The crate does nothing with the68 /// topic: the rules in [`Screen`] neither read it nor subscribe to69 /// it.70 fn publish(&self, topic: &str, payload: Vec<u8>, retained: bool);7172 /// Wake the loop on every delivery, so a press shows on the next frame73 /// rather than at the next scheduled second.74 fn wake_on_delivery(&self, wake: Waker);75}7677/// The slot the threads read their waker from. The loop does not exist yet78/// when the reader connects, so the waker arrives after the threads start,79/// and each one reads the slot on every delivery.80type WakerSlot = Arc<Mutex<Option<Waker>>>;8182/// The subscription, held by the client.83pub struct Reader {84 moments: mpsc::Receiver<Moment>,85 /// The rules. Both threads hold a weak reference to this one value, so86 /// dropping the reader ends both of them, and a client that opened a87 /// reader and let it go leaves no thread behind.88 screen: Arc<Mutex<Screen>>,89 threads: Threads,90}9192impl std::fmt::Debug for Reader {93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {94 f.debug_struct("Reader").finish_non_exhaustive()95 }96}9798impl Reader {99 /// Connect to the broker the wiring names and subscribe. The answer is100 /// `None` when the operator named no broker or no topics, which is how a101 /// client runs on a workstation with its seeds alone.102 ///103 /// `client_id` must name this client alone, because a broker closes the104 /// older connection when two arrive under one identifier.105 pub fn open(wiring: &Wiring, client_id: &str) -> Option<Self> {106 let screen = Screen::new(wiring);107 let (host, port) = broker(&wiring.bus_address)?;108 if screen.filters().is_empty() {109 return None;110 }111112 let mut options = MqttOptions::new(client_id, host, port);113 options.set_keep_alive(KEEPALIVE);114 let (client, connection) = Broker::new(options, QUEUE_DEPTH);115116 let (sender, moments) = mpsc::channel();117 let screen = Arc::new(Mutex::new(screen));118 let threads = Threads {119 screen: Arc::downgrade(&screen),120 client,121 sender,122 waker: Arc::default(),123 };124125 // The connection thread never leaves its loop, so the two windows run126 // on a thread of their own. A deadline on the connection itself would127 // have to cancel a read of the socket to fire, and a cancelled read128 // can lose the press it was in the middle of.129 let reading = threads.clone();130 spawn("media-bus", move || {131 let mut connection = connection;132 read(&reading, connection.iter());133 })?;134 let ticking = threads.clone();135 spawn("media-clock", move || clock(&ticking))?;136137 Some(Self {138 moments,139 screen,140 threads,141 })142 }143}144145impl Bus for Reader {146 /// The call costs the decoding and nothing else, because the threads147 /// decoded every moment before the channel.148 fn drain(&self) -> Vec<Moment> {149 self.moments.try_iter().collect()150 }151152 /// The shade comes back through [`Bus::drain`] the way every other moment153 /// does, so the client folds one stream.154 fn sleep(&self) {155 let effects = self156 .screen157 .lock()158 .expect("no thread panics with the lock")159 .sleep(Instant::now());160 perform(&self.threads, effects);161 }162163 /// The publish is queued rather than sent, the way every publish the164 /// rules ask for is, because the reader thread is what drives the165 /// connection.166 fn publish(&self, topic: &str, payload: Vec<u8>, retained: bool) {167 send(168 &self.threads.client,169 Publish {170 topic: topic.to_string(),171 payload,172 retained,173 },174 );175 }176177 /// Without a waker a moment waits in the channel for the next scheduled178 /// wake, and a press then shows up to a second late.179 fn wake_on_delivery(&self, wake: Waker) {180 *self181 .threads182 .waker183 .lock()184 .expect("no reader panics with the lock") = Some(wake);185 }186}187188/// What each thread of this crate holds: the rules, the connection to publish189/// on, the channel to the client, and the slot its waker arrives in.190#[derive(Clone)]191struct Threads {192 screen: Weak<Mutex<Screen>>,193 client: Broker,194 sender: mpsc::Sender<Moment>,195 waker: WakerSlot,196}197198impl Threads {199 /// The rules, while a client still holds them. A thread that reads200 /// nothing here ends, because the client its work is for is gone.201 fn screen(&self) -> Option<Arc<Mutex<Screen>>> {202 self.screen.upgrade()203 }204}205206/// Start one of this crate's threads. A client that spawns none draws its207/// seeds and hears nothing for the life of the pod, so the line says why.208fn spawn(name: &str, body: impl FnOnce() + Send + 'static) -> Option<()> {209 std::thread::Builder::new()210 .name(name.into())211 .spawn(body)212 .inspect_err(|error| eprintln!("media-screen: {name}: {error}"))213 .ok()214 .map(|_| ())215}216217/// The reader thread. It subscribes on every connection, because a broker218/// holds no subscription across a session, and it folds each message through219/// the rules before the channel, so the client's loop takes finished values.220fn read(threads: &Threads, events: impl Iterator<Item = Result<Event, ConnectionError>>) {221 for event in events {222 let Some(screen) = threads.screen() else {223 return;224 };225 let effects = match event {226 Ok(Event::Incoming(Packet::ConnAck(_))) => {227 let mut screen = screen.lock().expect("no thread panics with the lock");228 let effects = screen.connected();229 let filters = screen.filters().into_iter().map(|path| SubscribeFilter {230 path,231 qos: QoS::AtMostOnce,232 });233 // The subscribe is queued rather than sent, because this234 // thread is the one that drives the connection. A blocking235 // send would wait for a reader that is this loop.236 let _ = threads.client.try_subscribe_many(filters);237 effects238 }239 Ok(Event::Incoming(Packet::Publish(message))) => screen240 .lock()241 .expect("no thread panics with the lock")242 .deliver(243 &message.topic,244 &message.payload,245 message.retain,246 Instant::now(),247 ),248 Err(error) => {249 // The client reconnects on its own, so the line is the record250 // and not a request for anything.251 eprintln!("media-screen: bus: {error}");252 std::thread::sleep(RECONNECT_WAIT);253 Vec::new()254 }255 Ok(_) => Vec::new(),256 };257 if !perform(threads, effects) {258 // The client dropped its reader, so nothing reads what this259 // thread decodes.260 return;261 }262 }263}264265/// The clock thread, which is the two windows. It sleeps to the armed266/// deadline and no longer than [`TICK`], so a deadline another thread armed267/// is read within the second.268fn clock(threads: &Threads) {269 loop {270 let Some(screen) = threads.screen() else {271 return;272 };273 let deadline = screen274 .lock()275 .expect("no thread panics with the lock")276 .next_deadline();277 // The rules are released before the sleep, so the reader thread and278 // the client both reach them while this one waits.279 drop(screen);280 std::thread::sleep(wait(deadline, Instant::now()));281282 let Some(screen) = threads.screen() else {283 return;284 };285 let effects = screen286 .lock()287 .expect("no thread panics with the lock")288 .tick(Instant::now());289 if !perform(threads, effects) {290 return;291 }292 }293}294295/// How long the clock sleeps: to the armed deadline, and never longer than296/// one tick, so a window another thread armed is read on the next pass.297fn wait(deadline: Option<Instant>, now: Instant) -> Duration {298 match deadline {299 Some(at) => at.saturating_duration_since(now).min(TICK),300 None => TICK,301 }302}303304/// Perform one fold's effects: send each moment to the client, publish each305/// message on the connection, and wake the loop once if anything reached the306/// client. The answer is false only when the client dropped its receiver,307/// which ends the thread that called.308///309/// The wake follows the sends, so the loop reads a whole fold on one pass310/// rather than one moment per wake.311fn perform(threads: &Threads, effects: Vec<Effect>) -> bool {312 let mut drew = false;313 for effect in effects {314 match effect {315 Effect::Moment(moment) => {316 if threads.sender.send(moment).is_err() {317 return false;318 }319 drew = true;320 }321 Effect::Publish(publish) => send(&threads.client, publish),322 }323 }324 if drew325 && let Some(wake) = threads326 .waker327 .lock()328 .expect("no client panics with the lock")329 .as_ref()330 {331 wake();332 }333 true334}335336/// One publish, queued rather than sent, because the reader thread is what337/// drives the connection. It goes at QoS 0, and the rules say which messages338/// the broker retains.339fn send(client: &Broker, publish: Publish) {340 if let Err(error) = client.try_publish(341 publish.topic,342 QoS::AtMostOnce,343 publish.retained,344 publish.payload,345 ) {346 eprintln!("media-screen: bus: {error}");347 }348}349350/// The identifier this client connects under. It must name this client alone,351/// because a broker closes the older connection when two arrive under one352/// identifier. `prefix` is the client's own name, so two different clients on353/// one machine do not collide either.354pub fn client_id(prefix: &str, hostname: &str) -> String {355 match hostname.trim() {356 "" => prefix.to_string(),357 host => format!("{prefix}-{host}"),358 }359}360361/// The name this machine answers to. In a pod it is the pod's own name, which362/// is unique in the cluster.363pub fn hostname() -> String {364 std::fs::read_to_string("/etc/hostname").unwrap_or_default()365}366367/// The broker's host and port. An address with no port answers on the MQTT368/// default. An empty address is no broker at all.369///370/// An IPv6 literal carries colons of its own, so the port follows the371/// brackets the URI form puts around the address, and a bare literal is the372/// host alone on the default port.373fn broker(address: &str) -> Option<(String, u16)> {374 let address = address.trim();375 if address.is_empty() {376 return None;377 }378 if let Some(rest) = address.strip_prefix('[') {379 let (host, after) = rest.split_once(']')?;380 return match after {381 "" => Some((host.to_string(), DEFAULT_PORT)),382 after => Some((host.to_string(), after.strip_prefix(':')?.parse().ok()?)),383 };384 }385 if address.matches(':').count() > 1 {386 return Some((address.to_string(), DEFAULT_PORT));387 }388 match address.rsplit_once(':') {389 Some((host, port)) => Some((host.to_string(), port.parse().ok()?)),390 None => Some((address.to_string(), DEFAULT_PORT)),391 }392}393394#[cfg(test)]395mod tests;
media-screen/src/screen.rs 100.0%
1//! The rules a screen client holds for one `Player`: the quiet window and the2//! off window, the focus gate, the shade, the level a press steps, the cycle3//! request, and the panel desire.4//!5//! [`Screen`] reaches no socket and holds no clock. Every rule below is6//! a function of what arrived and what time it is, so a test proves7//! each one with no broker and no thread, and [`crate::Reader`] is the8//! only part of the crate that opens anything.9//!10//! The re-present is the whole fix for a seatless compositor. Weston's11//! kiosk-shell reveals a lower surface only along a code path gated on a12//! seat, and `liken`'s compositor runs with require-input=false and no input13//! devices, so it has no seat. When a `Play`'s surface is destroyed the idle14//! clock stays hidden and the screen goes black, though the client still15//! runs. A freshly mapped surface is revealed along a seat-independent path,16//! so the operator publishes the request, the client maps a new surface, and17//! kiosk reveals that one.1819pub mod keys;20pub mod press;2122use std::time::{Duration, Instant};2324use serde::Deserialize;2526use crate::panel;27use crate::status::{Activity, Status};28use crate::volume::Volume;29use crate::wiring::{Remote, Wiring};3031/// The suffix that turns a remote's focus topic into its cycle topic, the32/// same path `media-operator`'s `remoteFocusCycleTopic` builds, so a client33/// needs no second topic list.34const CYCLE_SUFFIX: &str = "/cycle";3536/// The one command this crate answers on the `Player`'s commands topic.37const RE_PRESENT: &str = "re-present";3839/// One thing the client draws.40///41/// Each one is a fact the screen shows or a moment it moves on, and42/// none of them is a decision the client makes again. The shade moments43/// say which way the cover eases. A focus names the controller a live44/// mark landed on, by its place in `spec.remotes`.45#[derive(Debug, Clone, PartialEq, Eq)]46pub enum Moment {47 /// One navigation press, under the kernel's name for the control. The48 /// client holds its own table from these names to what they do there.49 Press(&'static str),50 /// The quiet window ran out, or the client asked for the shade.51 Sleep,52 /// A press, a live mark, or a starting `Play` lifted the shade.53 Wake,54 /// A live mark named this `Player`. `remote` is the controller's place in55 /// `spec.remotes`, which is the order the status lists the parts in.56 Focus { remote: usize },57 /// A `Play` ended and the screen is this client's again. The client maps58 /// a fresh Wayland surface, and kiosk reveals that one.59 Present,60 /// The unit's whole presentable state.61 Status(Status),62 /// The unit's listening level. `pressed` is false for the broker's63 /// catch-up and true for a press.64 Level { volume: Volume, pressed: bool },65}6667/// One message this crate sends on the bus. The client never builds one:68/// [`crate::Reader`] performs each of these on the connection it already69/// holds.70#[derive(Debug, Clone, PartialEq, Eq)]71pub struct Publish {72 pub topic: String,73 pub payload: Vec<u8>,74 pub retained: bool,75}7677/// What one fold leaves to do: something the client draws, or something the78/// crate sends.79#[derive(Debug, Clone, PartialEq, Eq)]80pub enum Effect {81 Moment(Moment),82 Publish(Publish),83}8485/// One controller's mark and whether this bus session already delivered one.86/// The first message of a session is the broker's retained catch-up, a87/// restore and not a person, so it sets the gate and pulses nothing.88#[derive(Debug, Clone, Default, PartialEq, Eq)]89struct Mark {90 player: String,91 caught_up: bool,92}9394/// Which window the armed deadline belongs to.95#[derive(Debug, Clone, Copy, PartialEq, Eq)]96enum Window {97 /// The quiet window, which brings the shade down.98 Quiet,99 /// The off window, which states the off desire.100 Off,101}102103/// The state a screen client holds for one unit.104#[derive(Debug)]105pub struct Screen {106 /// The `Player`'s own object name, the value a focus mark holds when it107 /// names this unit. An empty name matches no mark, so a client that read108 /// none answers no press.109 player_name: String,110 status_topic: String,111 /// The unit's volume topic, empty for a `Player` with no sinks. Empty is112 /// the speaker gate: the client subscribes to no level and answers no113 /// volume press.114 volume_topic: String,115 commands_topic: String,116 panel_topic: String,117 /// The unit's controllers, in `spec.remotes` order, so a controller's118 /// index in this list is the index a focus moment carries.119 remotes: Vec<Remote>,120 /// The last mark each controller's focus topic delivered, one per entry121 /// of `remotes`.122 marks: Vec<Mark>,123124 /// The quiet window. Zero never arms the timer.125 fade_after: Duration,126 /// The off window, clamped to at least the fade. Zero leaves the desire127 /// at on forever.128 off_after: Duration,129130 /// The last state the volume topic delivered. A press steps from it, and131 /// from unity before any message arrives.132 volume: Option<Volume>,133 /// Whether the last status named the activity `Idle`, the only state the134 /// timer arms in. It starts false, so a client answers no press until the135 /// retained status reaches it.136 idle: bool,137 /// Whether the shade is down.138 asleep: bool,139 /// The panel state this client last stated, on or off. The client writes140 /// no hardware; the operator reads the desire from the bus and overrides141 /// the screen's `Display`.142 desire: &'static str,143 /// The armed window and the moment it runs out.144 deadline: Option<(Instant, Window)>,145}146147/// The one field of the commands topic this crate reads.148#[derive(Deserialize)]149struct Command {150 #[serde(default)]151 action: String,152}153154impl Screen {155 /// The screen one wiring describes.156 pub fn new(wiring: &Wiring) -> Self {157 Self {158 player_name: wiring.player_name.clone(),159 status_topic: wiring.status_topic.clone(),160 volume_topic: wiring.volume_topic.clone(),161 commands_topic: wiring.commands_topic.clone(),162 panel_topic: wiring.panel_topic.clone(),163 marks: vec![Mark::default(); wiring.remotes.len()],164 remotes: wiring.remotes.clone(),165 fade_after: wiring.fade_after,166 off_after: wiring.off_after,167 volume: None,168 idle: false,169 asleep: false,170 // A client that starts holds the on desire, and states it on its171 // first bus session, so a pod that returns while the panel is172 // dark lights it again.173 desire: panel::ON,174 deadline: None,175 }176 }177178 /// The topics to subscribe to. An empty topic is one the operator did not179 /// set, and a unit with no sinks has no volume topic at all.180 ///181 /// A press on any of the unit's controllers reaches the quiet window, so182 /// every events topic is read. The focus topic is retained, so each mark183 /// arrives on subscribe and the gate stands before the first press.184 pub fn filters(&self) -> Vec<String> {185 let mut filters = vec![186 self.status_topic.clone(),187 self.volume_topic.clone(),188 self.commands_topic.clone(),189 ];190 for remote in &self.remotes {191 filters.push(remote.events.clone());192 filters.push(remote.focus.clone());193 }194 filters.retain(|topic| !topic.is_empty());195 filters196 }197198 /// The moment the armed window runs out, and nothing while none is armed.199 pub fn next_deadline(&self) -> Option<Instant> {200 self.deadline.map(|(at, _)| at)201 }202203 /// Fold one message from any subscription, and the topic says which204 /// message this is. A topic this screen did not subscribe to, and a205 /// payload that does not decode, are both nothing at all, so a newer206 /// message on a topic has no effect rather than a crash.207 ///208 /// `retained` is the broker's own mark on a delivery from its retained209 /// store. A retained level is the catch-up, which a person did not press,210 /// so it sets the level and shows no indicator; a live level is a press.211 pub fn deliver(212 &mut self,213 topic: &str,214 payload: &[u8],215 retained: bool,216 now: Instant,217 ) -> Vec<Effect> {218 if !self.status_topic.is_empty() && topic == self.status_topic {219 return self.on_status(payload, now);220 }221 // The volume topic carries a state and not a named command, so it is222 // read before the command vocabulary below.223 if !self.volume_topic.is_empty() && topic == self.volume_topic {224 return self.on_level(payload, retained);225 }226 // A controller's presses are checked before the commands topic,227 // because a key event is not the operator's command vocabulary.228 if let Some(index) = self.remote_for(topic, |remote| &remote.events) {229 return self.on_press(index, payload, now);230 }231 // The mark is a state on its own retained topic, read before the232 // command vocabulary for the same reason the level is.233 if let Some(index) = self.remote_for(topic, |remote| &remote.focus) {234 return self.on_focus(index, payload, now);235 }236 if !self.commands_topic.is_empty() && topic == self.commands_topic {237 return self.on_command(payload);238 }239 Vec::new()240 }241242 /// The armed window running out: the quiet window brings the shade down243 /// and starts the off window, and the off window states the off desire244 /// and arms nothing.245 pub fn tick(&mut self, now: Instant) -> Vec<Effect> {246 let Some((at, window)) = self.deadline else {247 return Vec::new();248 };249 if now < at {250 return Vec::new();251 }252 let mut effects = Vec::new();253 match window {254 Window::Quiet => {255 self.asleep = true;256 // The shade coming down starts the second window.257 self.rearm(now);258 self.shade(Some(Moment::Sleep), &mut effects);259 }260 Window::Off => {261 self.deadline = None;262 self.desire(panel::OFF, &mut effects);263 }264 }265 effects266 }267268 /// Bring the shade down on the client's own reading of a press. The stock269 /// idle client asks on back; a client with levels asks only at the top270 /// one, because only the client knows whether back has anywhere to go.271 ///272 /// It acts only while the unit plays nothing and the screen is awake, and273 /// it makes the same three moves the quiet window makes, so the shade and274 /// the panel desire behave the same whichever one asked.275 pub fn sleep(&mut self, now: Instant) -> Vec<Effect> {276 if !self.idle || self.asleep {277 return Vec::new();278 }279 self.asleep = true;280 self.rearm(now);281 let mut effects = Vec::new();282 self.shade(Some(Moment::Sleep), &mut effects);283 effects284 }285286 /// The start of every bus session. A fresh session redelivers every287 /// retained mark, so each one is a catch-up again and pulses nothing. The288 /// mark itself stands across the reconnect, so the gate does not open or289 /// close on a broker restart alone.290 ///291 /// The panel desire is this client's own retained state, so it goes out292 /// again on every session. A client that returns while the panel is dark293 /// states the on desire here, and the operator lifts the override.294 pub fn connected(&mut self) -> Vec<Effect> {295 for mark in &mut self.marks {296 mark.caught_up = false;297 }298 let mut effects = Vec::new();299 self.publish_desire(&mut effects);300 effects301 }302303 /// Fold one status. `Idle` is the only activity the timer arms in, so a304 /// status that leaves `Idle` disarms it. The same status lifts the shade305 /// if the screen sleeps, so a `Play` started from another room shows its306 /// film and not a black screen.307 ///308 /// The operator republishes the status on any change to the payload, a309 /// controller's `Connected` flap included, so only a status that moved310 /// the unit into or out of `Idle` restarts the quiet window; a republish311 /// of the same activity leaves the window where it stands. The client312 /// draws every status either way.313 fn on_status(&mut self, payload: &[u8], now: Instant) -> Vec<Effect> {314 let Some(status) = crate::status::parse(payload) else {315 return Vec::new();316 };317 let idle = status.activity == Activity::Idle;318 let mut effects = vec![Effect::Moment(Moment::Status(status))];319 if idle == self.idle {320 return effects;321 }322 self.idle = idle;323 let mut moment = None;324 if !self.idle && self.asleep {325 self.asleep = false;326 moment = Some(Moment::Wake);327 }328 self.rearm(now);329 self.shade(moment, &mut effects);330 effects331 }332333 /// Fold one message off the volume topic. The level is held for two334 /// reasons: a volume press steps from the last level the topic delivered,335 /// and the client draws the indicator from it.336 fn on_level(&mut self, payload: &[u8], retained: bool) -> Vec<Effect> {337 let Some(volume) = crate::volume::parse(payload) else {338 return Vec::new();339 };340 self.volume = Some(volume);341 vec![Effect::Moment(Moment::Level {342 volume,343 pressed: !retained,344 })]345 }346347 /// Fold one key event. A sleeping screen wakes on any press, so a person348 /// gets the screen back with whatever control they touched, and that press349 /// does nothing else. A navigation key, while the unit plays nothing,350 /// reaches the client. A level key, while the unit plays nothing and the351 /// screen is awake, publishes the unit's next level. The cycle key asks352 /// the operator to move the mark and does nothing else. Every other press353 /// restarts the quiet window.354 ///355 /// A press acts only while the remote's mark names this `Player`. A pad356 /// pointed at another room touches nothing here, not the shade and not357 /// the level. A release changes nothing at all: the standing pod holds358 /// the repeat and stops it at the release, so this crate has nothing to359 /// stop.360 fn on_press(&mut self, index: usize, payload: &[u8], now: Instant) -> Vec<Effect> {361 let Some(press) = press::parse(payload) else {362 return Vec::new();363 };364 if !self.holds_focus(index) || !press.edge() {365 return Vec::new();366 }367368 let mut moment = None;369 let mut forwarded = None;370 let mut publish = None;371 if self.idle && press.down() && press.key == keys::CYCLE {372 publish = self.cycle(index);373 } else if self.asleep {374 self.asleep = false;375 moment = Some(Moment::Wake);376 } else if self.idle377 && let Some(key) = keys::navigation(&press.key)378 {379 forwarded = Some(key);380 } else if self.idle {381 publish = self.level(&press);382 }383384 self.rearm(now);385 let mut effects = Vec::new();386 self.shade(moment, &mut effects);387 if let Some(key) = forwarded {388 effects.push(Effect::Moment(Moment::Press(key)));389 }390 if let Some(publish) = publish {391 effects.push(Effect::Publish(publish));392 }393 effects394 }395396 /// Fold one mark off a controller's focus topic. It sets the gate every397 /// time. A live message that names this `Player` is a person pointing the398 /// controller here: it lifts the shade, restarts the quiet window, and399 /// pulses the display with the controller's index. The session's first400 /// message is the broker's retained catch-up, so it sets the gate and401 /// does nothing else. A mark that names another `Player`, or a `Play`402 /// name left from an older operator, gates closed and pulses nothing.403 ///404 /// A mark that does not name this `Player` only closes the gate here. The405 /// standing pod synthesises the repeat and stops it at the release, so a406 /// control held as the mark moves away needs nothing stopped here.407 fn on_focus(&mut self, index: usize, payload: &[u8], now: Instant) -> Vec<Effect> {408 let mark = String::from_utf8_lossy(payload).into_owned();409 let live = self.marks[index].caught_up;410 let names_this_player = self.names_this_player(&mark);411 self.marks[index] = Mark {412 player: mark,413 caught_up: true,414 };415 if !names_this_player || !live {416 return Vec::new();417 }418419 let mut moment = None;420 if self.asleep {421 self.asleep = false;422 moment = Some(Moment::Wake);423 }424 self.rearm(now);425 let mut effects = Vec::new();426 self.shade(moment, &mut effects);427 effects.push(Effect::Moment(Moment::Focus { remote: index }));428 effects429 }430431 /// Fold one message off the commands topic. One command acts, the432 /// operator's re-present, and it acts only while the unit plays nothing,433 /// so a stray one during a film never maps the clock over it. Every other434 /// action does nothing.435 fn on_command(&mut self, payload: &[u8]) -> Vec<Effect> {436 let Some(command) = crate::object::<Command>(payload) else {437 return Vec::new();438 };439 if command.action != RE_PRESENT || !self.idle {440 return Vec::new();441 }442 vec![Effect::Moment(Moment::Present)]443 }444445 /// The cycle request the operator arbitrates, on the controller's own446 /// cycle topic, not retained, because a cycle is an event and not a447 /// state. It is the same message the playback pod's command sidecar448 /// publishes during a film.449 fn cycle(&self, index: usize) -> Option<Publish> {450 let focus = &self.remotes[index].focus;451 if focus.is_empty() {452 return None;453 }454 Some(Publish {455 topic: focus.clone() + CYCLE_SUFFIX,456 payload: Vec::new(),457 retained: false,458 })459 }460461 /// What a level press publishes, retained. A key that names no level, and462 /// a unit with no sinks, publish nothing. The level steps from the last463 /// message the topic delivered, or from unity before any message arrives.464 fn level(&self, press: &press::Press) -> Option<Publish> {465 if self.volume_topic.is_empty() {466 return None;467 }468 let next = keys::level(press, self.volume.unwrap_or_default())?;469 Some(Publish {470 topic: self.volume_topic.clone(),471 payload: next.payload(),472 retained: true,473 })474 }475476 /// Add one fold's shade moment. A wake also states the on desire, which477 /// is what lifts the override. No moment is the ordinary case of a fold478 /// that changed no state, and it adds nothing.479 fn shade(&mut self, moment: Option<Moment>, effects: &mut Vec<Effect>) {480 let Some(moment) = moment else {481 return;482 };483 let wake = moment == Moment::Wake;484 effects.push(Effect::Moment(moment));485 if wake {486 self.desire(panel::ON, effects);487 }488 }489490 /// Hold the new desire and publish it. An unchanged desire publishes491 /// nothing, because the broker holds the last one.492 fn desire(&mut self, desire: &'static str, effects: &mut Vec<Effect>) {493 if self.desire == desire {494 return;495 }496 self.desire = desire;497 self.publish_desire(effects);498 }499500 /// The desire this client holds now, retained, so the operator reads the501 /// current one the moment it subscribes. A `Player` with no panel topic502 /// states no desire.503 fn publish_desire(&self, effects: &mut Vec<Effect>) {504 if self.panel_topic.is_empty() {505 return;506 }507 effects.push(Effect::Publish(Publish {508 topic: self.panel_topic.clone(),509 payload: panel::Desire {510 desire: self.desire,511 }512 .payload(),513 retained: true,514 }));515 }516517 /// Restart the armed window from now. The quiet window runs only while518 /// the screen is awake, the unit plays nothing, and the policy is above519 /// zero; every other state leaves it disarmed. The off window runs from520 /// the moment the shade came down, so the two windows measure one quiet521 /// stretch, and it arms only while the desire is still on.522 fn rearm(&mut self, now: Instant) {523 self.deadline = None;524 if !self.idle {525 return;526 }527 if self.asleep {528 if self.off_after.is_zero() || self.desire != panel::ON {529 return;530 }531 self.deadline = Some((now + (self.off_after - self.fade_after), Window::Off));532 return;533 }534 if self.fade_after.is_zero() {535 return;536 }537 self.deadline = Some((now + self.fade_after, Window::Quiet));538 }539540 /// Whether this controller's mark names this `Player` right now.541 fn holds_focus(&self, index: usize) -> bool {542 self.names_this_player(&self.marks[index].player)543 }544545 /// Compare one mark against the `Player`'s own name. A client that read546 /// no name matches no mark and answers no press.547 fn names_this_player(&self, mark: &str) -> bool {548 !self.player_name.is_empty() && mark == self.player_name549 }550551 /// Which controller a topic belongs to, by a scan over the unit's own552 /// controllers. An empty topic names none, so a controller the operator553 /// gave no focus topic matches nothing.554 fn remote_for(&self, topic: &str, of: impl Fn(&Remote) -> &String) -> Option<usize> {555 if topic.is_empty() {556 return None;557 }558 self.remotes.iter().position(|remote| of(remote) == topic)559 }560}561562#[cfg(test)]563mod tests;
media-screen/src/screen/keys.rs 100.0%
1// This crate's table from key names to what they do while nothing plays,2// beside the playback pod's table in `media-operator`'s `keybindings.go`. The3// two tables share the level keys and the cycle key. They part on navigation:4// the navigation keys reach the client that drew the list, and the playback5// pod's reach a display script.6//7// The crate holds a table rather than passing every key through,8// because three kinds of key are its own: the cycle key it answers for9// the operator, and the two level keys and mute, which step a state no10// client draws a list for. Every other key a screen client acts on is a11// navigation key, and the client binds it.1213use super::press::Press;14use crate::volume::{STEP, Volume};1516/// The key that asks the operator to move the focus mark to the next unit. It17/// is the same name during a film and between films.18pub const CYCLE: &str = "KEY_CYCLEWINDOWS";1920/// The navigation keys a client answers: the arrows, the select synonyms, and21/// the back synonyms. Back is one of them, so this crate never sleeps the22/// screen on a press. Only the client knows whether back has anywhere to go,23/// and the client asks for the shade with [`super::Screen::sleep`].24pub const NAVIGATION: [&str; 11] = [25 "KEY_UP",26 "KEY_DOWN",27 "KEY_LEFT",28 "KEY_RIGHT",29 "KEY_ENTER",30 "KEY_OK",31 "KEY_SELECT",32 "KEY_KPENTER",33 "KEY_BACK",34 "KEY_ESC",35 "KEY_EXIT",36];3738/// The three back synonyms among the navigation keys. A shell sends whichever39/// one it was built with, so a client that sleeps on back reads all three.40pub const BACK: [&str; 3] = ["KEY_BACK", "KEY_ESC", "KEY_EXIT"];4142/// One kernel key name as the navigation key it is, and nothing for a key43/// this crate hands no client. The answer is the table's own name, so the44/// client reads the kernel's name for the control and holds its own table.45pub fn navigation(key: &str) -> Option<&'static str> {46 NAVIGATION.into_iter().find(|name| *name == key)47}4849/// Whether one kernel key name is a back synonym.50pub fn back(key: &str) -> bool {51 BACK.contains(&key)52}5354/// What one press means for the level, and nothing for a key that names no55/// level. The two steps act on the press and on the repeat, because a person56/// ramps a level by holding the key. Mute acts on the press alone, because a57/// held mute that toggled on every repeat would flip the flag back and forth58/// under the hand.59pub fn level(press: &Press, held: Volume) -> Option<Volume> {60 match press.key.as_str() {61 "KEY_VOLUMEUP" => Some(held.stepped(STEP)),62 "KEY_VOLUMEDOWN" => Some(held.stepped(-STEP)),63 "KEY_MUTE" if press.down() => Some(held.toggled()),64 _ => None,65 }66}6768#[cfg(test)]69mod tests {70 use super::*;7172 fn press(key: &str, value: i64) -> Press {73 Press {74 key: key.into(),75 value,76 }77 }7879 #[test]80 fn every_navigation_key_reaches_the_client_under_its_own_name() {81 for key in NAVIGATION {82 assert_eq!(navigation(key), Some(key));83 }84 }8586 #[test]87 fn a_key_this_crate_hands_no_client_is_no_navigation_key() {88 assert_eq!(navigation("KEY_PLAYPAUSE"), None);89 assert_eq!(navigation("KEY_VOLUMEUP"), None);90 assert_eq!(navigation(CYCLE), None);91 assert_eq!(navigation(""), None);92 }9394 #[test]95 fn the_three_back_synonyms_are_navigation_keys_too() {96 for key in BACK {97 assert!(back(key));98 assert_eq!(navigation(key), Some(key));99 }100 assert!(!back("KEY_UP"));101 }102103 #[test]104 fn the_level_keys_step_by_five_on_the_press_and_the_repeat() {105 let held = Volume {106 level: 40,107 muted: false,108 };109 for value in [1, 2] {110 assert_eq!(111 level(&press("KEY_VOLUMEUP", value), held),112 Some(Volume {113 level: 45,114 muted: false115 })116 );117 assert_eq!(118 level(&press("KEY_VOLUMEDOWN", value), held),119 Some(Volume {120 level: 35,121 muted: false122 })123 );124 }125 }126127 #[test]128 fn mute_toggles_on_the_press_alone() {129 let held = Volume::default();130 assert_eq!(131 level(&press("KEY_MUTE", 1), held),132 Some(Volume {133 level: 100,134 muted: true135 })136 );137 assert_eq!(level(&press("KEY_MUTE", 2), held), None);138 }139140 #[test]141 fn a_key_that_names_no_level_is_no_level_press() {142 assert_eq!(level(&press("KEY_UP", 1), Volume::default()), None);143 assert_eq!(level(&press(CYCLE, 1), Volume::default()), None);144 }145}
media-screen/src/screen/press.rs 100.0%
1// One press as the standing remote pod publishes it, on a controller's2// events topic. The name is the kernel's, so a consumer holds no table of3// numbers, and this crate passes the name through to its client unchanged.45use serde::Deserialize;67/// One key event. `value` is the kernel's: 0 release, 1 press, 2 autorepeat.8#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]9pub struct Press {10 pub key: String,11 pub value: i64,12}1314impl Press {15 /// Whether this event is a control held down, the press or the repeat.16 /// The release is excluded because only a down edge is a person's act,17 /// and a release that counted would wake the screen its own press just18 /// put to sleep.19 pub fn edge(&self) -> bool {20 self.value == 1 || self.value == 221 }2223 /// Whether this event is the control going down, which is the edge mute24 /// acts on and the cycle key asks on.25 pub fn down(&self) -> bool {26 self.value == 127 }28}2930/// Read one event off a controller's events topic. A payload that is not an31/// object, and one that names no key or no value, are no press at all. Both32/// fields are required, so a message from another writer on this topic33/// changes nothing rather than reading as a release of an unnamed control.34pub fn parse(payload: &[u8]) -> Option<Press> {35 crate::object(payload)36}3738#[cfg(test)]39mod tests {40 use super::*;4142 #[test]43 fn a_press_carries_the_kernels_name_and_the_kernels_value() {44 assert_eq!(45 parse(br#"{"key":"KEY_UP","value":1}"#),46 Some(Press {47 key: "KEY_UP".into(),48 value: 149 })50 );51 }5253 #[test]54 fn the_press_and_the_repeat_are_the_control_held_down() {55 assert!(56 parse(br#"{"key":"KEY_UP","value":1}"#)57 .expect("it decodes")58 .edge()59 );60 assert!(61 parse(br#"{"key":"KEY_UP","value":2}"#)62 .expect("it decodes")63 .edge()64 );65 assert!(66 !parse(br#"{"key":"KEY_UP","value":0}"#)67 .expect("it decodes")68 .edge()69 );70 }7172 #[test]73 fn only_value_one_is_the_control_going_down() {74 assert!(75 parse(br#"{"key":"KEY_MUTE","value":1}"#)76 .expect("it decodes")77 .down()78 );79 assert!(80 !parse(br#"{"key":"KEY_MUTE","value":2}"#)81 .expect("it decodes")82 .down()83 );84 }8586 #[test]87 fn text_that_does_not_parse_is_no_press() {88 assert_eq!(parse(b""), None);89 assert_eq!(parse(b"not json"), None);90 assert_eq!(parse(b"KEY_UP"), None);91 assert_eq!(parse(br#"["KEY_UP",1]"#), None);92 }9394 #[test]95 fn a_message_that_names_no_key_or_no_value_is_no_press() {96 assert_eq!(parse(br#"{"key":"KEY_UP"}"#), None);97 assert_eq!(parse(br#"{"value":1}"#), None);98 assert_eq!(parse(br#"{"key":3,"value":1}"#), None);99 assert_eq!(parse(br#"{"key":"KEY_UP","value":"1"}"#), None);100 assert_eq!(parse(br#"{"action":"re-present"}"#), None);101 }102}
media-screen/src/status.rs 100.0%
1// The `Player`'s retained status: one message that carries the whole of what2// the operator says about a unit. The operator folds the Kubernetes objects3// into it, so a client resolves nothing and reads one payload.4//5// `media-operator` writes it in `playerstatus.go`.67use serde::Deserialize;89/// The status as it travels on the topic.10#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]11#[serde(rename_all = "camelCase")]12pub struct Status {13 /// The unit's friendly name. It replaces the whole identity block, so an14 /// edit to a `Player` shows with no pod restart.15 #[serde(default)]16 pub display_name: String,17 #[serde(default)]18 pub activity: Activity,19 /// The `Play` that runs or starts on the unit. A unit at rest names none.20 #[serde(default)]21 pub play: Option<Play>,22 #[serde(default)]23 pub components: Vec<Component>,24}2526/// What the unit is doing. The three words are the operator's own, and the27/// screen draws a different motion for each: `Starting` ramps the mark up28/// while a person waits, `Playing` stops it because a film covers the surface,29/// and `Idle` eases it back to rest.30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]31pub enum Activity {32 #[default]33 Idle,34 Starting,35 Playing,36}3738impl Activity {39 /// The activity one word names. A word this client does not name reads as40 /// `Idle`, which draws the mark at rest and no activity line, because41 /// every comparison downstream is against `Starting` and `Playing` alone.42 pub fn from_word(word: &str) -> Self {43 match word {44 "Starting" => Self::Starting,45 "Playing" => Self::Playing,46 _ => Self::Idle,47 }48 }49}5051impl<'de> Deserialize<'de> for Activity {52 fn deserialize<D: serde::Deserializer<'de>>(source: D) -> Result<Self, D::Error> {53 Ok(Self::from_word(&String::deserialize(source)?))54 }55}5657/// The `Play` on the unit. `name` is the object a person finds with `kubectl`,58/// and `title` is the one line the screen draws.59#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]60pub struct Play {61 #[serde(default)]62 pub name: String,63 #[serde(default)]64 pub title: String,65}6667/// One part of the unit: a screen, a set of speakers, or a controller.68#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]69pub struct Component {70 /// A part that carries no name defaults to an empty one, and the71 /// identity block drops it, because one unnamed part must not cost72 /// the whole retained status and leave the screen on stale state.73 #[serde(default)]74 pub name: String,75 /// `display`, `sink`, or `remote`. It is the screen's whole vocabulary for76 /// a part, and it says what to draw rather than which `DeviceClass` the77 /// part came from.78 #[serde(default)]79 pub kind: String,80 /// The presence of a part that has any. A wired screen and its speakers81 /// report none and carry no key, and the screen draws them at full82 /// brightness always, because a part that cannot be absent must not read83 /// as present-for-now.84 #[serde(default)]85 pub connected: Option<bool>,86 /// The charge the part reports, from 0 to 100. A part that runs on no87 /// battery, and one whose device reports no level, carries no key, and the88 /// screen draws the name alone.89 #[serde(default)]90 pub battery: Option<i64>,91 /// True on the one controller whose focus mark names this unit. It appears92 /// nowhere else, so exactly one unit draws the marker for a controller that93 /// several units list.94 #[serde(default)]95 pub focused: Option<bool>,96}9798impl Component {99 /// The kind name of a controller, the one part that carries presence and100 /// focus. A focus moment counts the controllers in the order the status101 /// lists them, which is the `Player`'s `spec.remotes` order.102 pub const REMOTE: &'static str = "remote";103}104105/// Read one message off the status topic. A payload that does not decode is no106/// status at all and changes nothing, because a half-read status on a screen is107/// worse than the last good one.108pub fn parse(payload: &[u8]) -> Option<Status> {109 crate::object(payload)110}111112#[cfg(test)]113mod tests {114 use super::*;115116 #[test]117 fn the_whole_status_decodes() {118 let status = parse(119 br#"{"displayName":"The Den","activity":"Starting",120 "play":{"name":"den-tv-1","title":"A Film"},121 "components":[122 {"name":"The screen","kind":"display"},123 {"name":"A remote","kind":"remote","connected":true,124 "battery":62,"focused":true}]}"#,125 )126 .expect("the status decodes");127128 assert_eq!(status.display_name, "The Den");129 assert_eq!(status.activity, Activity::Starting);130 assert_eq!(131 status.play.as_ref().map(|play| play.title.as_str()),132 Some("A Film")133 );134 assert_eq!(status.components[0].kind, "display");135 assert_eq!(status.components[0].connected, None);136 assert_eq!(status.components[1].connected, Some(true));137 assert_eq!(status.components[0].battery, None);138 assert_eq!(status.components[1].battery, Some(62));139 assert_eq!(status.components[1].focused, Some(true));140 }141142 #[test]143 fn a_unit_at_rest_names_no_play_and_no_parts() {144 let status = parse(br#"{"displayName":"The Den","activity":"Idle"}"#).expect("it decodes");145 assert_eq!(status.activity, Activity::Idle);146 assert_eq!(status.play, None);147 assert!(status.components.is_empty());148 }149150 #[test]151 fn a_word_this_client_does_not_name_is_idle() {152 assert_eq!(Activity::from_word("Playing"), Activity::Playing);153 assert_eq!(Activity::from_word("Buffering"), Activity::Idle);154 assert_eq!(Activity::from_word(""), Activity::Idle);155 }156157 #[test]158 fn text_that_does_not_parse_is_no_status() {159 assert_eq!(parse(b""), None);160 assert_eq!(parse(b"{"), None);161 assert_eq!(parse(b"[]"), None);162 assert_eq!(parse(br#"["The Den","Idle",null,[]]"#), None);163 }164165 #[test]166 fn a_part_with_no_name_leaves_the_rest_of_the_status_readable() {167 let status = parse(168 br#"{"displayName":"The Den","components":[169 {"kind":"remote"},{"name":"A remote","kind":"remote"}]}"#,170 )171 .expect("the status decodes");172173 assert_eq!(status.display_name, "The Den");174 assert_eq!(status.components[0].name, "");175 assert_eq!(status.components[1].name, "A remote");176 }177}
media-screen/src/volume.rs 100.0%
1// The listening level as `Player` state. One retained topic carries it, every2// pod for the unit follows that topic, and `media-operator` writes it in3// `volume.go`.45use serde::{Deserialize, Serialize};67/// The bottom of the range, and unity. The level runs 0 to 100, and 100 is8/// unity. The cap is unity because the sinks play at unity on the audio side,9/// and a software gain above unity only distorts.10pub const MIN_LEVEL: i64 = 0;11pub const UNITY_LEVEL: i64 = 100;1213/// The whole payload on the volume topic. The operator writes both fields on14/// every message, and each one still defaults here, so a partial payload from15/// another writer reads as the zero value instead of failing to decode.16#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]17pub struct Volume {18 #[serde(default)]19 pub level: i64,20 #[serde(default)]21 pub muted: bool,22}2324impl Default for Volume {25 /// The state the client holds before any message reaches it, and the value26 /// the operator seeds a unit at.27 fn default() -> Self {28 Self {29 level: UNITY_LEVEL,30 muted: false,31 }32 }33}3435/// The amount one press moves the level by. It is this consumer's default,36/// the same five the playback pod steps by, so a level moves the same amount37/// whether or not a film plays.38pub const STEP: i64 = 5;3940impl Volume {41 /// The level held inside 0 to 100. A level outside the range is clamped42 /// rather than refused, so another program's message cannot draw a row past43 /// unity.44 pub fn clamped(self) -> Self {45 Self {46 level: self.level.clamp(MIN_LEVEL, UNITY_LEVEL),47 ..self48 }49 }5051 /// The state one level press leaves. It steps from the last message the52 /// topic delivered, so two pods that press against the same message53 /// compute the same absolute value and the step lands once.54 pub fn stepped(self, by: i64) -> Self {55 Self {56 level: self.level + by,57 ..self58 }59 .clamped()60 }6162 /// The state one mute press leaves. Mute is a plain toggle, so the flag63 /// survives into the next `Play` and the indicator's glyph is what says64 /// so.65 pub fn toggled(self) -> Self {66 Self {67 muted: !self.muted,68 ..self69 }70 .clamped()71 }7273 /// The state as it travels on the topic. The clamp runs here too, so74 /// nothing this crate publishes is out of range.75 pub fn payload(self) -> Vec<u8> {76 // A level and a flag always encode, so the error is the interface's77 // and not a state this code reaches.78 serde_json::to_vec(&self.clamped()).unwrap_or_default()79 }80}8182/// Read one message off the volume topic. A payload that does not decode is no83/// state at all and changes nothing.84pub fn parse(payload: &[u8]) -> Option<Volume> {85 crate::object::<Volume>(payload).map(Volume::clamped)86}8788#[cfg(test)]89mod tests {90 use super::*;9192 #[test]93 fn the_state_decodes() {94 assert_eq!(95 parse(br#"{"level":40,"muted":true}"#),96 Some(Volume {97 level: 40,98 muted: true99 })100 );101 }102103 #[test]104 fn a_level_outside_the_range_is_clamped() {105 assert_eq!(106 parse(br#"{"level":400,"muted":false}"#).map(|v| v.level),107 Some(100)108 );109 assert_eq!(110 parse(br#"{"level":-9,"muted":false}"#).map(|v| v.level),111 Some(0)112 );113 }114115 #[test]116 fn a_field_the_message_omits_reads_as_its_zero_value() {117 assert_eq!(118 parse(br#"{"level":40}"#),119 Some(Volume {120 level: 40,121 muted: false122 })123 );124 assert_eq!(parse(br#"{}"#).map(|state| state.level), Some(0));125 }126127 #[test]128 fn text_that_does_not_parse_is_no_state() {129 assert_eq!(parse(b""), None);130 assert_eq!(parse(b"40"), None);131 assert_eq!(parse(b"[40,true]"), None);132 assert_eq!(parse(br#"{"level":"loud"}"#), None);133 }134135 #[test]136 fn a_client_with_no_message_holds_unity_unmuted() {137 assert_eq!(Volume::default().level, UNITY_LEVEL);138 assert!(!Volume::default().muted);139 }140 #[test]141 fn a_level_press_steps_from_the_last_message() {142 let held = Volume {143 level: 40,144 muted: false,145 };146 assert_eq!(held.stepped(STEP).level, 45);147 assert_eq!(held.stepped(-STEP).level, 35);148 }149150 #[test]151 fn a_step_never_leaves_the_range() {152 assert_eq!(153 Volume {154 level: 98,155 muted: false156 }157 .stepped(STEP)158 .level,159 UNITY_LEVEL160 );161 assert_eq!(162 Volume {163 level: 2,164 muted: false165 }166 .stepped(-STEP)167 .level,168 MIN_LEVEL169 );170 }171172 #[test]173 fn a_mute_press_toggles_the_flag_and_holds_the_level() {174 let muted = Volume {175 level: 40,176 muted: false,177 }178 .toggled();179 assert_eq!(180 muted,181 Volume {182 level: 40,183 muted: true184 }185 );186 assert!(!muted.toggled().muted);187 }188189 #[test]190 fn the_payload_is_the_whole_state() {191 assert_eq!(192 Volume {193 level: 45,194 muted: false195 }196 .payload(),197 br#"{"level":45,"muted":false}"#198 );199 assert_eq!(200 Volume {201 level: 400,202 muted: true203 }204 .payload(),205 br#"{"level":100,"muted":true}"#206 );207 }208}
media-screen/src/wiring.rs 100.0%
1// The variables the operator sets on a screen client's container, and what2// this crate reads from each one. `media-operator` names them in `wire.go` on3// the writing side, so the two files are one contract and a name here matches4// a name there.5//6// The operator passes all of this down, because a pod cannot read the7// address of the broker in front of it or the topic base a cluster8// chose. A delegate's operator reads the same facts off `status.idle`9// on the `Player` and sets the same variables, so one contract serves10// the client this project ships and any other.1112use std::time::Duration;1314/// The broker's address, written `host:port`.15pub const BUS_ADDRESS: &str = "MEDIA_BUS_ADDRESS";1617/// The `Player`'s own object name, the value every focus mark holds. It is18/// not the friendly name `IDLE_PLAYER_NAME` carries, because the operator19/// writes marks from `metadata.name`. A client that reads no name matches no20/// mark and answers no press.21pub const PLAYER_NAME: &str = "MEDIA_PLAYER_NAME";2223/// The `Player`'s retained status topic. It carries the display name, the24/// activity, the current `Play`, and the parts.25pub const STATUS_TOPIC: &str = "MEDIA_PLAYER_STATUS_TOPIC";2627/// The `Player`'s retained volume topic. The operator sets it only for a unit28/// that states sinks, so an empty value is the speaker gate: the client29/// subscribes to no level, draws no volume row, and answers no volume press.30pub const VOLUME_TOPIC: &str = "MEDIA_PLAYER_VOLUME_TOPIC";3132/// The `Player`'s commands topic. It carries the operator's33/// `re-present` and nothing else: the presses reach a client on the34/// controllers' own topics, and a client brings its own shade down in35/// its own process.36pub const COMMANDS_TOPIC: &str = "MEDIA_PLAYER_COMMANDS_TOPIC";3738/// The topic the client states the panel desire on. The operator builds it39/// whole, the way it builds the commands and status topics, so the client40/// parses no topic.41pub const PANEL_TOPIC: &str = "MEDIA_PLAYER_PANEL_TOPIC";4243/// The two lists of the unit's controllers, newline-joined and aligned by44/// position: each controller's events topic and the focus topic that carries45/// its mark. They are the same two variables the playback pod's command46/// sidecar reads. A line's number is the controller's place in `spec.remotes`,47/// which is the index a focus moment carries, so a blank focus line leaves48/// that controller with no focus topic rather than shifting the pairing.49pub const REMOTE_EVENTS_TOPICS: &str = "MEDIA_REMOTE_EVENTS_TOPICS";50pub const REMOTE_FOCUS_TOPICS: &str = "MEDIA_REMOTE_FOCUS_TOPICS";5152/// The quiet window in seconds, where zero means the screen never fades on53/// its own, and the off window in seconds, where zero leaves the panel lit.54/// The operator settles both for every `Player`, so an unset or unreadable55/// value fades nothing and darkens nothing rather than guessing a window a56/// cluster never asked for.57pub const FADE_AFTER_SECONDS: &str = "IDLE_FADE_AFTER_SECONDS";58pub const OFF_AFTER_SECONDS: &str = "IDLE_OFF_AFTER_SECONDS";5960/// One of the unit's controllers as a client reads it: the topic its presses61/// arrive on, and the topic its focus mark stands on. A controller with no62/// focus topic carries an empty one, and a press from it reaches nothing,63/// because the mark is the whole gate.64#[derive(Debug, Clone, Default, PartialEq, Eq)]65pub struct Remote {66 pub events: String,67 pub focus: String,68}6970/// Everything the operator told this container.71#[derive(Debug, Clone, Default, PartialEq, Eq)]72pub struct Wiring {73 pub bus_address: String,74 pub player_name: String,75 pub status_topic: String,76 pub volume_topic: String,77 pub commands_topic: String,78 pub panel_topic: String,79 /// The controllers in `spec.remotes` order.80 pub remotes: Vec<Remote>,81 /// The quiet window. Zero never arms the timer.82 pub fade_after: Duration,83 /// The off window, clamped to at least the fade, so the panel never goes84 /// dark behind a still-lit image. Zero leaves the desire at on forever.85 pub off_after: Duration,86}8788impl Wiring {89 /// What this process runs under.90 pub fn from_environment() -> Self {91 Self::read(|name| std::env::var(name).ok())92 }9394 /// The same read against any source of values. The environment is global to95 /// a process, so a test states the variables here instead of setting them96 /// and racing every other test in the binary.97 pub fn read(value: impl Fn(&str) -> Option<String>) -> Self {98 let read = |name| value(name).unwrap_or_default();99100 let fade_after = seconds(&read(FADE_AFTER_SECONDS));101 let off_after = seconds(&read(OFF_AFTER_SECONDS));102 Self {103 bus_address: read(BUS_ADDRESS),104 player_name: read(PLAYER_NAME),105 status_topic: read(STATUS_TOPIC),106 volume_topic: read(VOLUME_TOPIC),107 commands_topic: read(COMMANDS_TOPIC),108 panel_topic: read(PANEL_TOPIC),109 remotes: remotes(&read(REMOTE_EVENTS_TOPICS), &read(REMOTE_FOCUS_TOPICS)),110 fade_after,111 // A window of zero is the panel never going dark, whatever the112 // fade is, so the clamp runs only on a window a cluster stated.113 off_after: match off_after.is_zero() {114 true => Duration::ZERO,115 false => off_after.max(fade_after),116 },117 }118 }119}120121/// Pair each controller's events topic with the focus topic on the same line122/// of the second list. A blank line is kept, because the two lists stay123/// aligned by position and the line number is the controller's index.124fn remotes(events: &str, focuses: &str) -> Vec<Remote> {125 let focuses = lines(focuses);126 lines(events)127 .into_iter()128 .enumerate()129 .map(|(index, events)| Remote {130 events,131 focus: focuses.get(index).cloned().unwrap_or_default(),132 })133 .collect()134}135136/// One of the newline-joined lists the operator sets on a container. An unset137/// variable is no lines at all, not one empty line.138fn lines(text: &str) -> Vec<String> {139 if text.is_empty() {140 return Vec::new();141 }142 text.split('\n').map(str::to_string).collect()143}144145/// One window, in seconds. Anything but a positive whole number is no window146/// at all, because the operator settles this field for every `Player` and a147/// guessed default here would dim a screen the cluster never asked to dim.148fn seconds(text: &str) -> Duration {149 match text.trim().parse::<i64>() {150 Ok(seconds) if seconds > 0 => Duration::from_secs(seconds as u64),151 _ => Duration::ZERO,152 }153}154155#[cfg(test)]156mod tests {157 use super::*;158159 fn wiring(pairs: &[(&str, &str)]) -> Wiring {160 let pairs: Vec<(String, String)> = pairs161 .iter()162 .map(|(name, value)| (name.to_string(), value.to_string()))163 .collect();164 Wiring::read(|name| {165 pairs166 .iter()167 .find(|(set, _)| set == name)168 .map(|(_, value)| value.clone())169 })170 }171172 #[test]173 fn a_process_reads_its_own_environment() {174 // The gates run this binary with none of these variables set, so what175 // the read returns is the empty wiring. A client under that wiring176 // opens no reader and draws its seeds.177 assert_eq!(Wiring::from_environment(), Wiring::default());178 }179180 #[test]181 fn an_empty_environment_wires_nothing() {182 assert_eq!(Wiring::read(|_| None), Wiring::default());183 }184185 #[test]186 fn every_variable_lands_in_the_wiring() {187 let read = wiring(&[188 (BUS_ADDRESS, "broker:1883"),189 (PLAYER_NAME, "den-tv"),190 (STATUS_TOPIC, "media/players/den/tv/status"),191 (VOLUME_TOPIC, "media/players/den/tv/volume"),192 (COMMANDS_TOPIC, "media/players/den/tv/commands"),193 (PANEL_TOPIC, "media/players/den/tv/panel"),194 (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),195 (REMOTE_FOCUS_TOPICS, "focus/sofa\nfocus/armchair"),196 (FADE_AFTER_SECONDS, "600"),197 (OFF_AFTER_SECONDS, "1800"),198 ]);199200 assert_eq!(read.bus_address, "broker:1883");201 assert_eq!(read.player_name, "den-tv");202 assert_eq!(read.status_topic, "media/players/den/tv/status");203 assert_eq!(read.volume_topic, "media/players/den/tv/volume");204 assert_eq!(read.commands_topic, "media/players/den/tv/commands");205 assert_eq!(read.panel_topic, "media/players/den/tv/panel");206 assert_eq!(read.fade_after, Duration::from_secs(600));207 assert_eq!(read.off_after, Duration::from_secs(1800));208 }209210 #[test]211 fn the_two_remote_lists_pair_by_position() {212 let read = wiring(&[213 (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),214 (REMOTE_FOCUS_TOPICS, "focus/sofa\nfocus/armchair"),215 ]);216217 assert_eq!(218 read.remotes,219 [220 Remote {221 events: "events/sofa".into(),222 focus: "focus/sofa".into()223 },224 Remote {225 events: "events/armchair".into(),226 focus: "focus/armchair".into()227 },228 ]229 );230 }231232 #[test]233 fn a_missing_focus_topic_leaves_that_controller_blank_and_shifts_nothing() {234 let read = wiring(&[235 (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),236 (REMOTE_FOCUS_TOPICS, "\nfocus/armchair"),237 ]);238239 assert_eq!(read.remotes[0].focus, "");240 assert_eq!(read.remotes[1].focus, "focus/armchair");241 }242243 #[test]244 fn a_focus_list_shorter_than_the_events_list_shifts_nothing() {245 let read = wiring(&[246 (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),247 (REMOTE_FOCUS_TOPICS, "focus/sofa"),248 ]);249250 assert_eq!(read.remotes[0].focus, "focus/sofa");251 assert_eq!(read.remotes[1].focus, "");252 }253254 #[test]255 fn a_unit_with_no_controllers_lists_none() {256 assert!(lines("").is_empty());257 assert_eq!(lines("first"), ["first"]);258 assert_eq!(lines("first\n"), ["first", ""]);259 assert_eq!(lines("first\nsecond"), ["first", "second"]);260 }261262 #[test]263 fn the_quiet_window_reads_the_seconds() {264 assert_eq!(seconds("600"), Duration::from_secs(600));265 assert_eq!(seconds(" 60 "), Duration::from_secs(60));266 }267268 #[test]269 fn anything_but_a_positive_window_is_no_window() {270 assert_eq!(seconds("0"), Duration::ZERO);271 assert_eq!(seconds(""), Duration::ZERO);272 assert_eq!(seconds("soon"), Duration::ZERO);273 assert_eq!(seconds("-5"), Duration::ZERO);274 }275276 #[test]277 fn the_off_window_never_lands_before_the_fade() {278 let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "60")]);279 assert_eq!(read.off_after, Duration::from_secs(600));280281 let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "600")]);282 assert_eq!(read.off_after, Duration::from_secs(600));283 }284285 #[test]286 fn an_off_window_of_zero_leaves_the_panel_lit_whatever_the_fade_is() {287 let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "0")]);288 assert_eq!(read.off_after, Duration::ZERO);289290 let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "soon")]);291 assert_eq!(read.off_after, Duration::ZERO);292 }293}
Rust (idle-screen)
3328 of 3492 lines, 95.3%.
idle/src/client.rs 99.2%
1// The client the idle pod runs. It reads the unit's state off the bus, holds2// it, and draws the idle screen.3//4// The screen draws the ground and nothing over it. What the client holds is5// `Unit`, and every element reads its facts and its seconds from there.67use std::convert::Infallible;89use iced_wgpu::Renderer;10use iced_widget::Canvas;11use iced_winit::core::{Color, Element, Length, Theme};1213use media_screen::status::Activity;14use media_screen::{Bus, Moment, Reader, reader};1516use crate::harness::Screen;17use crate::idle::Idle;18use crate::idle::preview::Keys;19use crate::look;20use crate::unit::Unit;21use crate::wiring::Wiring;2223/// The name this client connects to the broker under, before the machine's24/// own name is appended. A broker closes the older connection when two arrive25/// under one identifier, so no two clients on one machine may share it.26const CLIENT: &str = "idle-screen";2728#[derive(Debug)]29pub struct Client {30 unit: Unit,31 /// The subscription, or nothing when the operator named no broker. A run on32 /// a workstation draws the seeds alone.33 ///34 /// It is the trait and not the reader itself, so a test folds real35 /// moments and reads a real request for the shade with no socket36 /// under it.37 bus: Option<Box<dyn Bus>>,38 /// Whether a `present` asked for a fresh Wayland surface. The harness39 /// reads it on every wake of the loop.40 surface_due: bool,41 /// The preview keys, on a run that binds them. They stand in for the bus42 /// on a workstation, and the legend draws where they are bound.43 keys: Option<Keys>,44 /// The second of the frame being drawn. The view is a function of it, so45 /// the tick records it and every element reads it.46 at: f64,47}4849impl Client {50 /// The client one wiring describes. The binary reads the environment once51 /// and hands the whole of it here, so this file names no variable. The52 /// seeds name the unit before the broker answers, so the first frame is53 /// never blank.54 pub fn open(wiring: Wiring) -> Self {55 let client_id = reader::client_id(CLIENT, &reader::hostname());56 let keys = wiring57 .preview58 .then(|| Keys::seeded(wiring.player_name.clone(), wiring.components.clone()));59 let bus = Reader::open(&wiring.screen, &client_id);60 Self {61 unit: Unit::seeded(wiring.player_name, wiring.components),62 bus: bus.map(|reader| Box::new(reader) as Box<dyn Bus>),63 surface_due: false,64 keys,65 at: 0.0,66 }67 }6869 /// The unit as the screen holds it.70 pub fn unit(&self) -> &Unit {71 &self.unit72 }7374 /// The screen the client draws, at the second the clock last read. The75 /// view and the schedule read one screen, so what the harness sleeps76 /// toward is what the next frame draws.77 fn screen(&self) -> Idle<'_> {78 Idle {79 unit: &self.unit,80 at: self.at,81 preview: self.keys.is_some(),82 }83 }8485 /// Fold one message in, at `at` seconds on the screen's clock. A `present`86 /// asks the harness for a new surface, and every other message reaches the87 /// unit.88 ///89 /// The retained status also asks, on its move to `Idle`. `present` rides a90 /// topic nothing retains, so a broker session that drops while a film ends91 /// loses the moment for good, and the catch-up status still says the92 /// screen is the client's again. The two usually drain in one wake, and93 /// the flag folds them into one map.94 pub fn receive(&mut self, moment: Moment, at: f64) {95 if moment == Moment::Present {96 self.surface_due = true;97 }98 // The stock idle screen draws no list, so the arrows and select99 // reach nothing, and back is the shade. The client decides this100 // and not the crate, because a client with levels sleeps at its101 // top level alone, and only the client reads its levels.102 if let Moment::Press(key) = moment103 && media_screen::screen::keys::back(key)104 && let Some(bus) = &self.bus105 {106 bus.sleep();107 }108 let was = self.unit.activity;109 self.unit.fold(moment, at);110 if self.unit.activity == Activity::Idle && was != Activity::Idle {111 self.surface_due = true;112 }113 }114}115116impl Screen for Client {117 // Nothing on the screen emits a message yet, and the type says so.118 type Message = Infallible;119120 fn background(&self) -> Color {121 look::BACKGROUND122 }123124 /// One key press. A run with no preview keys bound takes none, so a125 /// keyboard attached to a pod changes nothing on a screen in a house.126 ///127 /// A bound key builds the messages the bus would carry and folds them128 /// through the call the bus reader's messages take, so the press exercises129 /// the handlers a cluster exercises.130 fn key(&mut self, name: &str) {131 let Some(keys) = &mut self.keys else {132 return;133 };134 let messages = keys.press(name);135 let at = self.at;136 for message in messages {137 self.receive(message, at);138 }139 }140141 fn tick(&mut self, at: f64) {142 self.at = at;143 }144145 /// Hand the loop's waker to the reader, so a press on a controller shows146 /// on the next frame rather than at the clock's next second.147 fn wake_by(&mut self, wake: media_screen::Waker) {148 if let Some(bus) = &self.bus {149 bus.wake_on_delivery(wake);150 }151 }152153 /// Drain the reader and fold in what arrived. The harness calls this on154 /// every wake of the loop rather than on a frame, because a covered155 /// client draws no frame, and `present` arrives exactly while the client156 /// is covered.157 fn pump(&mut self, at: f64) -> bool {158 let Some(bus) = &self.bus else {159 return false;160 };161 let messages = bus.drain();162 let folded = !messages.is_empty();163 for message in messages {164 self.receive(message, at);165 }166 folded167 }168169 fn surface_due(&mut self) -> bool {170 std::mem::take(&mut self.surface_due)171 }172173 fn surfaced(&mut self, at: f64) {174 self.unit.presented = Some(at);175 }176177 fn view(&self) -> Element<'_, Self::Message, Theme, Renderer> {178 Canvas::new(self.screen())179 .width(Length::Fill)180 .height(Length::Fill)181 .into()182 }183184 /// The second the screen next changes. The elements answer it, and the185 /// bus does not: a delivery wakes the loop itself through the waker, and186 /// the reader drains in `pump` on every wake. The clock's next second is187 /// the backstop bound on each wait, so the broker is still read at least188 /// once a second if the wake ever fails.189 fn next_frame(&self, at: f64) -> Option<f64> {190 self.screen().next_frame(at)191 }192}193194#[cfg(test)]195mod tests {196 use std::sync::Arc;197 use std::sync::atomic::{AtomicUsize, Ordering};198 use std::sync::mpsc;199200 use super::*;201 use media_screen::status::{Activity, Status};202203 /// A bus over a plain channel, standing in for the reader's threads, so a204 /// test folds real moments and reads the client's own request for the205 /// shade with no broker under it. The count of those requests is shared,206 /// because the client owns the bus once it takes it.207 #[derive(Debug)]208 struct Channel {209 moments: mpsc::Receiver<Moment>,210 slept: Arc<AtomicUsize>,211 }212213 impl Bus for Channel {214 fn drain(&self) -> Vec<Moment> {215 self.moments.try_iter().collect()216 }217218 fn sleep(&self) {219 self.slept.fetch_add(1, Ordering::SeqCst);220 }221222 // The idle screen has no topic of its own, so it publishes223 // nothing and this stands in for a call no client of this crate224 // makes here.225 fn publish(&self, _topic: &str, _payload: Vec<u8>, _retained: bool) {}226227 fn wake_on_delivery(&self, _wake: media_screen::Waker) {}228 }229230 /// A client whose bus is that channel, and the two ends a test reads: what231 /// it sends the client, and how often the client asked for the shade.232 fn on_a_channel(client: &mut Client) -> (mpsc::Sender<Moment>, Arc<AtomicUsize>) {233 let (sender, moments) = mpsc::channel();234 let slept = Arc::new(AtomicUsize::new(0));235 client.bus = Some(Box::new(Channel {236 moments,237 slept: Arc::clone(&slept),238 }));239 (sender, slept)240 }241242 fn seeded() -> Client {243 Client::open(Wiring {244 player_name: "The Den".into(),245 components: vec!["The screen".into()],246 ..Wiring::default()247 })248 }249250 #[test]251 fn the_client_draws_on_the_theme_ground() {252 assert_eq!(seeded().background(), look::BACKGROUND);253 }254255 #[test]256 fn a_client_with_no_broker_draws_the_seeds() {257 let mut client = seeded();258 assert_eq!(client.unit().name, "The Den");259 assert_eq!(client.unit().parts.len(), 1);260261 // The clock advances with no reader, and nothing changes.262 client.tick(1.0);263 assert_eq!(client.unit().name, "The Den");264 }265266 #[test]267 fn a_settled_client_still_asks_for_a_frame_every_second() {268 let mut client = seeded();269 client.tick(7.25);270271 // The clock's next second bounds every wait, so a client that named272 // no second would leave the loop with no timer to pump the bus on.273 assert_eq!(client.next_frame(7.25), Some(8.0));274 }275276 #[test]277 fn the_pump_folds_what_the_bus_delivered_and_says_so() {278 let mut client = seeded();279 let (sender, _slept) = on_a_channel(&mut client);280281 assert!(!client.pump(1.0));282283 sender.send(Moment::Present).expect("the channel is open");284285 assert!(client.pump(2.0));286 assert!(client.surface_due());287 }288289 #[test]290 fn a_back_press_asks_the_bus_for_the_shade() {291 let mut client = seeded();292 let (_sender, slept) = on_a_channel(&mut client);293294 for key in media_screen::screen::keys::BACK {295 client.receive(Moment::Press(key), 1.0);296 }297 // The arrows and select reach nothing, because this screen draws no298 // list to move through.299 client.receive(Moment::Press("KEY_UP"), 2.0);300 client.receive(Moment::Press("KEY_ENTER"), 2.0);301302 assert_eq!(303 slept.load(Ordering::SeqCst),304 media_screen::screen::keys::BACK.len()305 );306 }307308 #[test]309 fn a_client_with_no_bus_asks_no_one_for_the_shade() {310 let mut client = seeded();311312 client.receive(Moment::Press("KEY_BACK"), 1.0);313314 assert!(client.bus.is_none());315 }316317 #[test]318 fn the_clock_moves_without_the_bus() {319 let mut client = seeded();320 client.tick(3.5);321 assert_eq!(client.next_frame(3.5), Some(4.0));322 }323324 #[test]325 fn a_status_reaches_the_unit() {326 let mut client = seeded();327 client.receive(328 Moment::Status(Status {329 activity: Activity::Playing,330 ..Status::default()331 }),332 2.0,333 );334 assert_eq!(client.unit().activity, Activity::Playing);335 }336337 #[test]338 fn the_retained_status_heals_a_present_the_client_never_received() {339 let mut client = seeded();340 client.receive(341 Moment::Status(Status {342 activity: Activity::Playing,343 ..Status::default()344 }),345 2.0,346 );347 assert!(!client.surface_due());348349 client.receive(350 Moment::Status(Status {351 activity: Activity::Idle,352 ..Status::default()353 }),354 9.0,355 );356 assert!(client.surface_due());357 }358359 #[test]360 fn a_present_asks_for_one_new_surface() {361 let mut client = seeded();362 assert!(!client.surface_due());363364 client.receive(Moment::Present, 3.0);365366 assert!(client.surface_due());367 assert!(!client.surface_due());368 }369370 #[test]371 fn the_unit_takes_the_second_the_new_surface_went_up() {372 let mut client = seeded();373 client.receive(Moment::Present, 3.0);374 assert_eq!(client.unit().presented, None);375376 client.surfaced(3.2);377378 assert_eq!(client.unit().presented, Some(3.2));379 }380381 fn previewing() -> Client {382 Client::open(Wiring {383 player_name: "Studio Lab".into(),384 components: vec!["Portable Screen".into(), "Studio Dualsense".into()],385 preview: true,386 ..Wiring::default()387 })388 }389390 #[test]391 fn a_run_with_no_preview_keys_takes_no_key() {392 let mut client = seeded();393 let before = client.unit().clone();394395 client.key("p");396397 assert_eq!(client.unit(), &before);398 }399400 #[test]401 fn a_preview_key_reaches_the_unit_through_the_bus_path() {402 let mut client = previewing();403 client.tick(2.0);404405 client.key("p");406407 assert_eq!(client.unit().activity, Activity::Starting);408 assert_eq!(client.unit().title.as_deref(), Some("Sailing"));409 }410411 #[test]412 fn the_film_end_key_returns_the_status_and_asks_for_a_new_surface() {413 let mut client = previewing();414 client.tick(4.0);415 client.key("o");416417 client.key("i");418419 assert_eq!(client.unit().activity, Activity::Idle);420 assert!(client.surface_due());421 }422423 #[test]424 fn the_presence_key_disconnects_the_last_part() {425 let mut client = previewing();426 client.tick(1.0);427428 client.key("d");429430 assert_eq!(client.unit().parts[1].connected, Some(false));431 }432433 #[test]434 fn the_focus_key_marks_the_remote_at_the_second_of_the_press() {435 let mut client = previewing();436 client.tick(5.0);437438 client.key("f");439 assert!(client.unit().parts[1].focused);440 assert_eq!(client.unit().parts[1].marked, Some(5.0));441442 client.key("g");443 assert!(!client.unit().parts[1].focused);444 }445446 #[test]447 fn the_sleep_key_draws_the_shade_down_and_then_up() {448 let mut client = previewing();449 client.tick(6.0);450451 client.key("s");452 assert_eq!(client.unit().shade.map(|shade| shade.down), Some(true));453454 client.key("s");455 assert_eq!(client.unit().shade.map(|shade| shade.down), Some(false));456 }457458 #[test]459 fn a_volume_key_shows_the_indicator_the_way_a_press_shows_it() {460 let mut client = previewing();461 client.tick(7.0);462463 client.key("9");464465 assert_eq!(client.unit().volume.level, 95);466 assert_eq!(client.unit().pressed, Some(7.0));467468 client.key("m");469 assert!(client.unit().volume.muted);470 }471}
idle/src/clock.rs 100.0%
1// The wall clock the screen draws, in the unit's own zone.2//3// Rust's standard library has no time zones, so the reading goes through4// `jiff`. `jiff` reads `TZ` and falls back to `/etc/localtime`, and it resolves5// the name against the image's own `/usr/share/zoneinfo`, which the `tzdata`6// package installs. The operator sets `TZ` on the pod when the household7// states a zone, so the clock shows the household's own hour. A pod with no8// zone reads UTC.910/// A wall-clock reading, to the minute. The screen redraws once a second so the11/// minute turns.12#[derive(Debug, Clone, Copy, PartialEq, Eq)]13pub struct Time {14 pub hour: u8,15 pub minute: u8,16}1718/// The time now, in the zone `TZ` names.19pub fn now() -> Time {20 let now = jiff::Zoned::now();21 Time {22 hour: now.hour() as u8,23 minute: now.minute() as u8,24 }25}2627impl Time {28 /// A twelve-hour clock with no leading zero and a lowercase suffix, as in29 /// "3:01 pm". It is what `display/clock.lua` draws, so the two screens read30 /// the same at the same minute.31 pub fn twelve_hour(self) -> String {32 let suffix = if self.hour < 12 { "am" } else { "pm" };33 let twelve = match self.hour % 12 {34 0 => 12,35 hour => hour,36 };37 format!("{twelve}:{:02} {suffix}", self.minute)38 }39}4041#[cfg(test)]42mod tests {43 use super::*;4445 fn at(hour: u8, minute: u8) -> String {46 Time { hour, minute }.twelve_hour()47 }4849 #[test]50 fn the_afternoon_reads_with_no_leading_zero() {51 assert_eq!(at(15, 1), "3:01 pm");52 assert_eq!(at(13, 45), "1:45 pm");53 }5455 #[test]56 fn the_morning_reads_the_same_way() {57 assert_eq!(at(9, 30), "9:30 am");58 assert_eq!(at(11, 59), "11:59 am");59 }6061 #[test]62 fn both_ends_of_the_day_read_twelve() {63 assert_eq!(at(0, 0), "12:00 am");64 assert_eq!(at(12, 0), "12:00 pm");65 }66}
idle/src/harness.rs 76.4%
1//! The idle screen's harness: the flags, the frame loop, and the2//! measurements.3//!4//! A screen here is a piece of state with a clock and a view. The harness owns5//! everything around it: the winit window, the wgpu surface, the iced renderer,6//! the scripted key timeline, the frame capture, and the statistics file. The7//! last two are the `measure` feature, and the image builds without it.8//!9//! The harness drives its own winit loop instead of calling `iced::application`10//! because it must reach the renderer directly. A frame capture and a frame11//! clock are not part of the high-level entry point.1213pub mod frame;14pub mod graphics;15pub mod options;16pub mod timeline;17pub mod watchdog;1819// The capture and the measurements are the `measure` feature. Nothing a pod20// runs reaches either one, because the operator sets no command on the idle21// container and the image's entrypoint takes no flag, so the image builds22// without them.23#[cfg(feature = "measure")]24pub mod capture;25#[cfg(feature = "measure")]26pub mod stats;2728use iced_wgpu::Renderer;29use iced_wgpu::graphics::Viewport;30use iced_winit::core::{Color, Element, Event, Size, Theme};31use iced_winit::runtime::user_interface;32use iced_winit::winit;33use iced_winit::{Clipboard, conversion};3435use winit::event::WindowEvent;36use winit::event_loop::{ControlFlow, EventLoop};37use winit::keyboard::{Key, ModifiersState, NamedKey};3839#[cfg(feature = "measure")]40use capture::Captures;41use graphics::Graphics;42pub use options::{Invocation, Options};43#[cfg(feature = "measure")]44use stats::Stats;45use timeline::Timeline;46use watchdog::Watchdog;4748/// The key that ends a run, from the keyboard or from a script.49pub const QUIT: &str = "q";5051/// What the harness needs from a screen. The harness advances the clock, hands52/// over each key the script or the keyboard produced, and asks for a view.53pub trait Screen {54 /// The messages the screen's own widgets emit.55 type Message: std::fmt::Debug + Send + 'static;5657 /// The color behind everything. It is the clear color of the frame, so it58 /// also fills a capture.59 fn background(&self) -> Color {60 Color::BLACK61 }6263 /// One key press, named the way the script names it: a single letter or64 /// digit, or `up`, `down`, `left`, `right`.65 fn key(&mut self, name: &str);6667 /// Fold in what the screen's own sources delivered since the last call,68 /// at `at` seconds on the clock. The answer is whether anything folded,69 /// so the harness drops a stale schedule and asks the screen again.70 ///71 /// The harness calls this on every wake of the loop, not only on a frame.72 /// A covered Wayland surface receives no frame callbacks, so a screen73 /// that read its sources only when it drew would go deaf for exactly as74 /// long as something covers it.75 fn pump(&mut self, _at: f64) -> bool {76 false77 }7879 /// Take a handle that wakes the loop from any thread. A screen with a80 /// source of its own hands it to that source, so a delivery wakes the81 /// loop the moment it lands and [`Screen::pump`] folds it in82 /// milliseconds. Without the wake, a message waits for the next83 /// scheduled second, and a person's press shows up to a second late.84 fn wake_by(&mut self, _wake: media_screen::Waker) {}8586 /// Move the screen's clock to `at` seconds since the first frame. Every87 /// animation reads that clock, so a frame is a pure function of it.88 fn tick(&mut self, at: f64);8990 /// The view for the clock's current position.91 fn view(&self) -> Element<'_, Self::Message, Theme, Renderer>;9293 /// The second at which the screen next changes, on the same clock94 /// [`Screen::tick`] reads. `at` is what that clock reads now, at or after95 /// the second of the last frame. The harness sleeps until the second this96 /// answer names, so a screen whose clock draws no seconds redraws once a97 /// minute rather than sixty times a second.98 ///99 /// `None` says nothing on this screen is scheduled, and the loop then100 /// draws on an event alone. A screen that folds in a source of its own,101 /// such as a bus, must not answer `None`.102 ///103 /// The default answers `at`, which is a change on the frame already drawn,104 /// so a screen that states nothing draws every pass the loop takes.105 fn next_frame(&self, at: f64) -> Option<f64> {106 Some(at)107 }108109 /// Handle a message from a widget.110 fn update(&mut self, _message: Self::Message) {}111112 /// Whether the screen asked for a fresh Wayland surface. The harness reads113 /// this on every wake of the loop, and the read clears the request, so one114 /// ask maps one new surface.115 fn surface_due(&mut self) -> bool {116 false117 }118119 /// The new surface is up, on the frame at `at` seconds. Every motion that120 /// starts with the surface reads that second.121 fn surfaced(&mut self, _at: f64) {}122}123124/// Run a screen until the run ends, and write what it measured.125pub fn run<S: Screen + 'static>(mut screen: S, options: Options) -> Result<(), String> {126 // The brand's two faces go into the toolkit's shared font system before127 // anything shapes a run of text, so every line the screen draws is128 // shaped against the face the binary carries and not against a fallback.129 liken_iced::font::load();130131 // Two spans start here. The watchdog's grace runs from this moment, and so132 // does the time to the first frame, which covers the whole life of the133 // process: the wgpu setup, the first window, and the first draw.134 let launched = std::time::Instant::now();135 let watchdog = Watchdog::new(options.window_grace, launched);136137 let event_loop = match EventLoop::new() {138 Ok(event_loop) => event_loop,139 // A client with no connection to a compositor has no window.140 Err(error) => {141 watchdog.expire(&format!("no connection to the compositor: {error}"));142 return Err(error.to_string());143 }144 };145146 // The screen's own sources wake the loop through this proxy, so a bus147 // delivery folds the moment it lands. The event it sends carries148 // nothing: the wake is the message, and `about_to_wait` pumps on it.149 let proxy = event_loop.create_proxy();150 screen.wake_by(std::sync::Arc::new(move || {151 let _ = proxy.send_event(());152 }));153154 let mut app = App {155 watchdog,156 stopped: false,157 state: State::Loading {158 screen: Some(screen),159 options,160 #[cfg(feature = "measure")]161 launched,162 },163 };164165 let outcome = event_loop.run_app(&mut app);166 if app.stopped {167 return outcome.map_err(|error| error.to_string());168 }169170 // The loop ended before the run did. The Wayland connection is the171 // window's one path to the screen, so a loop that ends any other way is a172 // window that went away, whether winit reported an error or not. Nothing173 // inside this process opens the connection again.174 let reason = match outcome {175 Ok(()) => "the compositor stopped answering".to_string(),176 Err(error) => format!("the compositor stopped answering: {error}"),177 };178 app.watchdog.expire(&reason);179 Err(reason)180}181182/// The run, once the compositor has given the process a window.183pub struct Ready<S: Screen> {184 pub(crate) screen: S,185 pub(crate) timeline: Timeline,186 /// The window and everything the frame loop draws through. They arrive187 /// together and they go together, and a `present` replaces two of them at188 /// once, so the run holds the whole of what the compositor gave it.189 pub(crate) graphics: Graphics,190 /// The Wayland app-id every window this run maps asks for, including the191 /// one a `present` maps in place of the first.192 pub(crate) app_id: String,193 pub(crate) viewport: Viewport,194 pub(crate) cache: user_interface::Cache,195 pub(crate) clipboard: Clipboard,196 pub(crate) modifiers: ModifiersState,197 pub(crate) events: Vec<Event>,198 pub(crate) resized: bool,199 /// Whether a fresh Wayland surface is owed. The screen's ask moves here200 /// and stays until a map succeeds, so a compositor that gives no window201 /// on one wake is asked again on the next.202 pub(crate) surface_pending: bool,203 /// The second the screen named for its next change, while the loop sleeps204 /// toward it. The harness holds that second rather than asking again on205 /// every pass, because a fresh answer names the change after it and the206 /// frame would never be drawn.207 pub(crate) scheduled: Option<f64>,208 /// The timeline's zero: the first frame, not the launch. A compositor209 /// can take seconds to give a window, and a script or a capture that210 /// counted from the launch would fire on the first frame, before a211 /// resize arrived and before anything was drawn.212 pub(crate) start: Option<std::time::Instant>,213 /// The second of the last frame. The pace holds the next one at least214 /// [`frame::STEP`] after it, because nothing else caps the rate: the215 /// surface presents without vsync, so an animation that asked for a216 /// frame on every pass would draw as fast as the loop can spin.217 pub(crate) drawn: f64,218 /// Whether the frame on the glass shows old state. A fold and a key both219 /// set it, because the elements schedule their own motion and not the220 /// content: a level that changes while its row stands still would221 /// otherwise wait for the next scheduled second, and a press must show222 /// on the next frame.223 pub(crate) stale: bool,224 /// The frames this run writes to disk, from `--capture`. A run that named225 /// no directory captures nothing.226 #[cfg(feature = "measure")]227 pub(crate) captures: Option<Captures>,228 /// What this run measures, from `--stats`. A run that named no file229 /// measures nothing, and every call on the frame path is then one branch230 /// and no allocation.231 #[cfg(feature = "measure")]232 pub(crate) stats: Option<Stats>,233 pub(crate) finished: bool,234 /// Whether this run asked to leave the loop. The run reads it after the235 /// loop returns, so it tells its own end from a lost window.236 pub(crate) stopped: bool,237}238239/// The run and the one thing that outlives its window: the watchdog, which240/// runs before the first window and after the last one.241struct App<S: Screen> {242 watchdog: Watchdog,243 /// Whether the run ended on its own terms: the quit key, the deadline, the244 /// last capture, or a close the compositor asked for.245 stopped: bool,246 state: State<S>,247}248249enum State<S: Screen> {250 Loading {251 screen: Option<S>,252 options: Options,253 #[cfg(feature = "measure")]254 launched: std::time::Instant,255 },256 Ready(Box<Ready<S>>),257 /// The run is over and the graphics are already gone.258 Done,259}260261impl<S: Screen> winit::application::ApplicationHandler for App<S> {262 fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {263 let App {264 watchdog, state, ..265 } = self;266 let State::Loading {267 screen,268 options,269 #[cfg(feature = "measure")]270 launched,271 } = state272 else {273 return;274 };275 #[cfg(feature = "measure")]276 let launched = *launched;277278 // The window comes first, so a compositor that gives none leaves the279 // screen and the flags where they are and the watchdog running.280 let Some(graphics) = graphics::open(event_loop, options.size, &options.app_id) else {281 return;282 };283 watchdog.present();284285 let screen = screen.take().expect("one window per run");286 let viewport = Viewport::with_physical_size(287 Size::new(graphics.size.0, graphics.size.1),288 graphics.window.scale_factor() as f32,289 );290 #[cfg(feature = "measure")]291 let stats = Stats::requested(292 options.stats.take(),293 launched,294 graphics.backend.clone(),295 graphics.adapter.clone(),296 graphics.size,297 );298 #[cfg(feature = "measure")]299 let captures = Captures::requested(300 options.capture_dir.take(),301 std::mem::take(&mut options.capture_at),302 );303304 let Options {305 script,306 quit_after,307 app_id,308 ..309 } = std::mem::take(options);310311 *state = State::Ready(Box::new(Ready {312 screen,313 timeline: Timeline::new(script, quit_after),314 graphics,315 app_id,316 viewport,317 cache: user_interface::Cache::new(),318 clipboard: Clipboard::unconnected(),319 modifiers: ModifiersState::default(),320 events: Vec::new(),321 resized: false,322 surface_pending: false,323 scheduled: None,324 start: None,325 drawn: 0.0,326 stale: false,327 #[cfg(feature = "measure")]328 captures,329 #[cfg(feature = "measure")]330 stats,331 finished: false,332 stopped: false,333 }));334 }335336 fn window_event(337 &mut self,338 event_loop: &winit::event_loop::ActiveEventLoop,339 id: winit::window::WindowId,340 event: WindowEvent,341 ) {342 let App {343 watchdog, state, ..344 } = self;345 let State::Ready(ready) = state else {346 return;347 };348349 match &event {350 WindowEvent::RedrawRequested => {351 ready.frame(event_loop);352 return;353 }354 // A `present` maps a new window before it drops the old one, so355 // this arrives for a window the run has already replaced. The356 // watchdog reads only the window the run draws on now.357 WindowEvent::Destroyed if id == ready.graphics.window.id() => {358 watchdog.missing(std::time::Instant::now());359 return;360 }361 WindowEvent::Resized(_) => ready.resized = true,362 WindowEvent::CloseRequested => {363 ready.stop(event_loop);364 return;365 }366 WindowEvent::ModifiersChanged(new) => ready.modifiers = new.state(),367 WindowEvent::KeyboardInput { event, .. } => {368 if event.state.is_pressed()369 && let Some(name) = key_name(&event.logical_key)370 && ready.press(&name)371 {372 ready.stop(event_loop);373 return;374 }375 }376 _ => {}377 }378379 if let Some(event) = conversion::window_event(380 event,381 ready.graphics.window.scale_factor() as f32,382 ready.modifiers,383 ) {384 ready.events.push(event);385 }386 }387388 fn about_to_wait(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {389 // The grace is checked here rather than in the frame, because a client390 // with no window draws no frame.391 self.watchdog.expire_if_late(std::time::Instant::now());392393 // A client waiting for a window has nothing to draw and a grace to394 // check, so the loop takes every pass it can until one is up. Winit395 // waits for an event otherwise, and a compositor that gives no window396 // sends none.397 if self.watchdog.counting() {398 event_loop.set_control_flow(ControlFlow::Poll);399 return;400 }401402 let State::Ready(ready) = &mut self.state else {403 return;404 };405406 // The deadline is checked here as well as in the frame, so a run ends407 // even if the compositor stops asking for frames. Before the first408 // frame there is no timeline yet, so there is no deadline.409 if let Some(start) = ready.start410 && ready.timeline.past_deadline(start.elapsed().as_secs_f64())411 {412 ready.stop(event_loop);413 return;414 }415416 // The sources are pumped here, on every wake of the loop, because a417 // covered client draws no frame: the compositor sends a hidden418 // surface no frame callbacks. `present` is the one message that lets419 // a covered client map the surface that reveals it, so the bus must420 // be read on a path the compositor cannot starve.421 if let Some(start) = ready.start {422 let at = start.elapsed().as_secs_f64();423 if ready.screen.pump(at) {424 // What arrived changed the unit, so the frame on the glass is425 // stale whatever the animations schedule, and the second426 // scheduled before it no longer holds.427 ready.scheduled = None;428 ready.stale = true;429 }430 if ready.screen.surface_due() {431 ready.surface_pending = true;432 }433 if ready.surface_pending && ready.represent(event_loop) {434 ready.surface_pending = false;435 ready.screen.surfaced(at);436 // A Wayland surface is not on screen until its first buffer437 // arrives, so the new window gets a draw whatever the438 // schedule says.439 ready.graphics.window.request_redraw();440 }441 }442443 ready.pace(event_loop);444 }445446 fn exiting(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) {447 let App { stopped, state, .. } = self;448 if let State::Ready(ready) = state {449 ready.finish();450 *stopped = ready.stopped;451 }452 // wgpu builds an instance for every backend it can reach, and the one453 // for GL holds an EGL display on the compositor's connection. Its454 // destructor speaks Wayland, so it has to run while the connection is455 // open. winit closes the connection after this call and never before456 // it, so the graphics are dropped here rather than where the loop457 // returns.458 self.state = State::Done;459 }460}461462/// The script's name for a key. A letter or a digit is itself, and the arrows463/// carry the names a scripted timeline uses.464pub fn key_name(key: &Key) -> Option<String> {465 match key {466 Key::Character(text) => Some(text.to_lowercase()),467 Key::Named(NamedKey::ArrowUp) => Some("up".into()),468 Key::Named(NamedKey::ArrowDown) => Some("down".into()),469 Key::Named(NamedKey::ArrowLeft) => Some("left".into()),470 Key::Named(NamedKey::ArrowRight) => Some("right".into()),471 _ => None,472 }473}474475#[cfg(test)]476mod tests {477 use super::*;478 use iced_winit::winit::keyboard::SmolStr;479480 #[test]481 fn a_letter_names_itself() {482 assert_eq!(483 key_name(&Key::Character(SmolStr::new("Q"))),484 Some("q".to_string())485 );486 }487488 #[test]489 fn an_arrow_carries_the_script_name() {490 assert_eq!(491 key_name(&Key::Named(NamedKey::ArrowLeft)),492 Some("left".to_string())493 );494 }495496 #[test]497 fn a_key_with_no_script_name_is_ignored() {498 assert_eq!(key_name(&Key::Named(NamedKey::F1)), None);499 }500}
idle/src/harness/capture.rs 93.8%
1// The frames a run writes to disk: which frame is due, where it goes, and the2// PNG the renderer's readback becomes.3//4// The schedule is a function of the clock alone, so a test drives it with5// numbers and never opens a window.67use std::path::{Path, PathBuf};89/// The directory the frames go in, the seconds they were asked for, and a10/// cursor into that list. The cursor only moves forward, so a capture fires11/// once.12#[derive(Debug)]13pub struct Captures {14 dir: PathBuf,15 at: Vec<f64>,16 next: usize,17}1819impl Captures {20 /// The captures `--capture` and `--capture-at` asked for, or nothing. The21 /// directory is where a frame goes, so a run that named none captures22 /// nothing whatever seconds it listed.23 pub fn requested(dir: Option<PathBuf>, at: Vec<f64>) -> Option<Self> {24 Some(Self {25 dir: dir?,26 at,27 next: 0,28 })29 }3031 /// The path this frame is written to, if the frame is the first one at or32 /// after the next capture second.33 pub fn due(&mut self, at: f64) -> Option<PathBuf> {34 let when = *self.at.get(self.next)?;35 if at < when {36 return None;37 }38 self.next += 1;39 Some(self.dir.join(format!("{when:06.2}.png")))40 }4142 /// The second of the next capture still to come. The loop folds it into43 /// the wake time, so a capturing run sleeps to the second it needs rather44 /// than drawing every pass toward it.45 pub fn next_due(&self) -> Option<f64> {46 self.at.get(self.next).copied()47 }4849 /// Whether a capture is still to come.50 pub fn pending(&self) -> bool {51 self.next < self.at.len()52 }5354 /// Whether the run has taken every capture it asked for. A run that named55 /// a directory and no second takes none and ends its own way.56 pub fn taken(&self) -> bool {57 !self.at.is_empty() && !self.pending()58 }59}6061/// Write one captured frame. `rgba` is what the renderer read back.62pub fn write_png(path: &Path, width: u32, height: u32, rgba: &[u8]) {63 if let Some(dir) = path.parent() {64 let _ = std::fs::create_dir_all(dir);65 }66 let Some(buffer) = image::RgbaImage::from_raw(width, height, rgba.to_vec()) else {67 eprintln!(68 "capture {}: {} bytes is not {width}x{height}",69 path.display(),70 rgba.len()71 );72 return;73 };74 if let Err(error) = buffer.save(path) {75 eprintln!("capture {}: {error}", path.display());76 }77}7879#[cfg(test)]80mod tests {81 use super::*;8283 fn capturing(dir: &str, at: Vec<f64>) -> Captures {84 Captures::requested(Some(PathBuf::from(dir)), at).expect("a directory captures")85 }8687 #[test]88 fn a_run_with_no_directory_captures_nothing() {89 assert!(Captures::requested(None, vec![0.5]).is_none());90 }9192 #[test]93 fn a_capture_is_due_on_the_first_frame_at_or_after_its_second() {94 let mut captures = capturing("/frames", vec![0.5]);95 assert_eq!(captures.due(0.49), None);96 assert_eq!(97 captures.due(0.52),98 Some(PathBuf::from("/frames/000.50.png"))99 );100 assert_eq!(captures.due(0.53), None);101 }102103 #[test]104 fn a_capture_is_named_for_the_second_it_was_asked_for() {105 let mut captures = capturing("/frames", vec![12.25, 100.0]);106 assert_eq!(107 captures.due(20.0),108 Some(PathBuf::from("/frames/012.25.png"))109 );110 assert_eq!(111 captures.due(200.0),112 Some(PathBuf::from("/frames/100.00.png"))113 );114 }115116 #[test]117 fn the_next_capture_is_due_until_it_is_taken() {118 let mut captures = capturing("/frames", vec![0.5, 1.5]);119 assert_eq!(captures.next_due(), Some(0.5));120121 captures.due(0.5);122 assert_eq!(captures.next_due(), Some(1.5));123124 captures.due(1.5);125 assert_eq!(captures.next_due(), None);126 }127128 #[test]129 fn a_run_has_taken_its_captures_after_the_last_one() {130 let mut captures = capturing("/frames", vec![0.5, 1.5]);131 assert!(captures.pending());132 assert!(!captures.taken());133134 captures.due(0.5);135 assert!(!captures.taken());136137 captures.due(1.5);138 assert!(captures.taken());139 assert!(!captures.pending());140 }141142 #[test]143 fn a_directory_with_no_seconds_captures_nothing_and_ends_nothing() {144 let captures = capturing("/frames", Vec::new());145 assert!(!captures.pending());146 assert!(!captures.taken());147 }148}
idle/src/harness/frame.rs 92.2%
1// One pass of the frame loop: the script's keys, the draw, the capture, and2// the numbers. The pass ends by setting the pace of the next one.34use iced_wgpu::graphics::Viewport;5use iced_wgpu::wgpu;6use iced_winit::core::time::Instant;7use iced_winit::core::{Color, Event, Size, Theme, mouse, renderer, window};8use iced_winit::runtime::user_interface::UserInterface;9use iced_winit::winit::event_loop::{ActiveEventLoop, ControlFlow};1011use std::sync::Arc;1213#[cfg(feature = "measure")]14use super::capture::{self, Captures};15use super::graphics::{self, configure};16#[cfg(feature = "measure")]17use super::stats::millis;18use super::timeline::{self, Wake};19use super::{QUIT, Ready, Screen};2021/// The least time between two frames, one sixtieth of a second. The surface22/// presents without vsync, so this floor is the whole of the frame-rate cap:23/// an animation that answers "now" on every ask draws sixty frames a second24/// and not as many as the loop can spin.25pub const STEP: f64 = 1.0 / 60.0;2627impl<S: Screen> Ready<S> {28 /// Hand one key to the screen. The answer is true when the key ends the29 /// run. Both the keyboard and the script arrive here, so the key that30 /// ends a run is decided once for the two of them.31 pub(crate) fn press(&mut self, name: &str) -> bool {32 if name == QUIT {33 return true;34 }35 // A key changes what the screen draws, so the frame on the glass is36 // stale and the second named before the press no longer holds.37 self.scheduled = None;38 self.stale = true;39 self.screen.key(name);40 false41 }4243 /// Write the numbers and leave the loop. The mark says the run ended on its44 /// own terms, so a loop that returns without it is a window that went away.45 pub(crate) fn stop(&mut self, event_loop: &ActiveEventLoop) {46 self.stopped = true;47 self.finish();48 event_loop.exit();49 }5051 /// Build, draw, capture, and present one frame.52 pub(crate) fn frame(&mut self, event_loop: &ActiveEventLoop) {53 // This frame is the one the schedule asked for, so the schedule is54 // spent and the next pass asks the screen again. It draws every fold55 // so far, so the glass is current again.56 self.scheduled = None;57 self.stale = false;58 let loop_start = std::time::Instant::now();59 let at = match self.start {60 Some(start) => start.elapsed().as_secs_f64(),61 None => {62 self.start = Some(loop_start);63 #[cfg(feature = "measure")]64 if let Some(stats) = &mut self.stats {65 stats.first_frame();66 }67 0.068 }69 };7071 self.drawn = at;7273 for key in self.timeline.due(at) {74 if self.press(&key) {75 self.stop(event_loop);76 return;77 }78 }7980 self.screen.tick(at);81 #[cfg(feature = "measure")]82 if let Some(stats) = &mut self.stats {83 stats.sample_rss(at);84 }8586 if self.resized {87 let size = self.graphics.window.inner_size();88 let (width, height) = (size.width.max(1), size.height.max(1));89 self.viewport = Viewport::with_physical_size(90 Size::new(width, height),91 self.graphics.window.scale_factor() as f32,92 );93 configure(94 &self.graphics.surface,95 &self.graphics.device,96 self.graphics.format,97 width,98 height,99 );100 #[cfg(feature = "measure")]101 if let Some(stats) = &mut self.stats {102 stats.resized((width, height));103 }104 self.resized = false;105 }106107 let frame = match self.graphics.surface.get_current_texture() {108 Ok(frame) => frame,109 Err(wgpu::SurfaceError::OutOfMemory) => {110 eprintln!("surface out of memory");111 self.stop(event_loop);112 return;113 }114 Err(_) => {115 self.resized = true;116 return;117 }118 };119120 // The clock starts after the swapchain image is in hand, so the frame121 // time measures the work of a frame and not the wait for the display.122 #[cfg(feature = "measure")]123 let build_start = std::time::Instant::now();124 let view = frame125 .texture126 .create_view(&wgpu::TextureViewDescriptor::default());127128 let mut interface = UserInterface::build(129 self.screen.view(),130 self.viewport.logical_size(),131 std::mem::take(&mut self.cache),132 &mut self.graphics.renderer,133 );134135 let mut messages = Vec::new();136 self.events.push(Event::Window(137 window::Event::RedrawRequested(Instant::now()),138 ));139 let _ = interface.update(140 &self.events,141 mouse::Cursor::Unavailable,142 &mut self.graphics.renderer,143 &mut self.clipboard,144 &mut messages,145 );146 self.events.clear();147148 interface.draw(149 &mut self.graphics.renderer,150 &Theme::Dark,151 &renderer::Style::default(),152 mouse::Cursor::Unavailable,153 );154 self.cache = interface.into_cache();155156 for message in messages {157 self.screen.update(message);158 }159160 let background = self.screen.background();161 // The frame is built. A capture writes a file and blocks on a readback,162 // so the clock stops here and starts again for the submit.163 #[cfg(feature = "measure")]164 let drawn_ms = millis(build_start.elapsed());165166 let captured = self.capture(at, background);167168 #[cfg(feature = "measure")]169 let submit_start = std::time::Instant::now();170 if !captured {171 let _ = self.graphics.renderer.present(172 Some(background),173 self.graphics.format,174 &view,175 &self.viewport,176 );177 }178 // The frame time is the work of a frame: build the interface, draw it,179 // and submit the commands. It stops before the surface is presented,180 // because that call waits for the compositor and measures the screen's181 // rate rather than this program's cost.182 #[cfg(feature = "measure")]183 let build_ms = drawn_ms + millis(submit_start.elapsed());184185 frame.present();186187 // A captured frame draws twice and blocks on a readback, so it says188 // nothing about the cost of a frame and stays out of the numbers.189 #[cfg(feature = "measure")]190 if let Some(stats) = &mut self.stats {191 stats.frame(build_ms, millis(loop_start.elapsed()), !captured);192 }193194 if self.timeline.past_deadline(at) || self.captured_everything() {195 self.stop(event_loop);196 }197 }198199 /// Set the pace of the loop, and ask for the frame that pace calls for.200 ///201 /// The loop sleeps until the earliest second anything is due, so a screen202 /// at rest builds one frame a change rather than one a display refresh. A203 /// second the screen has already named holds until the clock reaches it,204 /// because a fresh answer after the clock arrived would name the change205 /// after it, and the frame would never be drawn.206 pub(crate) fn pace(&mut self, event_loop: &ActiveEventLoop) {207 // Before the first frame there is no clock to schedule against, and208 // the first frame is what starts it.209 let Some(start) = self.start else {210 event_loop.set_control_flow(ControlFlow::Poll);211 self.graphics.window.request_redraw();212 return;213 };214215 let at = start.elapsed().as_secs_f64();216 let screen_next = match self.scheduled {217 Some(scheduled) => Some(scheduled),218 None => self.screen.next_frame(at),219 };220 self.scheduled = None;221222 // The wake is the earliest second anything is due: a stale frame is223 // due now, and after it the screen's own change, the next script key,224 // the deadline, or the next capture. The harness's own seconds come225 // from forward-only cursors, so they are asked again on every pass,226 // and only the screen's answer is held. The floor holds every answer227 // at least [`STEP`] after the last frame, which is the frame-rate228 // cap: a burst of folds coalesces to sixty frames a second and no229 // press waits past the next one.230 let stale_now = self.stale.then_some(at);231 let next = [232 stale_now,233 screen_next,234 self.timeline.next_due(),235 self.next_capture(),236 ]237 .into_iter()238 .flatten()239 .min_by(f64::total_cmp)240 .map(|next| next.max(self.drawn + STEP));241242 match timeline::wake(self.resized, at, next) {243 Wake::Now => {244 event_loop.set_control_flow(ControlFlow::Poll);245 self.graphics.window.request_redraw();246 }247 Wake::At(next) => {248 self.scheduled = screen_next;249 event_loop.set_control_flow(ControlFlow::WaitUntil(250 start + std::time::Duration::from_secs_f64(next),251 ));252 }253 Wake::Never => event_loop.set_control_flow(ControlFlow::Wait),254 }255 }256257 /// Write this frame to a file, if a capture is due at this second. The258 /// answer is whether one was written.259 ///260 /// `Renderer::screenshot` renders the frame that was just drawn into an261 /// offscreen texture and reads it back as RGBA. It is iced's own path off262 /// the GPU, and it draws the same layers the surface would get. The263 /// surface itself is left alone on a capture frame, because one drawn264 /// frame must not be submitted twice.265 #[cfg(feature = "measure")]266 fn capture(&mut self, at: f64, background: Color) -> bool {267 let Some(path) = self.captures.as_mut().and_then(|captures| captures.due(at)) else {268 return false;269 };270271 let pixels = self272 .graphics273 .renderer274 .screenshot(&self.viewport, background);275 let size = self.viewport.physical_size();276 capture::write_png(&path, size.width, size.height, &pixels);277 eprintln!("captured {} at {at:.3}s", path.display());278 true279 }280281 /// A build without the `measure` feature captures no frame.282 #[cfg(not(feature = "measure"))]283 fn capture(&mut self, _at: f64, _background: Color) -> bool {284 false285 }286287 /// The second of the next capture, folded into the wake time.288 #[cfg(feature = "measure")]289 fn next_capture(&self) -> Option<f64> {290 self.captures.as_ref().and_then(Captures::next_due)291 }292293 #[cfg(not(feature = "measure"))]294 fn next_capture(&self) -> Option<f64> {295 None296 }297298 /// Whether the run has taken every capture it asked for, which ends it.299 #[cfg(feature = "measure")]300 fn captured_everything(&self) -> bool {301 self.captures.as_ref().is_some_and(Captures::taken)302 }303304 #[cfg(not(feature = "measure"))]305 fn captured_everything(&self) -> bool {306 false307 }308309 /// Map a fresh Wayland surface, and report whether one went up.310 ///311 /// Weston's kiosk-shell reveals a lower surface only along a code path312 /// gated on a seat, and `liken`'s compositor has none, so a client that a313 /// film covered stays hidden until it maps a new surface. A newly mapped314 /// toplevel is revealed along a seat-independent path.315 ///316 /// The new window is created before the old one is dropped, so the client317 /// is never without a surface and the screen never shows the compositor's318 /// background. The old surface is destroyed here too: the assignments drop319 /// it and then the last reference to its window, in that order, because a320 /// surface holds the window it was created from.321 ///322 /// A compositor that gives no second window leaves the first one drawing.323 pub(crate) fn represent(&mut self, event_loop: &ActiveEventLoop) -> bool {324 let size = self.viewport.physical_size();325 let Some(window) = graphics::window(event_loop, (size.width, size.height), &self.app_id)326 else {327 return false;328 };329330 let surface = match self.graphics.instance.create_surface(Arc::clone(&window)) {331 Ok(surface) => surface,332 Err(error) => {333 eprintln!("idle-screen: no surface on the new window: {error}");334 return false;335 }336 };337338 configure(339 &surface,340 &self.graphics.device,341 self.graphics.format,342 size.width.max(1),343 size.height.max(1),344 );345 self.graphics.surface = surface;346 self.graphics.window = window;347 eprintln!("idle-screen: a new surface is up");348 true349 }350351 /// Write the statistics file once, whichever way the run ends.352 pub(crate) fn finish(&mut self) {353 if self.finished {354 return;355 }356 self.finished = true;357 #[cfg(feature = "measure")]358 if let Some(stats) = &self.stats {359 stats.write();360 }361 }362}
idle/src/harness/graphics.rs 86.9%
1// The window and the graphics device the frame loop draws through, kept apart2// from the loop itself.34use std::sync::Arc;56use iced_wgpu::graphics::Shell;7use iced_wgpu::{Engine, Renderer, wgpu};8use iced_winit::core::{Font, Pixels};9use iced_winit::winit;1011use winit::event_loop::ActiveEventLoop;12use winit::platform::wayland::WindowAttributesExtWayland;1314/// Everything that exists only after the compositor gives the process a window.15pub struct Graphics {16 pub window: Arc<winit::window::Window>,17 pub instance: wgpu::Instance,18 pub device: wgpu::Device,19 pub surface: wgpu::Surface<'static>,20 pub format: wgpu::TextureFormat,21 pub renderer: Renderer,22 pub backend: String,23 pub adapter: String,24 pub size: (u32, u32),25}2627/// Ask the compositor for a window. The answer is `None` when it gave none, and28/// the watchdog reads that as a client with nothing to draw on.29///30/// `app_id` is the Wayland app-id the display claim delivered, and the31/// compositor routes the surface to the right screen by it. An empty id asks32/// for none, which is a run on a workstation where no claim named one.33pub fn window(34 event_loop: &ActiveEventLoop,35 size: (u32, u32),36 app_id: &str,37) -> Option<Arc<winit::window::Window>> {38 let mut attributes = winit::window::WindowAttributes::default()39 .with_title("liken idle screen")40 // A kiosk client draws no title bar. winit's Wayland backend draws41 // one otherwise, and it takes 35 rows off the surface the compositor42 // gave the window.43 .with_decorations(false)44 .with_inner_size(winit::dpi::PhysicalSize::new(size.0, size.1));4546 if !app_id.is_empty() {47 // The general name is the Wayland app-id. The instance name is the48 // second half of the same protocol field, and the compositor reads49 // neither of the two for anything this client needs.50 attributes = attributes.with_name(app_id, "");51 }5253 match event_loop.create_window(attributes) {54 Ok(window) => Some(Arc::new(window)),55 Err(error) => {56 eprintln!("idle-screen: the compositor gave no window: {error}");57 None58 }59 }60}6162/// Open the window, pick an adapter, and build the renderer that draws into it.63///64/// Every failure here answers `None` the way a compositor that gave no65/// window does, so the run leaves the watchdog counting and the kubelet66/// reads the exit code the watchdog states, whichever part of the67/// graphics stack refused.68pub fn open(event_loop: &ActiveEventLoop, size: (u32, u32), app_id: &str) -> Option<Graphics> {69 let window = window(event_loop, size, app_id)?;7071 let physical = window.inner_size();72 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {73 backends: wgpu::Backends::from_env().unwrap_or_default(),74 ..Default::default()75 });76 let surface = match instance.create_surface(window.clone()) {77 Ok(surface) => surface,78 Err(error) => {79 eprintln!("idle-screen: no drawing surface on this window: {error}");80 return None;81 }82 };8384 let (format, adapter, device, queue) = block_on(async {85 let adapter =86 match wgpu::util::initialize_adapter_from_env_or_default(&instance, Some(&surface))87 .await88 {89 Ok(adapter) => adapter,90 Err(error) => {91 eprintln!("idle-screen: no wgpu adapter for this surface: {error}");92 return None;93 }94 };9596 let capabilities = surface.get_capabilities(&adapter);97 let (device, queue) = match adapter98 .request_device(&wgpu::DeviceDescriptor {99 label: None,100 required_features: adapter.features() & wgpu::Features::default(),101 required_limits: wgpu::Limits::default(),102 memory_hints: wgpu::MemoryHints::MemoryUsage,103 trace: wgpu::Trace::Off,104 experimental_features: wgpu::ExperimentalFeatures::disabled(),105 })106 .await107 {108 Ok(pair) => pair,109 Err(error) => {110 eprintln!("idle-screen: the adapter gave no device: {error}");111 return None;112 }113 };114115 // The client draws what libass draws, and libass composites on the116 // encoded sRGB values rather than in linear light. A format the117 // hardware treats as sRGB converts every colour on the way in and out,118 // and the blend then runs in linear light: the display's dim alpha119 // over black reads 143 of 255 that way and 79 of 255 the way libass120 // reads it. So the surface takes a format that stores what the shader121 // writes, and the `web-colors` feature of `iced_wgpu` hands the shader122 // the colours unconverted.123 //124 // The compositor reads the buffer as sRGB either way. The only change125 // is where the encoding happens, and here the client has already done126 // it.127 //128 // The search names the two eight-bit formats rather than taking the129 // first format that is not sRGB. A surface also offers wider ones,130 // such as `Rgba16Unorm`, and a render pipeline on that format needs a131 // wgpu feature this client does not enable. A surface that offers132 // neither eight-bit format falls back to sRGB, which blends in linear133 // light and draws every fade too bright.134 let encoded = |format: &wgpu::TextureFormat| {135 matches!(136 format,137 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm138 )139 };140 let formats = || capabilities.formats.iter().copied();141 let format = formats()142 .find(encoded)143 .or_else(|| formats().find(wgpu::TextureFormat::is_srgb))144 .or_else(|| formats().next());145 let Some(format) = format else {146 eprintln!("idle-screen: the surface offers no format");147 return None;148 };149150 Some((format, adapter, device, queue))151 })?;152153 // cage takes the next free `wayland-N`, which is not `wayland-1` when154 // another compositor already holds that name, so the local script reads the155 // name out of this line rather than guessing it.156 println!(157 "wayland: {}",158 std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "none".into())159 );160161 let info = adapter.get_info();162 eprintln!(163 "wgpu: {:?} on {} ({:?}), surface {:?} at {}x{}",164 info.backend, info.name, info.device_type, format, physical.width, physical.height165 );166167 configure(168 &surface,169 &device,170 format,171 physical.width.max(1),172 physical.height.max(1),173 );174175 let engine = Engine::new(176 &adapter,177 device.clone(),178 queue,179 format,180 None,181 Shell::headless(),182 );183184 Some(Graphics {185 window,186 instance,187 device,188 surface,189 format,190 renderer: Renderer::new(engine, Font::default(), Pixels::from(16)),191 backend: format!("{:?}", info.backend),192 adapter: info.name.clone(),193 size: (physical.width.max(1), physical.height.max(1)),194 })195}196197/// Point the swapchain at a size. Every resize runs through here.198pub fn configure(199 surface: &wgpu::Surface<'static>,200 device: &wgpu::Device,201 format: wgpu::TextureFormat,202 width: u32,203 height: u32,204) {205 surface.configure(206 device,207 &wgpu::SurfaceConfiguration {208 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,209 format,210 width,211 height,212 // Mailbox rather than FIFO, because of what acquire does on a213 // hidden Wayland surface: FIFO waits in mesa's poll for a buffer214 // release the compositor never sends while a film covers the215 // screen, and the whole loop stops with it, bus and all. Mailbox216 // keeps spare images, so acquire never waits on the compositor.217 // The loop's own pace is what caps the rate instead: it never218 // asks for frames faster than `frame::STEP`.219 present_mode: wgpu::PresentMode::AutoNoVsync,220 alpha_mode: wgpu::CompositeAlphaMode::Auto,221 view_formats: vec![],222 desired_maximum_frame_latency: 2,223 },224 );225}226227/// Wait for one future on this thread. Only wgpu's setup is asynchronous here,228/// and the futures crate ships its executor behind a feature iced does not229/// enable, so the harness parks the thread and lets the waker unpark it.230fn block_on<F: Future>(future: F) -> F::Output {231 struct Unpark(std::thread::Thread);232233 impl std::task::Wake for Unpark {234 fn wake(self: Arc<Self>) {235 self.0.unpark();236 }237 }238239 let waker = std::task::Waker::from(Arc::new(Unpark(std::thread::current())));240 let mut context = std::task::Context::from_waker(&waker);241 // The future never moves after this point, and it lives on this stack242 // frame until it resolves, so pinning it here is sound.243 let mut future = std::pin::pin!(future);244245 loop {246 match future.as_mut().poll(&mut context) {247 std::task::Poll::Ready(output) => return output,248 std::task::Poll::Pending => std::thread::park(),249 }250 }251}
idle/src/harness/options.rs 98.2%
1// The flags the idle screen accepts, and the parsers behind them. A headless2// run has no keyboard and no screenshot tool, so the flags stand in for both.34#[cfg(feature = "measure")]5use std::path::PathBuf;6use std::time::Duration;78/// The help the binary prints for `--help`.9pub fn help() -> String {10 [HEAD, MEASURE, TAIL].concat()11}1213const HEAD: &str = "\14idle-screen [FLAGS]1516 --script \"0.0:p,3.0:o\" key events at seconds from the first frame17";1819/// The flags the `measure` feature carries. A build without it parses none of20/// the three and lists none of them, so a person reading this help reads what21/// the binary in front of them accepts.22#[cfg(feature = "measure")]23const MEASURE: &str = " --capture DIR where captured PNGs go24 --capture-at \"0.5,3.2\" one PNG of the rendered frame at each second listed;25 the run ends after the last one26 --stats FILE the JSON measurements, written at exit27";2829#[cfg(not(feature = "measure"))]30const MEASURE: &str = "";3132const TAIL: &str = " --quit-after SECONDS when to exit33 --size WxH the window size to ask for; the default is 1920x108034 --help print this and exit3536The binary takes the same keys from a real keyboard, so it runs on a37workstation with no flags at all. Quit with q.3839Under IDLE_PREVIEW=1 the idle screen binds the keys that stand in for the40bus, and names them in a legend at the bottom right. local/idle sets that41variable.42";4344/// What the command line asked for.45#[derive(Debug, PartialEq)]46pub enum Invocation {47 /// Open a window with these options.48 Run(Options),49 /// Print the help from [`help`] and stop.50 Help,51}5253/// Everything the harness needs for a run: the flags below, and the two window54/// facts the container's environment carries. The command line states none of55/// those two, and [`Options::parse`] leaves both as the default, so the binary56/// reads the wiring once and fills them in.57#[derive(Debug, PartialEq)]58pub struct Options {59 /// Key events at seconds from the first frame, in the order they fire.60 pub script: Vec<(f64, String)>,61 /// Where captured PNGs go, and at what seconds.62 #[cfg(feature = "measure")]63 pub capture_dir: Option<PathBuf>,64 #[cfg(feature = "measure")]65 pub capture_at: Vec<f64>,66 /// Where the JSON measurements go at exit.67 #[cfg(feature = "measure")]68 pub stats: Option<PathBuf>,69 /// When to exit, in seconds. A capture run also exits after its last frame.70 pub quit_after: Option<f64>,71 /// The window size to ask the compositor for.72 pub size: (u32, u32),73 /// The Wayland app-id every window this run maps asks for, from74 /// `DISPLAY_APP_ID`. An empty id asks for none.75 pub app_id: String,76 /// How long the run waits for a window before it exits, from77 /// `IDLE_WINDOW_GRACE_SECONDS`. Nothing leaves the watchdog off.78 pub window_grace: Option<Duration>,79}8081impl Default for Options {82 fn default() -> Self {83 Self {84 script: Vec::new(),85 #[cfg(feature = "measure")]86 capture_dir: None,87 #[cfg(feature = "measure")]88 capture_at: Vec::new(),89 #[cfg(feature = "measure")]90 stats: None,91 quit_after: None,92 size: (1920, 1080),93 app_id: String::new(),94 window_grace: None,95 }96 }97}9899impl Options {100 /// Parse the command line. The flags are few and fixed, so the parser is a101 /// loop over the arguments rather than a dependency.102 pub fn parse<I>(args: I) -> Result<Invocation, String>103 where104 I: IntoIterator<Item = String>,105 {106 let mut options = Options::default();107 let mut args = args.into_iter();108109 while let Some(arg) = args.next() {110 let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));111112 match arg.as_str() {113 "--help" => return Ok(Invocation::Help),114 "--script" => options.script = parse_script(&value()?)?,115 #[cfg(feature = "measure")]116 "--capture" => options.capture_dir = Some(PathBuf::from(value()?)),117 #[cfg(feature = "measure")]118 "--capture-at" => options.capture_at = parse_times(&value()?)?,119 #[cfg(feature = "measure")]120 "--stats" => options.stats = Some(PathBuf::from(value()?)),121 "--quit-after" => {122 let raw = value()?;123 options.quit_after = Some(124 raw.trim()125 .parse()126 .map_err(|_| format!("bad --quit-after {raw}"))?,127 );128 }129 "--size" => options.size = parse_size(&value()?)?,130 other => return Err(format!("unknown flag {other}")),131 }132 }133134 Ok(Invocation::Run(options))135 }136}137138/// A scripted timeline: `SECONDS:KEY` steps, comma separated, sorted by time so139/// the frame loop reads them in order and never looks back.140pub fn parse_script(raw: &str) -> Result<Vec<(f64, String)>, String> {141 let mut script = Vec::new();142143 for step in raw.split(',') {144 let step = step.trim();145 if step.is_empty() {146 continue;147 }148 let (at, key) = step149 .split_once(':')150 .ok_or_else(|| format!("bad script step {step}"))?;151 let at: f64 = at152 .trim()153 .parse()154 .map_err(|_| format!("bad script time {at}"))?;155 let key = key.trim();156 if key.is_empty() {157 return Err(format!("bad script step {step}"));158 }159 script.push((at, key.to_string()));160 }161162 script.sort_by(|a, b| a.0.total_cmp(&b.0));163 Ok(script)164}165166/// A list of seconds, comma separated, sorted for the same reason.167#[cfg(feature = "measure")]168pub fn parse_times(raw: &str) -> Result<Vec<f64>, String> {169 let mut times = Vec::new();170171 for at in raw.split(',') {172 let at = at.trim();173 if at.is_empty() {174 continue;175 }176 times.push(at.parse().map_err(|_| format!("bad capture time {at}"))?);177 }178179 times.sort_by(f64::total_cmp);180 Ok(times)181}182183/// A window size, written `WIDTHxHEIGHT`.184pub fn parse_size(raw: &str) -> Result<(u32, u32), String> {185 let (width, height) = raw186 .trim()187 .split_once('x')188 .ok_or_else(|| format!("bad size {raw}"))?;189190 Ok((191 width.parse().map_err(|_| format!("bad width {width}"))?,192 height.parse().map_err(|_| format!("bad height {height}"))?,193 ))194}195196#[cfg(test)]197mod tests {198 use super::*;199200 fn args(line: &str) -> Vec<String> {201 line.split_whitespace().map(str::to_string).collect()202 }203204 #[test]205 fn no_flags_is_a_run_with_the_defaults() {206 assert_eq!(207 Options::parse(Vec::new()),208 Ok(Invocation::Run(Options::default()))209 );210 }211212 #[test]213 fn the_default_size_is_1920x1080() {214 assert_eq!(Options::default().size, (1920, 1080));215 }216217 #[test]218 fn the_help_lists_every_flag() {219 for flag in [220 "--script",221 "--capture",222 "--capture-at",223 "--stats",224 "--quit-after",225 "--size",226 "--help",227 ] {228 // Each flag stands in an indented column, and the three parts of229 // the text join without disturbing it.230 assert!(help().contains(&format!("\n {flag} ")), "{flag}");231 }232 }233234 #[test]235 fn help_stops_the_parse() {236 assert_eq!(Options::parse(args("--help")), Ok(Invocation::Help));237 assert_eq!(238 Options::parse(args("--size 800x600 --help")),239 Ok(Invocation::Help)240 );241 }242243 #[test]244 fn an_unknown_flag_is_an_error() {245 assert_eq!(246 Options::parse(args("--wallpaper")),247 Err("unknown flag --wallpaper".to_string())248 );249 }250251 #[test]252 fn a_flag_without_its_value_is_an_error() {253 assert_eq!(254 Options::parse(args("--stats")),255 Err("--stats needs a value".to_string())256 );257 }258259 #[test]260 fn the_flags_land_in_the_options() {261 let Ok(Invocation::Run(options)) = Options::parse(args(262 "--script 1.0:p --capture /frames --capture-at 0.5 \263 --stats /stats.json --quit-after 3 --size 1280x720",264 )) else {265 panic!("the flags parse");266 };267268 assert_eq!(options.script, vec![(1.0, "p".to_string())]);269 assert_eq!(options.capture_dir, Some(PathBuf::from("/frames")));270 assert_eq!(options.capture_at, vec![0.5]);271 assert_eq!(options.stats, Some(PathBuf::from("/stats.json")));272 assert_eq!(options.quit_after, Some(3.0));273 assert_eq!(options.size, (1280, 720));274 }275276 #[test]277 fn the_command_line_states_neither_window_fact() {278 let Ok(Invocation::Run(options)) = Options::parse(args("--size 1280x720")) else {279 panic!("the flags parse");280 };281282 assert_eq!(options.app_id, "");283 assert_eq!(options.window_grace, None);284 }285286 #[test]287 fn a_script_sorts_by_time() {288 assert_eq!(289 parse_script("4.0:o, 1.5:p,0.25:down"),290 Ok(vec![291 (0.25, "down".to_string()),292 (1.5, "p".to_string()),293 (4.0, "o".to_string()),294 ])295 );296 }297298 #[test]299 fn an_empty_script_is_empty() {300 assert_eq!(parse_script(""), Ok(Vec::new()));301 assert_eq!(parse_script(" , "), Ok(Vec::new()));302 }303304 #[test]305 fn a_script_step_needs_a_time_and_a_key() {306 assert!(parse_script("p").is_err());307 assert!(parse_script("later:p").is_err());308 assert!(parse_script("1.0:").is_err());309 }310311 #[test]312 fn capture_times_sort() {313 assert_eq!(parse_times("3.2, 0.5 ,1"), Ok(vec![0.5, 1.0, 3.2]));314 }315316 #[test]317 fn a_capture_time_must_be_a_number() {318 assert_eq!(319 parse_times("0.5,soon"),320 Err("bad capture time soon".to_string())321 );322 }323324 #[test]325 fn a_size_is_two_numbers_around_an_x() {326 assert_eq!(parse_size("1920x1080"), Ok((1920, 1080)));327 assert_eq!(parse_size(" 640x480 "), Ok((640, 480)));328 assert!(parse_size("1920").is_err());329 assert!(parse_size("widexhigh").is_err());330 }331}
idle/src/harness/stats.rs 99.4%
1// What the harness measures, and the JSON it writes at exit.23use std::path::PathBuf;4use std::time::Instant;56use serde_json::json;78pub struct Stats {9 /// Where the report goes at exit, from `--stats`.10 path: PathBuf,11 /// When the process began, for the time to the first frame.12 launched: Instant,13 backend: String,14 adapter: String,15 size: (u32, u32),16 frames: u64,17 first_frame: Option<f64>,18 /// Milliseconds to build, draw, and submit a frame, one entry per frame.19 build_ms: Vec<f64>,20 /// Milliseconds for the whole loop pass, which adds the wait for the next21 /// swapchain image. Under a compositor that waits for the display, this is22 /// the frame interval and the build time is the work inside it.23 loop_ms: Vec<f64>,24 rss_mib: Vec<f64>,25 next_rss_at: f64,26}2728impl Stats {29 /// The collector for a run, or nothing when `--stats` named no file. The30 /// file is the only reader of these numbers, and every sample stays in31 /// memory until the process writes it, so a run that names no file32 /// measures nothing and holds nothing.33 pub fn requested(34 path: Option<PathBuf>,35 launched: Instant,36 backend: String,37 adapter: String,38 size: (u32, u32),39 ) -> Option<Self> {40 Some(Self {41 path: path?,42 launched,43 backend,44 adapter,45 size,46 frames: 0,47 first_frame: None,48 build_ms: Vec::new(),49 loop_ms: Vec::new(),50 rss_mib: Vec::new(),51 next_rss_at: 0.0,52 })53 }5455 /// The surface changed size. The frame times collected so far belong to56 /// another size, and the frames around the change pay for a new swapchain,57 /// so the series starts again here.58 pub fn resized(&mut self, size: (u32, u32)) {59 self.size = size;60 self.build_ms.clear();61 self.loop_ms.clear();62 }6364 /// The first frame arrived. The seconds run from the launch, so the number65 /// covers the whole life of the process: the wgpu setup, the first window,66 /// and the first draw.67 pub fn first_frame(&mut self) {68 self.first_frame = Some(self.launched.elapsed().as_secs_f64());69 }7071 /// Record one frame. `counted` is false for a captured frame, which draws72 /// twice and waits on a readback.73 pub fn frame(&mut self, build_ms: f64, loop_ms: f64, counted: bool) {74 self.frames += 1;75 if counted {76 self.build_ms.push(build_ms);77 self.loop_ms.push(loop_ms);78 }79 }8081 /// Take one resident-set sample a second. The value is the VmRSS line of82 /// this process's own status file, which is what a machine with a gigabyte83 /// of memory has to fit.84 pub fn sample_rss(&mut self, at: f64) {85 if at < self.next_rss_at {86 return;87 }88 self.next_rss_at = at.floor() + 1.0;89 if let Some(mib) = read_rss_mib() {90 self.rss_mib.push(mib);91 }92 }9394 /// The measurements, as the file holds them.95 pub fn report(&self) -> serde_json::Value {96 json!({97 "backend": self.backend,98 "adapter": self.adapter,99 "width": self.size.0,100 "height": self.size.1,101 "frames": self.frames,102 "seconds_to_first_frame": rounded(self.first_frame.unwrap_or(f64::NAN), 4),103 "frame_ms_p50": rounded(percentile(&self.build_ms, 0.50), 3),104 "frame_ms_p99": rounded(percentile(&self.build_ms, 0.99), 3),105 "frame_ms_max": rounded(percentile(&self.build_ms, 1.0), 3),106 "loop_ms_p50": rounded(percentile(&self.loop_ms, 0.50), 3),107 "loop_ms_p99": rounded(percentile(&self.loop_ms, 0.99), 3),108 "rss_mib": self.rss_mib.iter().map(|value| rounded(*value, 1)).collect::<Vec<_>>(),109 "rss_mib_max": rounded(self.rss_mib.iter().copied().fold(0.0_f64, f64::max), 1),110 })111 }112113 /// Write the report to the file the run named.114 pub fn write(&self) {115 let json = format!("{:#}\n", self.report());116 if let Err(error) = std::fs::write(&self.path, json) {117 eprintln!("stats {}: {error}", self.path.display());118 }119 }120}121122/// A measured duration in milliseconds, the unit the frame numbers are kept in.123pub fn millis(elapsed: std::time::Duration) -> f64 {124 elapsed.as_secs_f64() * 1000.0125}126127/// The nearest-rank percentile of a sample. The sample is copied and sorted128/// here, because this runs once at exit and the frame path must not sort.129pub fn percentile(values: &[f64], fraction: f64) -> f64 {130 if values.is_empty() {131 return f64::NAN;132 }133 let mut sorted = values.to_vec();134 sorted.sort_by(|a, b| a.total_cmp(b));135 let rank = ((sorted.len() as f64 - 1.0) * fraction).round() as usize;136 sorted[rank]137}138139/// A number at the precision the file reports it, so a reader is not given140/// digits the measurement does not carry. A value that is not a number reads as141/// JSON's null, which is what serde_json does with a NaN.142fn rounded(value: f64, places: u32) -> serde_json::Value {143 let scale = 10_f64.powi(places as i32);144 json!((value * scale).round() / scale)145}146147fn read_rss_mib() -> Option<f64> {148 let status = std::fs::read_to_string("/proc/self/status").ok()?;149 let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;150 let kib: f64 = line.split_whitespace().nth(1)?.parse().ok()?;151 Some(kib / 1024.0)152}153154#[cfg(test)]155mod tests {156 use super::*;157158 #[test]159 fn a_duration_reads_as_milliseconds() {160 assert_eq!(millis(std::time::Duration::from_micros(1500)), 1.5);161 }162163 #[test]164 fn a_percentile_takes_the_nearest_rank() {165 let sample: Vec<f64> = (1..=101).map(f64::from).collect();166 assert_eq!(percentile(&sample, 0.0), 1.0);167 assert_eq!(percentile(&sample, 0.50), 51.0);168 assert_eq!(percentile(&sample, 0.99), 100.0);169 assert_eq!(percentile(&sample, 1.0), 101.0);170 }171172 #[test]173 fn a_percentile_sorts_first() {174 assert_eq!(percentile(&[9.0, 1.0, 5.0], 0.50), 5.0);175 assert_eq!(percentile(&[9.0, 1.0, 5.0], 1.0), 9.0);176 }177178 #[test]179 fn one_value_is_every_percentile() {180 assert_eq!(percentile(&[4.0], 0.50), 4.0);181 assert_eq!(percentile(&[4.0], 0.99), 4.0);182 }183184 #[test]185 fn an_empty_sample_has_no_percentile() {186 assert!(percentile(&[], 0.50).is_nan());187 }188189 fn collector() -> Stats {190 Stats::requested(191 Some(PathBuf::from("/stats.json")),192 Instant::now(),193 "Vulkan".into(),194 "an adapter".into(),195 (1920, 1080),196 )197 .expect("a run that names a file measures")198 }199200 #[test]201 fn a_run_with_no_file_measures_nothing() {202 assert!(203 Stats::requested(204 None,205 Instant::now(),206 "Vulkan".into(),207 "an adapter".into(),208 (1920, 1080),209 )210 .is_none()211 );212 }213214 fn measured() -> Stats {215 let mut stats = collector();216 stats.first_frame();217 for step in 1..=10 {218 stats.frame(f64::from(step), 16.0, true);219 }220 stats.frame(40.0, 40.0, false);221 stats222 }223224 #[test]225 fn the_report_counts_every_frame_and_times_only_the_counted_ones() {226 let report = measured().report();227 assert_eq!(report["frames"], json!(11));228 assert_eq!(report["frame_ms_max"], json!(10.0));229 assert_eq!(report["loop_ms_p50"], json!(16.0));230 }231232 #[test]233 fn the_first_frame_is_counted_from_the_launch() {234 let mut stats = collector();235 assert_eq!(stats.report()["seconds_to_first_frame"], json!(null));236237 stats.first_frame();238239 let seconds = stats.report()["seconds_to_first_frame"].as_f64();240 assert!(seconds.is_some_and(|seconds| seconds >= 0.0));241 }242243 #[test]244 fn a_resize_drops_the_frame_times_before_it() {245 let mut stats = measured();246 stats.resized((1280, 720));247 let report = stats.report();248 assert_eq!(report["width"], json!(1280));249 assert_eq!(report["frames"], json!(11));250 assert_eq!(report["frame_ms_p50"], json!(null));251 }252253 #[test]254 fn a_resident_set_sample_lands_once_a_second() {255 let mut stats = collector();256 for step in 0..30 {257 stats.sample_rss(f64::from(step) * 0.1);258 }259 let samples = stats.report()["rss_mib"].as_array().unwrap().len();260 assert_eq!(samples, 3);261 }262}
idle/src/harness/timeline.rs 100.0%
1// The timed decisions of a run, kept out of the code that needs a window:2// which script keys are due, whether the run has reached its deadline, and3// when the loop draws its next frame. Every decision is a function of the4// clock, so a test drives it with numbers and never opens a window.56/// The script and the deadline, with a cursor into the script. The cursor only7/// moves forward, so a step fires once.8#[derive(Debug, Default)]9pub struct Timeline {10 script: Vec<(f64, String)>,11 quit_after: Option<f64>,12 next_step: usize,13}1415impl Timeline {16 /// The schedule the flags asked for, at second zero.17 pub fn new(script: Vec<(f64, String)>, quit_after: Option<f64>) -> Self {18 Self {19 script,20 quit_after,21 next_step: 0,22 }23 }2425 /// The keys at or before `at`, in the order the script names them. The26 /// cursor moves past every key this call returned, so a later call never27 /// looks back. The harness hands each one to the same call a keyboard28 /// press takes, and that call holds the rule for the key that ends a run.29 pub fn due(&mut self, at: f64) -> Vec<String> {30 let mut keys = Vec::new();3132 while let Some((when, key)) = self.script.get(self.next_step)33 && *when <= at34 {35 keys.push(key.clone());36 self.next_step += 1;37 }3839 keys40 }4142 /// True once the clock reaches the `--quit-after` second.43 pub fn past_deadline(&self, at: f64) -> bool {44 self.quit_after.is_some_and(|limit| at >= limit)45 }4647 /// The next second the run itself must catch: the next script key, or the48 /// deadline. The loop folds it into the wake time, so a scripted or49 /// deadlined run sleeps between its seconds instead of drawing every50 /// pass, and a measurement under `--quit-after` reads a paced run.51 pub fn next_due(&self) -> Option<f64> {52 let step = self.script.get(self.next_step).map(|(when, _)| *when);53 [step, self.quit_after]54 .into_iter()55 .flatten()56 .min_by(f64::total_cmp)57 }58}5960/// What the loop does until it draws again.61#[derive(Debug, PartialEq)]62pub enum Wake {63 /// Draw now, and take the next pass of the loop as soon as it comes.64 Now,65 /// Draw at this second on the screen's clock, and sleep until then.66 At(f64),67 /// Draw when an event arrives, and on nothing else.68 Never,69}7071/// When the loop draws its next frame. `at` is the second of the frame that72/// was drawn last, and `next` is the earliest second anything is due: the73/// screen's own change, the next script key, the next capture, or the74/// deadline, whichever comes first.75///76/// A screen at rest changes on its own schedule, once a minute for a clock77/// that draws no seconds, and a loop that drew at the rate of the display78/// would build sixty identical frames a second for it. `immediate` is the79/// exception: a surface that changed size holds a stale frame, and the loop80/// draws now whatever the schedule says.81pub fn wake(immediate: bool, at: f64, next: Option<f64>) -> Wake {82 if immediate {83 return Wake::Now;84 }85 match next {86 // A second the clock never reaches is a screen with nothing scheduled,87 // and it is also the one value a wake time cannot hold.88 Some(next) if next.is_infinite() => Wake::Never,89 Some(next) if next > at => Wake::At(next),90 Some(_) => Wake::Now,91 None => Wake::Never,92 }93}9495#[cfg(test)]96mod tests {97 use super::*;9899 fn scripted(steps: &[(f64, &str)]) -> Timeline {100 Timeline::new(101 steps102 .iter()103 .map(|(at, key)| (*at, key.to_string()))104 .collect(),105 None,106 )107 }108109 fn keys(names: &[&str]) -> Vec<String> {110 names.iter().map(|name| name.to_string()).collect()111 }112113 #[test]114 fn a_step_is_due_at_its_second_and_not_before() {115 let mut timeline = scripted(&[(1.0, "p")]);116 assert_eq!(timeline.due(0.999), keys(&[]));117 assert_eq!(timeline.due(1.0), keys(&["p"]));118 }119120 #[test]121 fn every_step_the_clock_has_passed_comes_out_in_order_and_once() {122 let mut timeline = scripted(&[(0.1, "up"), (0.2, "down"), (0.3, "p")]);123 assert_eq!(timeline.due(0.25), keys(&["up", "down"]));124 assert_eq!(timeline.due(0.25), keys(&[]));125 assert_eq!(timeline.due(9.0), keys(&["p"]));126 }127128 #[test]129 fn a_run_ends_at_its_deadline() {130 let timeline = Timeline::new(Vec::new(), Some(3.0));131 assert!(!timeline.past_deadline(2.999));132 assert!(timeline.past_deadline(3.0));133 }134135 #[test]136 fn a_run_with_no_script_and_no_deadline_has_nothing_due() {137 assert_eq!(Timeline::default().next_due(), None);138 assert!(!Timeline::default().past_deadline(86_400.0));139 }140141 #[test]142 fn the_next_step_is_due_until_it_fires_and_then_the_one_after_it() {143 let mut timeline = scripted(&[(1.0, "p"), (2.5, "q")]);144 assert_eq!(timeline.next_due(), Some(1.0));145146 timeline.due(1.0);147148 assert_eq!(timeline.next_due(), Some(2.5));149 }150151 #[test]152 fn the_deadline_is_due_when_it_comes_before_the_next_step() {153 let timeline = Timeline::new(vec![(5.0, "p".into())], Some(3.0));154 assert_eq!(timeline.next_due(), Some(3.0));155 }156157 #[test]158 fn a_resized_surface_draws_now_whatever_the_schedule_says() {159 assert_eq!(wake(true, 4.0, Some(60.0)), Wake::Now);160 assert_eq!(wake(true, 4.0, None), Wake::Now);161 }162163 #[test]164 fn a_screen_that_changes_later_sleeps_until_then() {165 assert_eq!(wake(false, 4.0, Some(60.0)), Wake::At(60.0));166 }167168 #[test]169 fn a_screen_that_has_changed_draws_now() {170 assert_eq!(wake(false, 4.0, Some(4.0)), Wake::Now);171 assert_eq!(wake(false, 4.0, Some(3.5)), Wake::Now);172 }173174 #[test]175 fn a_screen_with_nothing_scheduled_waits_for_an_event() {176 assert_eq!(wake(false, 4.0, None), Wake::Never);177 assert_eq!(wake(false, 4.0, Some(f64::INFINITY)), Wake::Never);178 }179}
idle/src/harness/watchdog.rs 94.7%
1// The window watchdog. A client with no window draws nothing while the screen2// shows the compositor's background, and that is what a compositor restart3// under a running idle pod leaves behind. Nothing inside the process can open4// the connection again, so the client exits and the kubelet restarts the5// container with backoff until the compositor answers.6//7// `IDLE_WINDOW_GRACE_SECONDS` arms it, and the operator sets that variable on8// the idle container alone. A pod that expects no window sets it nowhere and9// the watchdog stays off.1011use std::time::{Duration, Instant};1213/// The exit code a client with no window leaves. The code is a contract with14/// whoever reads a container's last state: 7 means the compositor gave no15/// window, whichever client the image runs.16pub const NO_WINDOW: i32 = 7;1718/// The grace, and the moment the window went away.19#[derive(Debug)]20pub struct Watchdog {21 grace: Option<Duration>,22 missing_since: Option<Instant>,23}2425impl Watchdog {26 /// The watchdog the grace arms. A client starts with no window, so the27 /// grace runs from `now`.28 pub fn new(grace: Option<Duration>, now: Instant) -> Self {29 Self {30 grace,31 missing_since: Some(now),32 }33 }3435 /// The window went away, or one never arrived. A grace already running is36 /// left alone, so a second failure while it runs does not extend it.37 pub fn missing(&mut self, now: Instant) {38 self.missing_since.get_or_insert(now);39 }4041 /// A window is up. The grace stops.42 pub fn present(&mut self) {43 self.missing_since = None;44 }4546 /// Whether the grace is running. The loop takes every pass it can while it47 /// is, because a client with no window gets no event, and48 /// [`Watchdog::expire_if_late`] runs between passes.49 pub fn counting(&self) -> bool {50 self.grace.is_some() && self.missing_since.is_some()51 }5253 /// Leave the process when the grace has run out with no window. The54 /// message names the grace, so a person reading the container's log reads55 /// the number the operator set.56 pub fn expire_if_late(&self, now: Instant) {57 let Some(grace) = self.grace else {58 return;59 };60 if self.late(now) {61 self.expire(&format!("no window after {} seconds", grace.as_secs_f64()));62 }63 }6465 /// Whether the grace has run out with no window.66 fn late(&self, now: Instant) -> bool {67 match (self.grace, self.missing_since) {68 (Some(grace), Some(since)) => now.duration_since(since) >= grace,69 _ => false,70 }71 }7273 /// Leave the process, because there is no window and none is coming. A74 /// watchdog that no grace armed returns instead, so a run outside a pod75 /// carries on and its caller reports the failure its own way.76 ///77 /// One line says what happened and that the exit is deliberate, because a78 /// non-zero exit in a log reads as a crash otherwise.79 pub fn expire(&self, reason: &str) {80 if self.grace.is_none() {81 return;82 }83 eprintln!(84 "idle-screen: {reason}; exiting {NO_WINDOW} so the kubelet restarts this container"85 );86 std::process::exit(NO_WINDOW)87 }88}8990#[cfg(test)]91mod tests {92 use super::*;9394 fn armed(seconds: u64) -> (Watchdog, Instant) {95 let now = Instant::now();96 (Watchdog::new(Some(Duration::from_secs(seconds)), now), now)97 }9899 // `expire_if_late` leaves the process, so these tests read the grace100 // through `late`, which is the same answer that call acts on.101 #[test]102 fn an_unarmed_watchdog_never_expires() {103 let now = Instant::now();104 let watchdog = Watchdog::new(None, now);105 assert!(!watchdog.counting());106 assert!(!watchdog.late(now + Duration::from_secs(86_400)));107 }108109 #[test]110 fn the_grace_runs_from_the_launch() {111 let (watchdog, now) = armed(30);112 assert!(watchdog.counting());113 assert!(!watchdog.late(now + Duration::from_secs(29)));114 assert!(watchdog.late(now + Duration::from_secs(30)));115 }116117 #[test]118 fn a_window_stops_the_grace() {119 let (mut watchdog, now) = armed(30);120 watchdog.present();121 assert!(!watchdog.counting());122 assert!(!watchdog.late(now + Duration::from_secs(60)));123 }124125 #[test]126 fn a_window_that_goes_away_starts_the_grace_again() {127 let (mut watchdog, now) = armed(30);128 watchdog.present();129 watchdog.missing(now + Duration::from_secs(60));130131 assert!(watchdog.counting());132 assert!(!watchdog.late(now + Duration::from_secs(89)));133 assert!(watchdog.late(now + Duration::from_secs(90)));134 }135136 #[test]137 fn a_second_failure_does_not_extend_a_running_grace() {138 let (mut watchdog, now) = armed(30);139 watchdog.missing(now + Duration::from_secs(20));140 assert!(watchdog.late(now + Duration::from_secs(30)));141 }142143 #[test]144 fn a_grace_that_has_not_run_out_leaves_the_process_alone() {145 let (watchdog, now) = armed(30);146 watchdog.expire_if_late(now + Duration::from_secs(29));147 }148149 #[test]150 fn an_unarmed_watchdog_leaves_the_process_alone() {151 Watchdog::new(None, Instant::now()).expire("no window");152 }153}
idle/src/idle.rs 99.1%
1//! The idle screen: what the client draws while no `Play` runs.2//!3//! The whole screen is one `canvas`, and `draw` states the order the elements4//! go into it.5//!6//! That order holds between shapes, and it does not hold between a shape and7//! text. `iced_wgpu` renders a layer in four passes, quads then meshes then8//! images then text, and a canvas frame is one layer, so a rectangle drawn9//! last still lands under every line of text. An element that has to cover10//! text cannot be drawn over it. So the shade is a factor every element11//! applies to its own colours, and every `draw` below takes it.12//!13//! Every element draws in the display's own space, 1080 rows tall, and the14//! frame scales that space onto the surface. `media-operator`'s15//! `display/theme.lua` holds the same space for the same reason: one layout16//! serves 720, 1080, and 4K with no branch.1718pub mod activity;19pub mod clock;20pub mod energy;21pub mod identity;22pub mod mark;23pub mod preview;24pub mod shade;25pub mod volume;2627use iced_wgpu::Renderer;28use iced_widget::canvas;29use iced_winit::core::{Point, Rectangle, Size, Theme, mouse};3031use crate::look;32use crate::unit::Unit;3334/// The canvas frame every element draws into.35pub type Frame = canvas::Frame<Renderer>;3637/// The canvas the elements measure against.38///39/// The height is always 1080, and the width follows the surface's own ratio.40/// `look::canvas_width` states the rule and the reason.41#[derive(Debug, Clone, Copy, PartialEq)]42pub struct Layout {43 pub width: f32,44 pub height: f32,45 /// How many surface pixels one canvas pixel takes. The frame carries this46 /// as a transform, so no element multiplies a position by it. A stroke is47 /// the exception, because the toolkit tessellates a stroke at the width it48 /// is given after it has transformed the path, so a stroke width is stated49 /// in surface pixels and every element that strokes multiplies by this.50 pub scale: f32,51}5253impl Layout {54 /// The canvas for one surface size.55 pub fn for_surface(size: Size) -> Self {56 Self {57 width: look::canvas_width((size.width as u32, size.height as u32)),58 height: look::CANVAS_HEIGHT,59 scale: size.height / look::CANVAS_HEIGHT,60 }61 }6263 /// The x every flush-right element hangs from.64 pub fn right(&self) -> f32 {65 self.width - look::MARGIN_X66 }6768 /// The y every element standing on the bottom edge hangs from. It is the69 /// top margin, so the identity block balances the clock across the screen.70 pub fn bottom(&self) -> f32 {71 self.height - look::MARGIN_Y72 }7374 /// The middle of the canvas, where the mark rests.75 pub fn center(&self) -> Point {76 Point::new(self.width / 2.0, self.height / 2.0)77 }7879 /// The width the mark fills: a third of the canvas height at 16:9, or a80 /// third of the width on a screen narrower than that. The mark then keeps81 /// one size against the height, and a wide screen shows the same mark with82 /// more room beside it.83 pub fn mark_span(&self) -> f32 {84 self.width.min(self.height * 16.0 / 9.0) / 3.085 }8687 /// How one canvas row maps onto the surface.88 fn onto(&self, bounds: Rectangle) -> f32 {89 bounds.height / self.height90 }91}9293/// The screen, as the canvas draws it at one moment.94///95/// `at` is the second on the harness's clock, and every element is a function96/// of it and of the moments in `unit`. Nothing here counts frames, so a97/// captured frame is reproducible and a dropped frame is harmless.98#[derive(Debug)]99pub struct Idle<'a> {100 pub unit: &'a Unit,101 pub at: f64,102 /// Whether the preview keys are bound, which only a workstation does. The103 /// legend draws under it, so a cluster's idle screen never carries one.104 pub preview: bool,105}106107impl Idle<'_> {108 /// The second this screen next changes, which is the first second any one109 /// element changes at. `at` is what the harness's clock reads now, at or110 /// after the second of the last frame. A second at or before `at` asks the111 /// harness to draw now.112 ///113 /// Each element answers for itself, beside the code that computes its own114 /// drawing, so a change to an ease changes the schedule with it. Most of115 /// them ease or they are still: an ease asks to draw now, and a settled116 /// element states nothing. The clock and the volume row are the two that117 /// name a second ahead.118 ///119 /// Two of the elements `draw` lists answer through another. The activity120 /// line takes its opacity from the energy, and the preview legend draws121 /// one fixed run of text.122 ///123 /// The clock always names one, so this is never `None`. That matters124 /// because the client drains the broker in `tick`, and `tick` runs on a125 /// frame: a screen that answered `None` would sleep until an event that126 /// only a frame could read.127 pub fn next_frame(&self, at: f64) -> Option<f64> {128 [129 Some(clock::next_frame(at)),130 mark::next_frame(self.unit, at),131 energy::next_frame(self.unit, at),132 shade::next_frame(self.unit, at),133 identity::next_frame(self.unit, at),134 volume::next_frame(self.unit, at),135 ]136 .into_iter()137 .flatten()138 .min_by(f64::total_cmp)139 }140}141142impl<Message> canvas::Program<Message, Theme, Renderer> for Idle<'_> {143 type State = ();144145 /// The elements. The mark draws first, so the corner text draws over it146 /// if the two ever meet. The shade is read first and drawn by none of147 /// them: each element scales its own colours by it.148 fn draw(149 &self,150 _state: &Self::State,151 renderer: &Renderer,152 _theme: &Theme,153 bounds: Rectangle,154 _cursor: mouse::Cursor,155 ) -> Vec<canvas::Geometry<Renderer>> {156 let layout = Layout::for_surface(bounds.size());157 let mut frame = Frame::new(renderer, bounds.size());158 frame.scale(layout.onto(bounds));159160 let light = shade::fade(self.unit, self.at);161162 mark::draw(&mut frame, &layout, self.unit, self.at, light);163 clock::draw(&mut frame, &layout, self.unit, self.at, light);164 activity::draw(&mut frame, &layout, self.unit, self.at, light);165 identity::draw(&mut frame, &layout, self.unit, self.at, light);166 volume::draw(&mut frame, &layout, self.unit, self.at, light);167168 if self.preview {169 preview::legend(&mut frame, &layout, light);170 }171172 vec![frame.into_geometry()]173 }174}175176#[cfg(test)]177mod tests {178 use super::*;179 use media_screen::Moment;180 use media_screen::volume::Volume;181182 #[test]183 fn a_16_by_9_surface_gives_the_width_the_display_has_always_held() {184 let layout = Layout::for_surface(Size::new(1920.0, 1080.0));185 assert_eq!((layout.width, layout.height), (1920.0, 1080.0));186 }187188 #[test]189 fn a_wider_surface_widens_the_canvas_and_leaves_the_mark_alone() {190 let wide = Layout::for_surface(Size::new(2560.0, 1080.0));191 let square = Layout::for_surface(Size::new(1920.0, 1080.0));192 assert!(wide.width > square.width);193 assert_eq!(wide.mark_span(), square.mark_span());194 }195196 #[test]197 fn a_narrower_surface_keeps_the_mark_inside_the_margins() {198 let tall = Layout::for_surface(Size::new(1440.0, 1080.0));199 assert_eq!(tall.mark_span(), tall.width / 3.0);200 }201202 /// The screen one unit draws, with no preview keys bound.203 fn screen(unit: &Unit) -> Idle<'_> {204 Idle {205 unit,206 at: 0.0,207 preview: false,208 }209 }210211 #[test]212 fn a_settled_screen_draws_once_a_second_for_the_clock() {213 let unit = Unit::default();214 assert_eq!(screen(&unit).next_frame(0.0), Some(1.0));215 assert_eq!(screen(&unit).next_frame(11.75), Some(12.0));216 }217218 #[test]219 fn an_element_in_motion_takes_the_screen_ahead_of_the_clock() {220 let mut unit = Unit::default();221 unit.fold(Moment::Sleep, 10.0);222223 // The shade eases for four seconds, and a second at or before `at`224 // asks the harness to draw now.225 assert_eq!(screen(&unit).next_frame(10.5), Some(10.5));226 assert_eq!(screen(&unit).next_frame(14.5), Some(15.0));227 }228229 #[test]230 fn the_volume_rows_hold_never_outruns_the_clock() {231 let mut unit = Unit::default();232 unit.fold(233 Moment::Level {234 volume: Volume {235 level: 40,236 muted: false,237 },238 pressed: true,239 },240 10.0,241 );242243 // The row names 14.0, the second it starts to leave, and the clock244 // names the second after `at`. The screen takes the nearer of the two.245 assert_eq!(screen(&unit).next_frame(11.5), Some(12.0));246 }247248 #[test]249 fn the_margins_balance_across_the_screen() {250 let layout = Layout::for_surface(Size::new(1920.0, 1080.0));251 assert_eq!(layout.width - layout.right(), look::MARGIN_X);252 assert_eq!(layout.height - layout.bottom(), look::MARGIN_Y);253 }254}
idle/src/idle/activity.rs 83.3%
1//! The activity line: the one line the screen draws while a `Play` starts or2//! runs.3//!4//! The line draws under the clock and names the title, so the seconds a5//! playback pod pulls and starts read as work in progress and not as a screen6//! that ignored the button. The unit holds the last title after the `Play`7//! ends, because the line leaves with the mark's motion rather than vanishing8//! on the frame the activity changes.910use iced_winit::core::Point;1112use super::energy;13use super::{Frame, Layout};14use crate::look;15use crate::unit::Unit;16use media_screen::status::Activity;1718/// How opaque the line draws, from 0 clear to 1 full.19///20/// While a `Play` starts or runs the line is opaque, whatever the energy is.21/// After that it draws at the energy, so the ramp down that follows an arrival22/// carries the line out with the mark's motion, and a settled screen at energy23/// 0 draws no line at all.24pub fn opacity(unit: &Unit, at: f64) -> f32 {25 match unit.activity {26 Activity::Starting | Activity::Playing => 1.0,27 Activity::Idle => energy::level(unit, at) as f32,28 }29}3031/// The line the screen draws for one title.32///33/// The title takes real quotation marks and one ellipsis character, because the34/// line is read from across a room.35fn line(title: &str) -> String {36 format!("Playing \u{201c}{title}\u{201d}\u{2026}")37}3839/// Draw the element into the frame, in canvas units.40///41/// The line shares the clock's right edge and hangs one line pitch under it, so42/// the two read as one column and neither touches the other.43pub fn draw(frame: &mut Frame, layout: &Layout, unit: &Unit, at: f64, light: f32) {44 let Some(title) = &unit.title else {45 return;46 };47 let opacity = opacity(unit, at);48 if opacity <= 0.0 {49 return;50 }5152 frame.fill_text(look::line(53 line(title),54 Point::new(layout.right(), look::MARGIN_Y + look::LINE_PITCH),55 look::Anchor::TopRight,56 look::SMALL,57 look::under(look::faded(look::text(), opacity), light),58 ));59}6061#[cfg(test)]62mod tests {63 use super::*;64 use media_screen::Moment;65 use media_screen::status::{Play, Status};6667 /// A unit that read one status naming a `Play`.68 fn playing(activity: Activity, at: f64) -> Unit {69 let mut unit = Unit::default();70 unit.fold(71 Moment::Status(Status {72 activity,73 play: Some(Play {74 name: "den-tv-1".into(),75 title: "A Film".into(),76 }),77 ..Status::default()78 }),79 at,80 );81 unit82 }8384 #[test]85 fn the_line_names_the_title_in_quotation_marks() {86 assert_eq!(line("A Film"), "Playing \u{201c}A Film\u{201d}\u{2026}");87 }8889 #[test]90 fn the_line_is_opaque_while_a_play_starts_or_runs() {91 assert_eq!(opacity(&playing(Activity::Starting, 0.0), 0.1), 1.0);92 assert_eq!(opacity(&playing(Activity::Playing, 0.0), 9.0), 1.0);93 }9495 #[test]96 fn the_line_leaves_with_the_marks_motion() {97 let mut unit = playing(Activity::Starting, 0.0);98 unit.fold(Moment::Status(Status::default()), 1.2);99100 assert_eq!(opacity(&unit, 1.2), 1.0);101 assert!(opacity(&unit, 2.45) < 1.0);102 assert_eq!(opacity(&unit, 3.7), 0.0);103 }104105 #[test]106 fn a_settled_screen_draws_no_line() {107 let unit = playing(Activity::Idle, 0.0);108 assert_eq!(unit.title.as_deref(), Some("A Film"));109 assert_eq!(opacity(&unit, 5.0), 0.0);110 }111}
idle/src/idle/clock.rs 100.0%
1//! The clock: the wall-clock time at the top right.2//!3//! A viewer reads the hour without leaving the screen. `display/clock.lua`4//! draws the end of the film beside the time as well, from the duration and the5//! position `mpv` reports. An idle client plays no film and has no duration to6//! read, so it draws the time alone.78use iced_winit::core::Point;910use super::{Frame, Layout};11use crate::clock::now;12use crate::look;13use crate::unit::Unit;1415/// Draw the element into the frame, in canvas units.16///17/// The time hangs from the top margin at the right, the corner the activity18/// line and the volume row hang under. The anchor is the top of the line box,19/// so the tallest glyph of the face lands on the margin.20pub fn draw(frame: &mut Frame, layout: &Layout, _unit: &Unit, _at: f64, light: f32) {21 frame.fill_text(look::line(22 now().twelve_hour(),23 Point::new(layout.right(), look::MARGIN_Y),24 look::Anchor::TopRight,25 look::SMALL,26 look::under(look::text(), light),27 ));28}2930/// The second the clock next changes, for [`super::Idle::next_frame`].31///32/// The reading turns at a minute, and the answer is the next whole second.33/// The harness counts its clock from the first frame, and the wall clock's34/// minute lands anywhere inside that second, so a screen that woke once a35/// minute would draw the new minute up to a second late. The second is also36/// the backstop on every wait: a bus delivery wakes the loop itself, and37/// this bound is what still reads the broker if that wake ever fails.38pub fn next_frame(at: f64) -> f64 {39 at.floor() + 1.040}4142#[cfg(test)]43mod tests {44 use super::*;45 use iced_winit::core::Size;4647 #[test]48 fn the_next_frame_is_the_next_whole_second() {49 assert_eq!(next_frame(0.0), 1.0);50 assert_eq!(next_frame(0.25), 1.0);51 assert_eq!(next_frame(11.75), 12.0);52 }5354 #[test]55 fn the_time_hangs_from_the_top_right_margin() {56 let layout = Layout::for_surface(Size::new(1920.0, 1080.0));57 assert_eq!(layout.right(), 1780.0);58 assert_eq!(look::MARGIN_Y, 90.0);59 }60}
idle/src/idle/energy.rs 100.0%
1//! The energy: the one scalar that drives the mark's motion, and the alpha the2//! activity line leaves on.3//!4//! It runs from 0, the mark at rest, to 1, the mark at full swing. The5//! `brand` repository's `motion.md` states the rule: the mark moves only while6//! the system works on something a person waits for, and every change of7//! energy eases rather than steps. The numbers below state how.8//!9//! The activity in the `Player`'s retained status decides the target. Starting10//! ramps the energy up, because the seconds a playback pod pulls and starts are11//! the seconds a person waits. Playing stops the motion outright, because the12//! film's surface covers the idle surface and a frame drawn behind it is never13//! seen. Anything else eases the energy back to 0.1415use crate::unit::{Ramp, Unit};16use media_screen::status::Activity;1718/// The ramp up is shorter than the ramp down, so the mark reaches full swing19/// quickly when a `Play` starts, and returns to rest slowly when the film ends.20const RAMP_UP: f64 = 1.2;21const RAMP_DOWN: f64 = 2.5;2223/// The animation clock advances at this fraction of its full rate at energy 0,24/// and at the full rate at energy 1. The floor is above 0 so a ramp down slows25/// the motion without freezing it before the swing itself reaches 0.26const SPEED_FLOOR: f64 = 0.3;2728/// Smoothstep, the curve that starts and ends at zero slope. It is what makes a29/// ramp read as an ease and not as a straight climb. The shade eases on the30/// same curve.31pub fn ease(t: f64) -> f64 {32 let t = t.clamp(0.0, 1.0);33 t * t * (3.0 - 2.0 * t)34}3536/// The area under [`ease`] from 0 to `t`, which is `t^3 - t^4 / 2`.37///38/// The animation clock is an integral of the level over time, and the level39/// over a ramp is the ramp's two ends with [`ease`] between them. Integrating40/// that curve in closed form is what keeps the clock a function of the wall41/// clock: a frame the client drops costs the motion nothing, and a captured42/// frame is the same frame on every run.43fn area(t: f64) -> f64 {44 let t = t.clamp(0.0, 1.0);45 t.powi(3) - t.powi(4) / 2.046}4748/// The ramp in flight at one moment: its two ends, how long it takes, the49/// second it started, and the animation clock it carried into that second.50///51/// A ramp with both ends at 0 moves nothing, the clock stands still through52/// it, and a settled idle screen animates nothing at all.53#[derive(Debug, Clone, Copy, PartialEq)]54struct Flight {55 from: f64,56 to: f64,57 seconds: f64,58 since: f64,59 phase: f64,60}6162impl Flight {63 /// The energy at `at`, which is the ramp's two ends with the ease between64 /// them.65 fn level_at(&self, at: f64) -> f64 {66 self.from + (self.to - self.from) * ease((at - self.since) / self.seconds)67 }6869 /// Whether this ramp moves the mark at all. A ramp with both ends at 070 /// holds the energy at 0 and the animation clock where it stands, so every71 /// frame it covers draws the same mark.72 fn moves(&self) -> bool {73 self.from > 0.0 || self.to > 0.074 }7576 /// The animation clock at `at`.77 ///78 /// The clock runs at the floor rate at energy 0 and at the full rate at79 /// energy 1, so its reading is the integral of that rate over the seconds80 /// the mark moved. The ramp itself contributes the area under its own81 /// curve, and a ramp that has landed above 0 goes on turning the mark at a82 /// steady rate until the next moment.83 fn phase_at(&self, at: f64) -> f64 {84 if !self.moves() {85 return self.phase;86 }8788 let elapsed = (at - self.since).max(0.0);89 let ramping = elapsed.min(self.seconds);90 let level = self.from * ramping91 + (self.to - self.from) * self.seconds * area(ramping / self.seconds);92 let mut phase = self.phase + SPEED_FLOOR * ramping + (1.0 - SPEED_FLOOR) * level;9394 if self.to > 0.0 && elapsed > self.seconds {95 phase += (SPEED_FLOOR + (1.0 - SPEED_FLOOR) * self.to) * (elapsed - self.seconds);96 }9798 phase99 }100}101102/// The ramp in flight, from the moment the activity last changed and the103/// moment a new surface last came up.104///105/// The target and the duration come from the activity the unit holds now, and106/// the unit's [`Ramp`] holds where that ramp started. An arrival replaces the107/// whole ramp, because a `present` puts the energy at 1 whatever the ramp under108/// it was doing.109fn flight(unit: &Unit) -> Flight {110 let (to, seconds) = match unit.activity {111 Activity::Starting => (1.0, RAMP_UP),112 _ => (0.0, RAMP_DOWN),113 };114 let ramp = Flight {115 from: unit.ramp.from,116 to,117 seconds,118 since: unit.ramp.since,119 phase: unit.ramp.phase,120 };121122 // The arrival. A new surface after a film ends brings the mark back at full123 // swing and eases it to rest, so the screen returns in motion rather than124 // appearing frozen. An arrival that lands while a `Play` starts or runs125 // changes nothing, because that activity owns the energy, and a later126 // change of activity replaces the arrival for the same reason.127 match unit.presented {128 Some(presented)129 if presented >= unit.ramp.since130 && !matches!(unit.activity, Activity::Starting | Activity::Playing) =>131 {132 Flight {133 from: 1.0,134 to: 0.0,135 seconds: RAMP_DOWN,136 since: presented,137 phase: ramp.phase_at(presented),138 }139 }140 _ => ramp,141 }142}143144/// The energy at one moment.145pub fn level(unit: &Unit, at: f64) -> f64 {146 flight(unit).level_at(at)147}148149/// The animation clock the mark's sines read, in seconds.150///151/// The clock advances faster at a high energy than at a low one, which is how152/// the energy scales the speed and the swing together. It never resets, so a153/// change of energy changes the rate of the motion and never its position.154pub fn phase(unit: &Unit, at: f64) -> f64 {155 flight(unit).phase_at(at)156}157158/// The second the energy next changes, for [`crate::idle::Idle::next_frame`].159///160/// A ramp changes the energy on every frame it covers, and the harness reads161/// `at` itself as a draw now, so a ramp in flight answers with `at`. A ramp162/// that has landed states nothing, and so does one that moves nothing: the163/// energy then holds one level, and the mark states whether that level still164/// turns the animation clock.165pub fn next_frame(unit: &Unit, at: f64) -> Option<f64> {166 let flight = flight(unit);167168 (flight.moves() && at < flight.since + flight.seconds).then_some(at)169}170171/// The ramp the energy takes when the activity becomes `next` at `at`.172///173/// A ramp starts from the level the energy stands at, so a reversal turns the174/// motion around from where it is and the mark never jumps. `Playing` is the175/// one activity that steps rather than eases: a film covers this surface, and a176/// frame drawn behind it is never seen. An idle pod that starts in the middle177/// of a film reads its first retained status as `Playing` and lands here, so it178/// starts still instead of animating.179pub fn ramp(unit: &Unit, next: Activity, at: f64) -> Ramp {180 Ramp {181 since: at,182 from: match next {183 Activity::Playing => 0.0,184 _ => level(unit, at),185 },186 phase: phase(unit, at),187 }188}189190#[cfg(test)]191mod tests {192 use super::*;193 use media_screen::Moment;194 use media_screen::status::Status;195196 /// Two readings of one curve. The expected numbers below are read off the197 /// curve itself, so a difference this small is the arithmetic and not the198 /// motion.199 #[track_caller]200 fn assert_close(measured: f64, expected: f64) {201 assert!(202 (measured - expected).abs() < 1e-9,203 "{measured} is not {expected}"204 );205 }206207 /// A unit whose activity became `activity` at `at`, which is the one moment208 /// the energy reads.209 fn unit(activity: Activity, at: f64) -> Unit {210 let mut unit = Unit::default();211 unit.fold(212 Moment::Status(Status {213 activity,214 ..Status::default()215 }),216 at,217 );218 unit219 }220221 #[test]222 fn a_screen_that_has_read_nothing_rests() {223 let unit = Unit::default();224 assert_eq!(level(&unit, 0.0), 0.0);225 assert_eq!(level(&unit, 600.0), 0.0);226 }227228 #[test]229 fn a_starting_play_ramps_the_energy_to_full_swing() {230 let unit = unit(Activity::Starting, 1.0);231 assert_eq!(level(&unit, 1.0), 0.0);232 assert_close(level(&unit, 1.6), 0.5);233 assert_eq!(level(&unit, 2.2), 1.0);234 assert_eq!(level(&unit, 9.0), 1.0);235 }236237 #[test]238 fn a_film_stops_the_mark_with_no_ease() {239 let mut unit = unit(Activity::Starting, 0.0);240 unit.fold(241 Moment::Status(Status {242 activity: Activity::Playing,243 ..Status::default()244 }),245 0.6,246 );247 assert_eq!(level(&unit, 0.6), 0.0);248 assert_eq!(level(&unit, 0.7), 0.0);249 }250251 #[test]252 fn a_reversal_turns_around_from_where_the_mark_stands() {253 let mut unit = unit(Activity::Starting, 0.0);254 unit.fold(Moment::Status(Status::default()), 0.6);255256 assert_close(level(&unit, 0.6), 0.5);257 assert_close(level(&unit, 1.85), 0.25);258 assert_close(level(&unit, 3.1), 0.0);259 }260261 #[test]262 fn a_repeated_status_does_not_stretch_the_ramp() {263 let mut unit = unit(Activity::Starting, 0.0);264 unit.fold(265 Moment::Status(Status {266 activity: Activity::Starting,267 ..Status::default()268 }),269 0.6,270 );271 assert_eq!(level(&unit, 1.2), 1.0);272 }273274 #[test]275 fn an_arrival_brings_the_mark_back_at_full_swing() {276 let mut unit = unit(Activity::Playing, 1.0);277 unit.fold(Moment::Status(Status::default()), 4.0);278 unit.presented = Some(4.5);279280 assert_eq!(level(&unit, 4.5), 1.0);281 assert_close(level(&unit, 5.75), 0.5);282 assert_close(level(&unit, 7.0), 0.0);283 }284285 #[test]286 fn an_arrival_while_a_play_starts_changes_nothing() {287 let mut unit = unit(Activity::Starting, 1.0);288 unit.presented = Some(1.6);289 assert_close(level(&unit, 1.6), 0.5);290 }291292 #[test]293 fn a_status_after_an_arrival_takes_the_energy_the_arrival_reached() {294 let mut unit = Unit {295 presented: Some(0.0),296 ..Unit::default()297 };298 unit.fold(299 Moment::Status(Status {300 activity: Activity::Starting,301 ..Status::default()302 }),303 1.25,304 );305306 // The arrival had eased halfway to rest, and the ramp up leaves from307 // there rather than from 0.308 assert_close(level(&unit, 1.25), 0.5);309 assert!(level(&unit, 1.55) > 0.5);310 }311312 #[test]313 fn the_clock_stands_still_while_the_mark_rests() {314 let unit = Unit::default();315 assert_eq!(phase(&unit, 0.0), 0.0);316 assert_eq!(phase(&unit, 600.0), 0.0);317 }318319 #[test]320 fn the_clock_stands_still_under_a_film() {321 let mut unit = unit(Activity::Starting, 0.0);322 unit.fold(323 Moment::Status(Status {324 activity: Activity::Playing,325 ..Status::default()326 }),327 1.2,328 );329 assert_eq!(phase(&unit, 1.2), phase(&unit, 60.0));330 }331332 #[test]333 fn the_clock_runs_at_the_full_rate_at_full_swing() {334 let unit = unit(Activity::Starting, 0.0);335 let a_second = phase(&unit, 5.0) - phase(&unit, 4.0);336 assert!((a_second - 1.0).abs() < 1e-12, "{a_second}");337 }338339 #[test]340 fn the_clock_never_jumps_when_the_activity_changes() {341 let mut unit = unit(Activity::Starting, 0.0);342 let before = phase(&unit, 0.6);343 unit.fold(Moment::Status(Status::default()), 0.6);344 assert_eq!(phase(&unit, 0.6), before);345 }346347 #[test]348 fn the_clock_never_jumps_when_a_surface_arrives() {349 let mut unit = Unit::default();350 unit.fold(351 Moment::Status(Status {352 activity: Activity::Starting,353 ..Status::default()354 }),355 0.0,356 );357 unit.fold(Moment::Status(Status::default()), 1.2);358 let before = phase(&unit, 2.0);359360 unit.fold(Moment::Present, 2.0);361 unit.presented = Some(2.0);362 assert_eq!(phase(&unit, 2.0), before);363 }364365 /// The animation clock over one ramp, read from [`Flight::phase_at`].366 fn closed(from: f64, to: f64, seconds: f64, until: f64) -> f64 {367 Flight {368 from,369 to,370 seconds,371 since: 0.0,372 phase: 0.0,373 }374 .phase_at(until)375 }376377 /// The same clock by numeric integration.378 ///379 /// The clock is the integral of the rate over time, and the rate at one380 /// moment is the floor plus the rest of the range scaled by the level.381 /// [`ease`] clamps its own input, so a moment after the ramp lands reads the382 /// steady rate the ramp left the mark at. This is the midpoint rule over383 /// enough steps that its own error is under a nanosecond, which makes it a384 /// reading of the mathematics rather than a second copy of the code.385 fn integrated(from: f64, to: f64, seconds: f64, until: f64) -> f64 {386 const STEPS: usize = 100_000;387 let step = until / STEPS as f64;388389 (0..STEPS)390 .map(|index| {391 let at = (index as f64 + 0.5) * step;392 let level = from + (to - from) * ease(at / seconds);393394 step * (SPEED_FLOOR + (1.0 - SPEED_FLOOR) * level)395 })396 .sum()397 }398399 /// A reading of the integral and a reading of the closed form differ only400 /// by the midpoint rule's own error.401 const INTEGRAL_TOLERANCE: f64 = 1e-9;402403 #[test]404 fn the_clock_is_the_integral_of_the_rate_over_a_ramp_down() {405 let drift = closed(1.0, 0.0, RAMP_DOWN, 2.5) - integrated(1.0, 0.0, RAMP_DOWN, 2.5);406 assert!(drift.abs() < INTEGRAL_TOLERANCE, "{drift}");407 }408409 #[test]410 fn the_clock_is_the_integral_halfway_through_a_ramp() {411 let drift = closed(0.0, 1.0, RAMP_UP, 0.6) - integrated(0.0, 1.0, RAMP_UP, 0.6);412 assert!(drift.abs() < INTEGRAL_TOLERANCE, "{drift}");413 }414415 #[test]416 fn the_clock_holds_the_integral_for_seconds_after_a_ramp() {417 let drift = closed(0.0, 1.0, RAMP_UP, 6.0) - integrated(0.0, 1.0, RAMP_UP, 6.0);418 assert!(drift.abs() < INTEGRAL_TOLERANCE, "{drift}");419 }420421 #[test]422 fn the_clock_is_the_integral_over_a_reversal() {423 let drift = closed(0.4, 1.0, RAMP_UP, 4.0) - integrated(0.4, 1.0, RAMP_UP, 4.0);424 assert!(drift.abs() < INTEGRAL_TOLERANCE, "{drift}");425 }426427 // The phase is defined as this stepped loop: `phase = phase + TICK *428 // (SPEED_FLOOR + (1 - SPEED_FLOOR) * level)`, thirty steps a second, with429 // the level read after the ramp steps. The closed form above must land430 // where the loop lands, so the test below runs the loop to `until`431 // seconds and holds the two together.432 const TICK: f64 = 1.0 / 30.0;433434 fn accumulated(from: f64, to: f64, seconds: f64, until: f64) -> f64 {435 let mut phase = 0.0;436 let mut elapsed = 0.0;437 let mut frame = 0.0;438439 while frame + TICK <= until + 1e-9 {440 frame += TICK;441 elapsed += TICK;442 let level = from + (to - from) * ease(elapsed / seconds);443 phase += TICK * (SPEED_FLOOR + (1.0 - SPEED_FLOOR) * level);444 }445446 phase447 }448449 // The Lua adds one tick of the rate it reads at the end of each frame,450 // which is a right-hand Riemann sum of the integral the closed form takes.451 // Such a sum leads the integral by `(TICK / 2) * (rate at the end - rate at452 // the start)`, which is 0.012 s over a ramp between rest and full swing,453 // and the terms after that one are under a microsecond. The tolerance454 // leaves room for them.455 //456 // Twelve milliseconds of animation clock turns the fastest hexagon's sines457 // by 0.008 of a cycle, which moves a vertex by a third of a pixel on a458 // 1080-row canvas. The two screens draw the same frame.459 const LUA_TOLERANCE: f64 = 0.02;460461 #[test]462 fn the_closed_form_agrees_with_the_display_energy_lua_loop() {463 let drift = closed(0.0, 1.0, RAMP_UP, 1.2) - accumulated(0.0, 1.0, RAMP_UP, 1.2);464 assert!(drift.abs() < LUA_TOLERANCE, "{drift}");465 }466467 #[test]468 fn a_ramp_up_asks_for_a_frame_until_it_lands() {469 let unit = unit(Activity::Starting, 1.0);470 assert_eq!(next_frame(&unit, 1.0), Some(1.0));471 assert_eq!(next_frame(&unit, 2.19), Some(2.19));472 assert_eq!(next_frame(&unit, 2.21), None);473 }474475 #[test]476 fn a_ramp_down_asks_for_a_frame_for_the_whole_2500_ms() {477 let mut unit = unit(Activity::Starting, 0.0);478 unit.fold(Moment::Status(Status::default()), 2.0);479480 assert_eq!(next_frame(&unit, 4.4), Some(4.4));481 assert_eq!(next_frame(&unit, 4.5), None);482 }483484 #[test]485 fn an_arrival_asks_for_a_frame_from_the_second_the_surface_went_up() {486 let unit = Unit {487 presented: Some(10.0),488 ..Unit::default()489 };490491 assert_eq!(next_frame(&unit, 12.4), Some(12.4));492 assert_eq!(next_frame(&unit, 12.5), None);493 }494495 #[test]496 fn a_settled_screen_and_a_film_ask_for_no_frame() {497 assert_eq!(next_frame(&Unit::default(), 600.0), None);498 // A film steps the energy to 0 and covers this surface, so the ramp499 // under it moves nothing for the whole 2500 ms it is in flight.500 assert_eq!(next_frame(&unit(Activity::Playing, 0.0), 0.5), None);501 }502}
idle/src/idle/identity.rs 94.3%
1//! The identity block, the bottom-left element of the idle screen. It names2//! the unit and lists its parts, so a person reads what the unit is and what3//! it plays through while no film runs.4//!5//! Each part draws at its own brightness. A part with live state, a controller6//! that connects and disconnects, draws dim while it is away and eases back to7//! full when it returns. A part with no live state, a wired screen or its8//! built-in speakers, draws at full brightness always, because a part that9//! cannot be absent must not read as present-for-now.10//!11//! A part that reports a battery level draws it as a small bar in the12//! left margin, on the part's own line. The bar carries no number and no13//! charging state. Every bar ends on one column, left of the focus14//! marker's place, so the names keep their flush-left column and a person15//! reads the charges of two controllers against each other.16//!17//! The block also shows focus. The status marks at most one part focused, the18//! controller whose presses drive this unit, and that line takes a small19//! hexagon in the left margin. The seed carries no focus, so no marker draws20//! before the first status.21//!22//! Every brightness here is a function of the clock and of the seconds in23//! `Part`, so a captured frame is reproducible and a dropped frame is24//! harmless.2526use iced_widget::canvas::Path;27use iced_winit::core::{Color, Point, Rectangle};2829use super::{Frame, Layout};30use crate::look;31use crate::unit::{Part, Unit};3233/// The left edge every line of the block is flush with.34const LEFT: f32 = look::MARGIN_X;3536/// The header reads one step larger than the parts, so the unit's name leads37/// the list.38const HEADER_SIZE: f32 = look::LABEL;3940/// The line box a part's name draws in, in canvas pixels. `identity.lua` states41/// it as `theme.type.small - 4`, four ASS units under the shared small size, so42/// the parts read a touch lighter without changing that size for every other43/// element.44///45/// That one number does two jobs in the Lua, and this block keeps them apart.46/// `ITEM_BOX` is the layout measure, and every step and offset below is a47/// fraction of it. `ITEM_SIZE` is the type size the same number states.48const ITEM_BOX: f32 = look::SMALL_BOX - 4.0;4950/// The size a part's name draws at, which is `ITEM_BOX` through the face51/// metric.52const ITEM_SIZE: f32 = look::type_size(ITEM_BOX);5354/// The drop from one part's line to the next: 1.1 line boxes, the leading55/// `identity.lua` gives its list.56const ITEM_STEP: f32 = 1.1 * ITEM_BOX;5758/// The drop from the header to the first part, 1.3 line boxes. The gap is wider59/// than `ITEM_STEP`, so the name reads as the title of the list under it.60const HEADER_STEP: f32 = 1.3 * ITEM_BOX;6162/// The focus marker's radius, from its center to a vertex. It is a fifth of a63/// line box, so the marker scales with the line and stands 12 canvas pixels64/// tall beside a part's name.65const MARKER_R: f32 = ITEM_BOX / 5.0;6667/// The distance from the marker's center to `LEFT`. The marker draws inside68/// the margin, so the names keep one flush-left column with or without a69/// marker.70const MARKER_GAP: f32 = 18.0;7172/// The lift from the line's anchor, the bottom of the line box, to the middle73/// of the lowercase letters, where the marker's center draws. It is 0.42 of a74/// line box.75const MARKER_RISE: f32 = 0.42 * ITEM_BOX;7677/// The gap between the bar's right end and the marker's place. The bar78/// draws left of every marker, drawn or not, so the two never touch and79/// the bars of two parts end on one column whichever of them holds the80/// focus.81const BAR_GAP: f32 = 12.0;8283/// The column every bar ends on, which is the marker's place less the84/// gap.85const BAR_RIGHT: f32 = LEFT - MARKER_GAP - MARKER_R - BAR_GAP;8687/// The bar is two characters long and an eighth of a line box tall. It88/// reads as a measure beside the name and not as a second line of the89/// list.90const BAR_LENGTH: f32 = 2.0 * ITEM_SIZE;91const BAR_HEIGHT: f32 = ITEM_BOX / 8.0;9293/// Half the height rounds the ends to semicircles, which is the widest94/// radius `look::rounded` holds for a shape this thin.95const BAR_RADIUS: f32 = BAR_HEIGHT / 2.0;9697/// The charge the bar fills at, the top of the range a `Peripheral`98/// reports.99const FULL_CHARGE: f32 = 100.0;100101/// The time one part takes to move between dim and full.102const DIM_SECONDS: f64 = 0.4;103104/// A beat rises in this time and falls in `BEAT_FALL_SECONDS`. The rise is the105/// shorter of the two, so a controller that returns reads as one bright beat106/// and not as a blink.107const BEAT_RISE_SECONDS: f64 = 0.12;108const BEAT_FALL_SECONDS: f64 = 0.5;109110/// The marker's resting opacity on a lit line, one step under its own name.111const MARKER_REST: f32 = look::DIM;112113/// The marker's resting opacity on a dim line, which is `look::DIM` of114/// `look::DIM`. The marker keeps the same fraction of a dim line that it keeps115/// of a lit one, so it never reads brighter than the part it marks.116/// `identity.lua` states the same value as the ASS alpha byte 0xE1.117const MARKER_DIM: f32 = look::DIM * look::DIM;118119/// Draw the element into the frame, in canvas units.120///121/// The block draws nothing while nothing names the unit, so a client that has122/// no seed and no status shows the clock alone. The last part stands on the123/// bottom margin, and the block builds upward from there.124pub fn draw(frame: &mut Frame, layout: &Layout, unit: &Unit, at: f64, light: f32) {125 if unit.name.is_empty() {126 return;127 }128129 let bottom = layout.bottom();130 for (above, part) in unit.parts.iter().rev().enumerate() {131 let y = item_y(bottom, above);132 let drawn = line(part, y, at);133134 text(135 frame,136 y,137 &part.name,138 ITEM_SIZE,139 look::under(drawn.color, light),140 );141 if let Some(bar) = bar(part, y, at) {142 // The track reads as the room the charge has left, so it draws in the143 // line's own colour at the track opacity rather than in the volume144 // row's darker green.145 frame.fill(146 &look::rounded(bar.track, BAR_RADIUS),147 look::under(bar.color, look::TRACK_OPACITY * light),148 );149 if bar.fill.width >= 1.0 {150 frame.fill(151 &look::rounded(bar.fill, BAR_RADIUS),152 look::under(bar.color, light),153 );154 }155 }156 if let Some(marker) = drawn.marker {157 hexagon(158 frame,159 marker.center,160 MARKER_R,161 look::under(marker.color, light),162 );163 }164 }165166 text(167 frame,168 header_y(bottom, unit.parts.len()),169 &unit.name,170 HEADER_SIZE,171 look::under(look::text(), light),172 );173}174175/// One part's line at one moment: the color its name draws in, and its marker176/// when the marker draws.177#[derive(Debug, Clone, Copy, PartialEq)]178struct Line {179 color: Color,180 marker: Option<Marker>,181}182183/// The focus marker of one line.184#[derive(Debug, Clone, Copy, PartialEq)]185struct Marker {186 center: Point,187 color: Color,188}189190/// One part's line, from the part and the clock alone.191///192/// The marker draws for the focused part, and for a part mid-beat whose status193/// has not landed yet. A focus moment reaches the client before the status that194/// marks the part focused, and the beat shows either way.195fn line(part: &Part, y: f32, at: f64) -> Line {196 let level = brightness(part, at);197 let flash = flash(part, at);198 let focus = focus(part, at);199200 Line {201 color: look::faded(lit(flash), name_opacity(level)),202 marker: (part.focused || focus > 0.0).then(|| Marker {203 center: Point::new(LEFT - MARKER_GAP, y - MARKER_RISE),204 // The marker draws in the color its name draws in, so a controller205 // that returns flashes both.206 color: look::faded(lit(flash.max(focus)), marker_opacity(level, focus)),207 }),208 }209}210211/// The charge bar of one line: the track that carries the whole length,212/// the fill the charge covers, and the colour both draw in. Every part213/// that reports a charge draws one, and a part that reports none draws214/// nothing.215#[derive(Debug, Clone, Copy, PartialEq)]216struct Bar {217 track: Rectangle,218 fill: Rectangle,219 color: Color,220}221222/// One part's charge bar, from the part and the clock alone. It takes the223/// colour of the line, so the bar of a controller that is away dims with224/// its name and the bar of one that returns flashes with it.225fn bar(part: &Part, y: f32, at: f64) -> Option<Bar> {226 let level = part.battery?;227 let track = Rectangle {228 x: BAR_RIGHT - BAR_LENGTH,229 y: y - MARKER_RISE - BAR_HEIGHT / 2.0,230 width: BAR_LENGTH,231 height: BAR_HEIGHT,232 };233234 Some(Bar {235 track,236 fill: Rectangle {237 width: filled(level),238 ..track239 },240 color: line(part, y, at).color,241 })242}243244/// How much of the bar one charge fills, in canvas pixels. A reading245/// outside 0 to 100 fills to one end and no further.246fn filled(level: i64) -> f32 {247 BAR_LENGTH * (level as f32 / FULL_CHARGE).clamp(0.0, 1.0)248}249250/// One run of text, anchored at its bottom left.251///252/// ASS `\an1` anchors a run at the bottom left, so a line's position is its own253/// bottom and the stack builds from the bottom up. The line box is one type254/// size tall, so the leadings above are the gaps between the anchors and255/// nothing more.256fn text(frame: &mut Frame, y: f32, content: &str, size: f32, color: Color) {257 frame.fill_text(look::line(258 content.to_owned(),259 Point::new(LEFT, y),260 look::Anchor::BottomLeft,261 size,262 color,263 ))264}265266/// One filled hexagon about `center`, with the vertices `vertices` gives.267fn hexagon(frame: &mut Frame, center: Point, r: f32, color: Color) {268 let points = vertices(center, r);269 let path = Path::new(|builder| {270 builder.move_to(points[0]);271 for point in &points[1..] {272 builder.line_to(*point);273 }274 builder.close();275 });276277 frame.fill(&path, color);278}279280/// The y one part's line hangs from. `above` counts up the list from the last281/// part, which is the part on the bottom margin.282fn item_y(bottom: f32, above: usize) -> f32 {283 bottom - above as f32 * ITEM_STEP284}285286/// The y the unit's name hangs from, over `parts` lines of parts. A unit with287/// no parts is a name on the bottom margin.288fn header_y(bottom: f32, parts: usize) -> f32 {289 let first = item_y(bottom, parts.saturating_sub(1));290 if parts == 0 {291 first292 } else {293 first - HEADER_STEP294 }295}296297/// The six vertices of a pointy-top regular hexagon about `center`. `r` is the298/// distance from the center to a vertex, and 0.8660254 is cos(30 degrees), the299/// half-width of a pointy-top hexagon.300///301/// The marker is this formula, and not one of the fourteen hexagons that302/// `liken_iced::mark::hexagons` reads out of `liken.svg`. Those fourteen are303/// the same regular pointy-top shape, and each one draws with a stroke in its304/// own fill that rounds its corners. The marker's corners are sharp, and its305/// size comes from the line it marks, so the formula draws what the display306/// draws.307fn vertices(center: Point, r: f32) -> [Point; 6] {308 let half = 0.866_025_4 * r;309 [310 Point::new(center.x, center.y - r),311 Point::new(center.x + half, center.y - 0.5 * r),312 Point::new(center.x + half, center.y + 0.5 * r),313 Point::new(center.x, center.y + r),314 Point::new(center.x - half, center.y + 0.5 * r),315 Point::new(center.x - half, center.y - 0.5 * r),316 ]317}318319/// The brightness one part draws at, from 0 at `look::DIM` to 1 at full.320///321/// A part that reports no presence draws at full brightness always. A part that322/// has been away since the first status rests at dim, because the client saw no323/// moment to ease from.324///325/// A part that reports presence eases over `DIM_SECONDS` in both326/// directions, from the level `Part::brightness_from` recorded at the327/// moment it turned toward the end its presence names. `Unit` reads the328/// level through this function, so the curve is stated once.329pub fn brightness(part: &Part, at: f64) -> f32 {330 match (part.connected, part.returned, part.left) {331 (None, _, _) => 1.0,332 (Some(true), None, _) => 1.0,333 (Some(true), Some(returned), _) => between(334 part.brightness_from,335 1.0,336 progress(at - returned, DIM_SECONDS),337 ),338 (Some(false), _, None) => 0.0,339 (Some(false), _, Some(left)) => {340 between(part.brightness_from, 0.0, progress(at - left, DIM_SECONDS))341 }342 }343}344345/// The white flash on the name of a controller that returned: the beat346/// `returned` and `flash_from` state.347pub fn flash(part: &Part, at: f64) -> f32 {348 beat(part.returned, part.flash_from, at)349}350351/// The beat on the marker of a controller that took focus: the beat352/// `marked` and `focus_from` state.353pub fn focus(part: &Part, at: f64) -> f32 {354 beat(part.marked, part.focus_from, at)355}356357/// One white beat, 0 at rest and 1 at its peak. It rises from `moment` over358/// `BEAT_RISE_SECONDS` and falls over `BEAT_FALL_SECONDS`. Two beats run on one359/// part, the flash on the name of a controller that returned and the beat on360/// the marker of a controller that took focus, and both read their moment from361/// `Part`.362///363/// `from` is the level the beat stood at when the moment landed, so a364/// beat that lands inside another climbs from there instead of dropping365/// to rest first.366fn beat(moment: Option<f64>, from: f32, at: f64) -> f32 {367 let Some(moment) = moment else {368 return 0.0;369 };370371 let elapsed = at - moment;372 if elapsed < BEAT_RISE_SECONDS {373 between(from, 1.0, progress(elapsed, BEAT_RISE_SECONDS))374 } else {375 1.0 - progress(elapsed - BEAT_RISE_SECONDS, BEAT_FALL_SECONDS)376 }377}378379/// How far a move of `over` seconds has run at `elapsed` seconds, from 0 to 1.380/// The move is linear, which is the step the display takes on its timer. A381/// moment the clock has not reached yet gives 0.382fn progress(elapsed: f64, over: f64) -> f32 {383 (elapsed / over).clamp(0.0, 1.0) as f32384}385386/// The opacity one part's name draws at: opaque at brightness 1, and387/// `look::DIM` at 0.388fn name_opacity(brightness: f32) -> f32 {389 between(look::DIM, 1.0, brightness)390}391392/// The opacity one part's marker draws at. At rest it runs with the line's own393/// brightness, from `MARKER_DIM` on a disconnected line to `MARKER_REST` on a394/// lit one, so the marker reports the presence the name reports. The beat then395/// lifts it to opaque, and that lift is what a person sees when focus arrives.396fn marker_opacity(brightness: f32, beat: f32) -> f32 {397 let rest = between(MARKER_DIM, MARKER_REST, brightness);398399 between(rest, 1.0, beat)400}401402/// One value between `at_zero` and `at_one`, in step with `t` from 0 to 1.403fn between(at_zero: f32, at_one: f32, t: f32) -> f32 {404 at_zero + (at_one - at_zero) * t405}406407/// The whole of one beat, the rise and the fall together.408const BEAT_SECONDS: f64 = BEAT_RISE_SECONDS + BEAT_FALL_SECONDS;409410/// The second the block next changes, for [`super::Idle::next_frame`].411///412/// Every move here changes a colour on every frame it covers, so the block413/// answers with `at` and the harness draws now. A block whose parts have all414/// settled states nothing, which is every second of an idle screen except the415/// few after a controller arrives, leaves, or takes focus.416pub fn next_frame(unit: &Unit, at: f64) -> Option<f64> {417 unit.parts.iter().any(|part| moving(part, at)).then_some(at)418}419420/// Whether any of one part's three moves is still running at `at`: the change421/// between dim and full, the flash on a name that returned, and the beat on a422/// marker that took focus.423///424/// The presence decides which moment the brightness eases from, and this match425/// reads the same one [`brightness`] reads. A part that reports no presence426/// holds one brightness, and so does a part that has no moment to ease from.427fn moving(part: &Part, at: f64) -> bool {428 let dimming = match part.connected {429 Some(true) => running(part.returned, DIM_SECONDS, at),430 Some(false) => running(part.left, DIM_SECONDS, at),431 None => false,432 };433434 dimming || running(part.returned, BEAT_SECONDS, at) || running(part.marked, BEAT_SECONDS, at)435}436437/// Whether a move of `over` seconds from `moment` is still running at `at`. A438/// part that read no such moment never moves on it.439fn running(moment: Option<f64>, over: f64, at: f64) -> bool {440 moment.is_some_and(|moment| at < moment + over)441}442443/// The color one part draws in. At beat 0 it is `look::text()`, and at 1 every444/// channel reaches white, the bright beat of a controller that returned.445fn lit(beat: f32) -> Color {446 let text = look::text();447 let toward_white = |channel: f32| between(channel, 1.0, beat);448449 Color {450 r: toward_white(text.r),451 g: toward_white(text.g),452 b: toward_white(text.b),453 a: text.a,454 }455}456457#[cfg(test)]458mod tests {459 use super::*;460461 fn part(connected: Option<bool>) -> Part {462 Part {463 name: "A remote".into(),464 kind: "remote".into(),465 connected,466 ..Part::default()467 }468 }469470 /// Two readings of one measure, where `expected` is the number471 /// `identity.lua` rounds to and the port lands `tolerance` away from it.472 #[track_caller]473 fn assert_close(measured: f32, expected: f32, tolerance: f32) {474 assert!(475 (measured - expected).abs() < tolerance,476 "{measured} is not {expected}"477 );478 }479480 /// A part that reports `level`, for the bar the charge draws.481 fn charged(level: i64) -> Part {482 Part {483 battery: Some(level),484 ..part(Some(true))485 }486 }487488 #[test]489 fn a_charge_fills_that_fraction_of_the_bar() {490 assert_eq!(491 bar(&charged(50), 990.0, 10.0).expect("no bar").fill.width,492 BAR_LENGTH / 2.0493 );494 assert_eq!(495 bar(&charged(0), 990.0, 10.0).expect("no bar").fill.width,496 0.0497 );498 assert_eq!(499 bar(&charged(100), 990.0, 10.0).expect("no bar").fill.width,500 BAR_LENGTH501 );502 }503504 #[test]505 fn a_charge_outside_the_range_fills_the_bar_no_further_than_its_ends() {506 assert_eq!(507 bar(&charged(140), 990.0, 10.0).expect("no bar").fill.width,508 BAR_LENGTH509 );510 assert_eq!(511 bar(&charged(-5), 990.0, 10.0).expect("no bar").fill.width,512 0.0513 );514 }515516 #[test]517 fn a_part_that_reports_no_charge_draws_no_bar() {518 assert_eq!(bar(&part(Some(true)), 990.0, 10.0), None);519 }520521 #[test]522 fn the_track_carries_the_whole_bar_and_the_fill_starts_where_it_starts() {523 let drawn = bar(&charged(50), 990.0, 10.0).expect("no bar");524525 assert_eq!(drawn.track.width, BAR_LENGTH);526 assert_eq!(drawn.fill.x, drawn.track.x);527 assert_eq!(drawn.fill.y, drawn.track.y);528 assert_eq!(drawn.fill.height, drawn.track.height);529 }530531 #[test]532 fn every_bar_ends_on_one_column_clear_of_the_marker_and_inside_the_margin() {533 let drawn = bar(&charged(50), 990.0, 10.0).expect("no bar");534 let right = drawn.track.x + drawn.track.width;535 // `vertices` puts the marker's left vertices this far left of its536 // centre, and the centre stands `MARKER_GAP` inside `LEFT`.537 let marker_left = LEFT - MARKER_GAP - 0.866_025_4 * MARKER_R;538539 assert_eq!(right, LEFT - MARKER_GAP - MARKER_R - BAR_GAP);540 assert!(right < marker_left, "{right} touches the marker");541 assert!(drawn.track.x > 0.0, "{} is off the screen", drawn.track.x);542 assert_eq!(543 bar(&charged(100), 924.0, 10.0).expect("no bar").track.x,544 drawn.track.x545 );546 }547548 #[test]549 fn the_bar_centres_on_the_rise_the_marker_takes() {550 let drawn = bar(&charged(50), 990.0, 10.0).expect("no bar");551552 assert_close(553 drawn.track.y + drawn.track.height / 2.0,554 990.0 - MARKER_RISE,555 0.001,556 );557 }558559 #[test]560 fn the_bar_draws_in_the_colour_of_the_line_it_stands_on() {561 let away = Part {562 battery: Some(50),563 ..part(Some(false))564 };565566 assert_eq!(567 bar(&charged(50), 990.0, 10.0).expect("no bar").color,568 line(&charged(50), 990.0, 10.0).color569 );570 assert!(571 bar(&away, 990.0, 10.0).expect("no bar").color.a572 < bar(&charged(50), 990.0, 10.0).expect("no bar").color.a573 );574 }575576 #[test]577 fn a_returning_parts_bar_flashes_white_with_its_name() {578 let returning = Part {579 returned: Some(10.0),580 ..charged(50)581 };582583 assert_eq!(bar(&returning, 990.0, 10.12).expect("no bar").color.r, 1.0);584 }585586 #[test]587 fn the_markers_two_resting_opacities_are_the_lua_alphas() {588 // `identity.lua` states them as the ASS alpha bytes 0xA8 and 0xE1,589 // which leave 87 and 30 of the 255 steps of light. Half a step is590 // under what a panel draws.591 assert_close(MARKER_REST, 87.0 / 255.0, 0.5 / 255.0);592 assert_close(MARKER_DIM, 30.0 / 255.0, 0.5 / 255.0);593 }594595 #[test]596 fn the_block_measures_in_line_boxes_and_draws_at_the_size_that_box_states() {597 // `identity.lua` lists its parts in a 30 pixel line box, which is 22.6598 // canvas pixels of type, and it places the marker a fifth of that box599 // wide and 0.42 of it over the line.600 assert_close(ITEM_BOX, 30.0, 0.1);601 assert_close(ITEM_SIZE, 22.6, 0.1);602 assert_close(MARKER_R, 6.0, 0.1);603 assert_close(MARKER_RISE, 12.6, 0.1);604 // The charge bar is two of those 22.6 pixel characters long and an605 // eighth of the line box tall.606 assert_close(BAR_LENGTH, 45.2, 0.2);607 assert_close(BAR_HEIGHT, 3.75, 0.01);608 }609610 #[test]611 fn a_part_with_no_live_state_draws_at_full_brightness_always() {612 let part = part(None);613 assert_eq!(brightness(&part, 0.0), 1.0);614 assert_eq!(brightness(&part, 1_000.0), 1.0);615 assert_eq!(name_opacity(brightness(&part, 1_000.0)), 1.0);616 }617618 #[test]619 fn a_disconnected_part_rests_at_the_dim_alpha() {620 let part = part(Some(false));621 assert_eq!(brightness(&part, 12.0), 0.0);622 assert_eq!(name_opacity(0.0), look::DIM);623 }624625 #[test]626 fn a_part_that_never_left_draws_full_with_no_ease() {627 assert_eq!(brightness(&part(Some(true)), 12.0), 1.0);628 }629630 #[test]631 fn a_part_that_went_away_eases_down_over_400_ms() {632 let part = Part {633 left: Some(10.0),634 brightness_from: 1.0,635 ..part(Some(false))636 };637638 assert_eq!(brightness(&part, 10.0), 1.0);639 assert_eq!(brightness(&part, 10.2), 0.5);640 assert_eq!(brightness(&part, 10.4), 0.0);641 assert_eq!(brightness(&part, 30.0), 0.0);642 }643644 #[test]645 fn a_part_that_has_been_away_since_the_first_status_rests_at_dim() {646 assert_eq!(brightness(&part(Some(false)), 12.0), 0.0);647 }648649 #[test]650 fn a_part_that_returned_eases_to_full_over_400_ms() {651 let part = Part {652 returned: Some(10.0),653 ..part(Some(true))654 };655656 assert_eq!(brightness(&part, 10.0), 0.0);657 assert_eq!(brightness(&part, 10.2), 0.5);658 assert_eq!(brightness(&part, 10.4), 1.0);659 assert_eq!(brightness(&part, 30.0), 1.0);660 }661662 #[test]663 fn a_part_that_came_back_mid_fade_eases_up_from_the_level_it_stood_at() {664 // The part left at 10.0 and came back at 10.2, halfway down the665 // 400 ms fade, so the ease up leaves from half brightness.666 let part = Part {667 returned: Some(10.2),668 left: Some(10.0),669 brightness_from: 0.5,670 ..part(Some(true))671 };672673 assert_eq!(brightness(&part, 10.2), 0.5);674 assert_eq!(brightness(&part, 10.4), 0.75);675 assert_eq!(brightness(&part, 10.6), 1.0);676 }677678 #[test]679 fn a_part_that_went_away_mid_ease_fades_down_from_the_level_it_stood_at() {680 let part = Part {681 returned: Some(10.0),682 left: Some(10.2),683 brightness_from: 0.5,684 ..part(Some(false))685 };686687 assert_eq!(brightness(&part, 10.2), 0.5);688 assert_eq!(brightness(&part, 10.4), 0.25);689 assert_eq!(brightness(&part, 10.6), 0.0);690 }691692 #[test]693 fn a_beat_peaks_at_120_ms_and_falls_over_500_ms() {694 let moment = Some(10.0);695696 assert_eq!(beat(moment, 0.0, 10.0), 0.0);697 assert_eq!(beat(moment, 0.0, 10.06), 0.5);698 assert_eq!(beat(moment, 0.0, 10.12), 1.0);699 assert_eq!(beat(moment, 0.0, 10.37), 0.5);700 assert_eq!(beat(moment, 0.0, 10.62), 0.0);701 assert_eq!(beat(moment, 0.0, 30.0), 0.0);702 }703704 #[test]705 fn a_beat_that_has_not_landed_is_at_rest() {706 assert_eq!(beat(None, 0.0, 10.0), 0.0);707 assert_eq!(beat(Some(12.0), 0.0, 10.0), 0.0);708 }709710 #[test]711 fn a_second_focus_beat_inside_the_first_climbs_from_the_level_it_held() {712 // The first beat stood at half on its way down when the second713 // focus moment landed at 10.37, so the marker climbs from 0.5 to714 // 1 over the 120 ms rise rather than dropping to rest first.715 let part = Part {716 marked: Some(10.37),717 focus_from: 0.5,718 ..part(Some(true))719 };720721 assert_eq!(focus(&part, 10.37), 0.5);722 assert_eq!(focus(&part, 10.43), 0.75);723 assert_eq!(focus(&part, 10.49), 1.0);724 }725726 #[test]727 fn a_second_flash_inside_the_first_climbs_from_the_level_it_held() {728 let part = Part {729 returned: Some(10.37),730 flash_from: 0.5,731 ..part(Some(true))732 };733734 assert_eq!(flash(&part, 10.37), 0.5);735 assert_eq!(flash(&part, 10.49), 1.0);736 }737738 #[test]739 fn a_flash_reaches_white_and_settles_on_the_text_color() {740 assert_eq!(lit(0.0), look::text());741 assert_eq!(lit(1.0), Color::from_rgb(1.0, 1.0, 1.0));742743 let half = lit(0.5);744 assert!(half.r > look::text().r && half.r < 1.0);745 }746747 #[test]748 fn the_marker_rests_one_step_under_the_line_it_marks() {749 assert_eq!(marker_opacity(1.0, 0.0), look::DIM);750 assert_eq!(marker_opacity(0.0, 0.0), MARKER_DIM);751 assert!(marker_opacity(0.0, 0.0) < marker_opacity(1.0, 0.0));752 }753754 #[test]755 fn a_focus_beat_lifts_the_marker_to_opaque_and_back() {756 let moment = Some(10.0);757758 assert_eq!(marker_opacity(1.0, beat(moment, 0.0, 10.12)), 1.0);759 assert_eq!(marker_opacity(0.0, beat(moment, 0.0, 10.12)), 1.0);760 assert!(marker_opacity(1.0, beat(moment, 0.0, 10.37)) > look::DIM);761 assert_eq!(marker_opacity(1.0, beat(moment, 0.0, 10.62)), look::DIM);762 }763764 #[test]765 fn a_part_the_status_marks_focused_takes_the_marker() {766 let part = Part {767 focused: true,768 ..part(Some(true))769 };770 let marker = line(&part, 990.0, 10.0).marker.expect("no marker");771772 assert_eq!(marker.center, Point::new(122.0, 990.0 - MARKER_RISE));773 assert_eq!(marker.color.a, look::DIM);774 }775776 #[test]777 fn a_part_mid_beat_takes_the_marker_before_its_status_lands() {778 let part = Part {779 marked: Some(10.0),780 ..part(Some(true))781 };782783 assert!(line(&part, 990.0, 10.12).marker.is_some());784 assert!(line(&part, 990.0, 30.0).marker.is_none());785 }786787 #[test]788 fn a_part_with_no_focus_and_no_beat_takes_no_marker() {789 assert_eq!(line(&part(Some(true)), 990.0, 10.0).marker, None);790 }791792 #[test]793 fn a_returning_part_flashes_its_name_and_its_marker_white() {794 let part = Part {795 focused: true,796 returned: Some(10.0),797 ..part(Some(true))798 };799 let drawn = line(&part, 990.0, 10.12);800801 assert_eq!(drawn.color.r, 1.0);802 assert_eq!(drawn.marker.expect("no marker").color.r, 1.0);803 }804805 #[test]806 fn the_parts_stack_upward_from_the_bottom_margin() {807 // One step is 1.1 of the 30 canvas pixels `identity.lua` gives a808 // part's line box, and that file rounds each step to 33.809 assert_close(item_y(990.0, 0), 990.0, 0.2);810 assert_close(item_y(990.0, 1), 957.0, 0.2);811 assert_close(item_y(990.0, 2), 924.0, 0.2);812 }813814 #[test]815 fn the_header_reads_a_wider_gap_over_the_first_part() {816 // The header's step is 1.3 of the same line box, which `identity.lua`817 // rounds to 39, six pixels wider than a step between two parts.818 assert_close(header_y(990.0, 3), 924.0 - 39.0, 0.2);819 assert_close(header_y(990.0, 1), 990.0 - 39.0, 0.2);820 assert_close(header_y(990.0, 0), 990.0, 0.2);821 }822823 /// A unit whose one part is `part`.824 fn unit(part: Part) -> Unit {825 Unit {826 name: "The Den".into(),827 parts: vec![part],828 ..Unit::default()829 }830 }831832 #[test]833 fn a_part_that_went_away_asks_for_a_frame_for_400_ms() {834 let unit = unit(Part {835 left: Some(10.0),836 ..part(Some(false))837 });838839 assert_eq!(next_frame(&unit, 10.39), Some(10.39));840 assert_eq!(next_frame(&unit, 10.41), None);841 }842843 #[test]844 fn a_part_that_returned_asks_for_a_frame_for_the_whole_620_ms_beat() {845 // The name eases up over 400 ms and the flash runs 120 ms up and 500846 // down, so the beat outlasts the ease and sets the schedule.847 let unit = unit(Part {848 returned: Some(10.0),849 ..part(Some(true))850 });851852 assert_eq!(next_frame(&unit, 10.61), Some(10.61));853 assert_eq!(next_frame(&unit, 10.63), None);854 }855856 #[test]857 fn a_marker_that_took_focus_asks_for_a_frame_while_it_beats() {858 let unit = unit(Part {859 focused: true,860 marked: Some(10.0),861 ..part(Some(true))862 });863864 assert_eq!(next_frame(&unit, 10.61), Some(10.61));865 assert_eq!(next_frame(&unit, 10.63), None);866 }867868 #[test]869 fn a_block_whose_parts_have_settled_asks_for_no_frame() {870 assert_eq!(next_frame(&unit(part(None)), 600.0), None);871 assert_eq!(next_frame(&unit(part(Some(true))), 600.0), None);872 assert_eq!(next_frame(&unit(part(Some(false))), 600.0), None);873 assert_eq!(next_frame(&Unit::default(), 600.0), None);874 }875876 #[test]877 fn the_marker_is_a_regular_pointy_top_hexagon() {878 let center = Point::new(122.0, 900.0);879 let points = vertices(center, MARKER_R);880881 for point in points {882 let reach = ((point.x - center.x).powi(2) + (point.y - center.y).powi(2)).sqrt();883 assert!(884 (reach - MARKER_R).abs() < 0.001,885 "{reach} is not {MARKER_R}"886 );887 }888889 let width = points[1].x - points[5].x;890 let height = points[3].y - points[0].y;891 assert!((height - 2.0 * MARKER_R).abs() < 0.001, "{height}");892 assert!((width / height - 0.866).abs() < 0.001);893 }894}
idle/src/idle/mark.rs 100.0%
1//! The mark: the fourteen-hexagon mosaic at the middle of the screen.2//!3//! The geometry, the colours, and the pulse are the `brand` repository's, and4//! `liken-iced` reads them out of `liken.svg`. This module places the mark on5//! the canvas and hands it the energy. Where the mark draws and how large it6//! draws are this display's layout and not the brand's.78use super::energy;9use super::{Frame, Layout};10use crate::unit::Unit;1112/// Draw the element into the frame, in canvas units.13///14/// The mark draws at the shade's own factor and at no opacity of its own, so15/// the whole screen darkens as one when the quiet window runs out.16///17/// The mark is fourteen filled paths whose corners are geometry, so it needs18/// nothing from the canvas scale. `liken_iced::mark::outline` says why.19pub fn draw(frame: &mut Frame, layout: &Layout, unit: &Unit, at: f64, light: f32) {20 liken_iced::mark::draw(21 frame,22 layout.center(),23 layout.mark_span(),24 energy::level(unit, at),25 energy::phase(unit, at),26 light,27 );28}2930/// The second the mark next changes, for [`super::Idle::next_frame`].31///32/// The animation clock runs at the speed floor or above it while the energy is33/// above 0, and a ramp that lands above 0 goes on turning the mark after it,34/// so the mark answers with `at` and the harness draws now. At energy 0 the35/// swing is 0 and the animation clock stands still, so every frame draws the36/// fourteen hexagons in the same places and the mark states nothing.37pub fn next_frame(unit: &Unit, at: f64) -> Option<f64> {38 (energy::level(unit, at) > 0.0).then_some(at)39}4041#[cfg(test)]42mod tests {43 use super::*;44 use iced_winit::core::Size;45 use media_screen::Moment;46 use media_screen::status::{Activity, Status};4748 fn layout() -> Layout {49 Layout::for_surface(Size::new(1920.0, 1080.0))50 }5152 #[test]53 fn the_mosaic_is_fourteen_hexagons() {54 assert_eq!(liken_iced::mark::hexagons().len(), 14);55 }5657 #[test]58 fn a_starting_play_swings_the_mosaic_and_rest_holds_it_still() {59 let mut unit = Unit::default();60 let layout = layout();61 let hexagon = &liken_iced::mark::hexagons()[0];62 let placed = |unit: &Unit, at: f64| {63 hexagon.place(64 layout.center(),65 layout.mark_span(),66 energy::level(unit, at),67 energy::phase(unit, at),68 )69 };7071 let still = placed(&unit, 0.0);72 assert_eq!(placed(&unit, 9.0).points, still.points);7374 unit.fold(75 Moment::Status(Status {76 activity: Activity::Starting,77 ..Status::default()78 }),79 0.0,80 );81 assert_ne!(placed(&unit, 2.0).points, still.points);82 }8384 #[test]85 fn a_swinging_mark_asks_for_a_frame_now() {86 let mut unit = Unit::default();87 unit.fold(88 Moment::Status(Status {89 activity: Activity::Starting,90 ..Status::default()91 }),92 0.0,93 );9495 // The ramp has landed at full swing by 1.2 seconds, and the mark goes96 // on turning at that level with no ramp under it.97 assert_eq!(next_frame(&unit, 0.6), Some(0.6));98 assert_eq!(next_frame(&unit, 30.0), Some(30.0));99 }100101 #[test]102 fn a_mark_at_rest_asks_for_no_frame() {103 assert_eq!(next_frame(&Unit::default(), 600.0), None);104 }105}
idle/src/idle/preview.rs 96.5%
1//! The preview keys, the workstation stand-in for the bus, and the legend that2//! names them on the screen itself.3//!4//! On a cluster the idle command pod publishes the `Player`'s status, its level,5//! and the screen's moments, and the pod has no keyboard. On a workstation6//! `local/idle` sets `IDLE_PREVIEW=1` and the keys below build the same7//! [`Moment`] values those topics carry. The client folds each one through8//! `Client::receive`, the call the bus reader's own messages take, so a person9//! plays every ramp, every dim, and every beat against the real handlers and10//! not against a second set written for a workstation.1112use iced_winit::core::Point;1314use super::{Frame, Layout};15use crate::look;16use media_screen::Moment;17use media_screen::status::{Activity, Component, Play, Status};18use media_screen::volume::{MIN_LEVEL, UNITY_LEVEL, Volume};1920/// The kind the preview gives every part but the last. A screen and a set of21/// speakers both draw at full brightness with no presence, so one word covers22/// both and the last part alone carries the presence the `d` key toggles.23const SINK: &str = "sink";2425/// The charge the preview's controller reports, so the identity block draws26/// a level beside the name the way a bonded device makes it draw one.27const PREVIEW_BATTERY: i64 = 62;2829/// The `Play` the preview names while one starts or runs. The operator30/// publishes the object's name and the one line the screen draws, so the31/// preview states both.32const PLAY_NAME: &str = "sailing";33const PLAY_TITLE: &str = "Sailing";3435/// The controller a focus moment names. A focus counts the controllers in the36/// order the status lists them, and the preview's status lists one, so the37/// index is always the first.38const REMOTE: usize = 0;3940/// How far one volume key moves the level, out of the 100 that is unity: a41/// step big enough to read on the bar at a glance, and small enough that the42/// range takes twenty of them.43const VOLUME_STEP: i64 = 5;4445/// The state behind the keys: what the fake status says about the unit now.46///47/// The bus holds this state on a cluster, and the client only reads it. With no48/// broker the presses have to hold it themselves, so each key changes one field49/// here and then states the whole unit again, the way the operator republishes50/// a whole status for one change.51#[derive(Debug, Clone, PartialEq, Eq)]52pub struct Keys {53 /// The unit's name and its parts, from the same environment seed the54 /// operator sets on the idle container. The preview calls the last part the55 /// remote, because the parts read in the order the operator lists them and56 /// a controller is the part a person adds last.57 name: String,58 parts: Vec<String>,59 activity: Activity,60 /// Whether the remote is connected. The `d` key toggles it.61 connected: bool,62 /// Whether the status marks the remote focused. The `f` key sets it and63 /// beats the marker, the way a live mark landing here does, and the `g` key64 /// clears it, the way a cycle onto another unit does.65 focused: bool,66 /// Whether the `s` key last put the screen to sleep, so the one key plays67 /// the two edges of the fade in turn.68 asleep: bool,69 volume: Volume,70}7172impl Keys {73 /// The keys for a unit the environment named. The seed carries names and no74 /// kinds, so every part reads as a sink until the last one, which is the75 /// remote.76 pub fn seeded(name: String, parts: Vec<String>) -> Self {77 Self {78 name,79 parts,80 activity: Activity::Idle,81 connected: true,82 focused: false,83 asleep: false,84 volume: Volume::default(),85 }86 }8788 /// The messages one key press stands for, in the order the bus would carry89 /// them. A key this module does not bind carries nothing, so the arrow keys90 /// the harness also delivers change nothing here.91 pub fn press(&mut self, key: &str) -> Vec<Moment> {92 match key {93 "p" => vec![self.doing(Activity::Starting)],94 "o" => vec![self.doing(Activity::Playing)],95 // The end of a film: the status returns to `Idle` and the sidecar96 // reports the idle surface back in view, in that order.97 "i" => vec![self.doing(Activity::Idle), Moment::Present],98 "d" => {99 self.connected = !self.connected;100 vec![self.status()]101 }102 // A live focus message naming this unit: the status marks the103 // remote and the moment beats its marker. A first press draws the104 // marker and beats it, and every later press beats it again, which105 // is the cycle press that wraps onto the unit already focused.106 "f" => {107 self.focused = true;108 vec![self.status(), Moment::Focus { remote: REMOTE }]109 }110 // The focus cycling away. The status stops marking the remote, the111 // marker goes, and nothing beats.112 "g" => {113 self.focused = false;114 vec![self.status()]115 }116 "s" => {117 self.asleep = !self.asleep;118 vec![match self.asleep {119 true => Moment::Sleep,120 false => Moment::Wake,121 }]122 }123 "9" => vec![self.step(-VOLUME_STEP)],124 "0" => vec![self.step(VOLUME_STEP)],125 "m" => {126 self.volume.muted = !self.volume.muted;127 vec![self.level()]128 }129 _ => Vec::new(),130 }131 }132133 /// Move the unit to one activity and state the whole status again.134 fn doing(&mut self, activity: Activity) -> Moment {135 self.activity = activity;136 self.status()137 }138139 /// The status as the operator would publish it now. The `Play` block140 /// appears only while a `Play` starts or runs, which is the rule the141 /// operator writes it under.142 fn status(&self) -> Moment {143 let last = self.parts.len().saturating_sub(1);144 let components = self145 .parts146 .iter()147 .enumerate()148 .map(|(index, name)| {149 if index == last {150 Component {151 name: name.clone(),152 kind: Component::REMOTE.to_string(),153 connected: Some(self.connected),154 battery: Some(PREVIEW_BATTERY),155 focused: self.focused.then_some(true),156 }157 } else {158 Component {159 name: name.clone(),160 kind: SINK.to_string(),161 connected: None,162 battery: None,163 focused: None,164 }165 }166 })167 .collect();168169 Moment::Status(Status {170 display_name: self.name.clone(),171 activity: self.activity,172 play: (self.activity != Activity::Idle).then(|| Play {173 name: PLAY_NAME.to_string(),174 title: PLAY_TITLE.to_string(),175 }),176 components,177 })178 }179180 /// Move the level by one step, held inside the range the operator holds it181 /// in, and state it.182 fn step(&mut self, step: i64) -> Moment {183 self.volume.level = (self.volume.level + step).clamp(MIN_LEVEL, UNITY_LEVEL);184 self.level()185 }186187 /// The level as a press. The first level of a bus session is the broker's188 /// retained catch-up, which a person did not press and which draws no189 /// indicator. A key is a press every time, so the indicator shows every190 /// time.191 fn level(&self) -> Moment {192 Moment::Level {193 volume: self.volume,194 pressed: true,195 }196 }197}198199/// The legend, one key and what it does per pair, in the order the line reads200/// them. The screen names the keys itself and not only in this file.201const LEGEND: [(&str, &str); 10] = [202 ("p", "play starts"),203 ("o", "playing"),204 ("i", "film ends"),205 ("d", "presence"),206 ("f", "focus"),207 ("g", "unfocus"),208 ("s", "sleep"),209 ("9/0", "volume"),210 ("m", "mute"),211 // The one key of the legend this module does not bind. The harness reads212 // the quit key itself, and the legend names it beside the rest because a213 // person reading the line needs the way out.214 (crate::harness::QUIT, "quit"),215];216217/// The gap between one pair and the next, wide enough that a reader takes the218/// key and its words as one.219const LEGEND_GAP: &str = " ";220221/// The legend as one line.222fn legend_line() -> String {223 LEGEND224 .iter()225 .map(|(key, does)| format!("{key} {does}"))226 .collect::<Vec<String>>()227 .join(LEGEND_GAP)228}229230/// Draw the legend. The client calls this only where the preview keys are231/// bound, so a cluster's idle screen carries none.232///233/// The line hangs from its own bottom-right corner, which mirrors the identity234/// block's bottom-left across the screen.235pub fn legend(frame: &mut Frame, layout: &Layout, light: f32) {236 frame.fill_text(look::line(237 legend_line(),238 Point::new(layout.right(), layout.bottom()),239 look::Anchor::BottomRight,240 look::TINY,241 look::under(look::faded(look::muted(), look::DIM), light),242 ));243}244245#[cfg(test)]246mod tests {247 use super::*;248249 fn keys() -> Keys {250 Keys::seeded(251 "Studio Lab".to_string(),252 vec![253 "Portable Screen".to_string(),254 "Built-in Speakers".to_string(),255 "Studio Dualsense".to_string(),256 ],257 )258 }259260 fn status(moment: &Moment) -> &Status {261 let Moment::Status(status) = moment else {262 panic!("the press states a status");263 };264 status265 }266267 #[test]268 fn the_seed_names_the_unit_and_its_parts() {269 let pressed = keys().press("d");270 let status = status(&pressed[0]);271272 assert_eq!(status.display_name, "Studio Lab");273 assert_eq!(274 status275 .components276 .iter()277 .map(|part| part.name.as_str())278 .collect::<Vec<_>>(),279 ["Portable Screen", "Built-in Speakers", "Studio Dualsense"]280 );281 }282283 #[test]284 fn the_last_part_is_the_remote_and_every_other_part_is_a_sink() {285 let pressed = keys().press("d");286 let parts = &status(&pressed[0]).components;287288 assert_eq!(parts[0].kind, SINK);289 assert_eq!(parts[0].connected, None);290 assert_eq!(parts[2].kind, Component::REMOTE);291 assert_eq!(parts[2].connected, Some(false));292 }293294 #[test]295 fn a_unit_with_no_parts_states_none() {296 let mut keys = Keys::seeded("Studio Lab".to_string(), Vec::new());297 assert!(status(&keys.press("d")[0]).components.is_empty());298 }299300 #[test]301 fn the_play_keys_state_the_three_activities() {302 let mut keys = keys();303304 assert_eq!(status(&keys.press("p")[0]).activity, Activity::Starting);305 assert_eq!(status(&keys.press("o")[0]).activity, Activity::Playing);306 assert_eq!(status(&keys.press("i")[0]).activity, Activity::Idle);307 }308309 #[test]310 fn a_play_names_a_title_and_a_unit_at_rest_names_none() {311 let mut keys = keys();312313 let starting = keys.press("p");314 assert_eq!(315 status(&starting[0]).play,316 Some(Play {317 name: PLAY_NAME.to_string(),318 title: PLAY_TITLE.to_string(),319 })320 );321322 let idle = keys.press("i");323 assert_eq!(status(&idle[0]).play, None);324 }325326 #[test]327 fn the_end_of_a_film_returns_the_status_and_then_the_surface() {328 let pressed = keys().press("i");329330 assert_eq!(pressed.len(), 2);331 assert_eq!(status(&pressed[0]).activity, Activity::Idle);332 assert_eq!(pressed[1], Moment::Present);333 }334335 #[test]336 fn the_presence_key_disconnects_the_remote_and_connects_it_again() {337 let mut keys = keys();338339 let gone = keys.press("d");340 assert_eq!(status(&gone[0]).components[2].connected, Some(false));341342 let back = keys.press("d");343 assert_eq!(status(&back[0]).components[2].connected, Some(true));344 }345346 #[test]347 fn focus_marks_the_remote_and_beats_its_marker() {348 let mut keys = keys();349350 let landed = keys.press("f");351 assert_eq!(landed.len(), 2);352 assert_eq!(status(&landed[0]).components[2].focused, Some(true));353 assert_eq!(landed[1], Moment::Focus { remote: 0 });354355 // The cycle press that wraps onto the unit already focused beats it356 // again.357 assert_eq!(keys.press("f")[1], Moment::Focus { remote: 0 });358 }359360 #[test]361 fn focus_cycling_away_stops_marking_the_remote_and_beats_nothing() {362 let mut keys = keys();363 keys.press("f");364365 let left = keys.press("g");366367 assert_eq!(left.len(), 1);368 assert_eq!(status(&left[0]).components[2].focused, None);369 }370371 #[test]372 fn the_sleep_key_plays_the_two_edges_of_the_fade_in_turn() {373 let mut keys = keys();374375 assert_eq!(keys.press("s"), [Moment::Sleep], "the quiet window ran out");376 assert_eq!(keys.press("s"), [Moment::Wake], "a press woke the screen");377 }378379 #[test]380 fn a_volume_key_steps_the_level_as_a_press() {381 let mut keys = keys();382383 assert_eq!(384 keys.press("9"),385 [Moment::Level {386 volume: Volume {387 level: UNITY_LEVEL - VOLUME_STEP,388 muted: false,389 },390 pressed: true,391 }]392 );393 assert_eq!(394 keys.press("0"),395 [Moment::Level {396 volume: Volume {397 level: UNITY_LEVEL,398 muted: false,399 },400 pressed: true,401 }]402 );403 }404405 #[test]406 fn the_level_stays_inside_the_range_the_operator_holds_it_in() {407 let mut keys = keys();408 for _ in 0..30 {409 keys.press("9");410 }411 assert_eq!(keys.volume.level, MIN_LEVEL);412413 for _ in 0..30 {414 keys.press("0");415 }416 assert_eq!(keys.volume.level, UNITY_LEVEL);417 }418419 #[test]420 fn the_mute_key_carries_the_muted_state_on_the_same_indicator() {421 let mut keys = keys();422423 assert_eq!(424 keys.press("m"),425 [Moment::Level {426 volume: Volume {427 level: UNITY_LEVEL,428 muted: true,429 },430 pressed: true,431 }]432 );433 assert_eq!(434 keys.press("m"),435 [Moment::Level {436 volume: Volume {437 level: UNITY_LEVEL,438 muted: false,439 },440 pressed: true,441 }]442 );443 }444445 #[test]446 fn a_key_this_module_does_not_bind_carries_nothing() {447 let mut keys = keys();448 assert!(keys.press("up").is_empty());449 assert!(keys.press("z").is_empty());450 assert_eq!(keys, self::keys());451 }452453 #[test]454 fn the_preview_binds_every_key_the_legend_names_but_the_harness_key() {455 let mut keys = keys();456 let bound_to_nothing: Vec<&str> = LEGEND457 .iter()458 .flat_map(|(key, _)| key.split('/'))459 .filter(|key| keys.press(key).is_empty())460 .collect();461462 assert_eq!(bound_to_nothing, [crate::harness::QUIT]);463 }464465 #[test]466 fn the_legend_line_reads_each_key_beside_what_it_does() {467 assert_eq!(468 legend_line(),469 "p play starts o playing i film ends d presence f focus \470 g unfocus s sleep 9/0 volume m mute q quit"471 );472 }473}
idle/src/idle/shade.rs 100.0%
1//! The shade: how far the whole screen is darkened.2//!3//! The idle command pod owns the quiet timer and sends the two moments the cover4//! reads. This module only eases between clear and opaque. Going dark is slow5//! and coming back is fast, because a screen that fades on its own has no one6//! watching it, and a person who pressed a button is waiting.7//!8//! The shade draws no shape of its own. It is one factor, and every element9//! scales its own colours by it. `look::under` states why, and10//! `display/theme.lua` holds the same mechanism in `theme.fade`.1112use super::energy::ease;13use crate::unit::Unit;1415/// Four seconds down, and under half a second back up.16const SLEEP: f64 = 4.0;17const WAKE: f64 = 0.4;1819/// The cover at one moment, from 0 clear to 1 opaque.20///21/// A screen that has read no moment has no cover at all, which is the state an22/// idle pod starts in.23pub fn cover(unit: &Unit, at: f64) -> f64 {24 let Some(shade) = unit.shade else {25 return 0.0;26 };2728 let (to, seconds) = match shade.down {29 true => (1.0, SLEEP),30 false => (0.0, WAKE),31 };3233 shade.from + (to - shade.from) * ease((at - shade.since) / seconds)34}3536/// The fraction of full brightness every element draws at, from 1 on a clear37/// screen to 0 on a dark one. It is the complement of the cover, and it is the38/// factor `display/theme.lua` calls `theme.fade`.39pub fn fade(unit: &Unit, at: f64) -> f32 {40 1.0 - cover(unit, at) as f3241}4243/// The second the shade next changes, for [`super::Idle::next_frame`].44///45/// An ease changes the colour of every element on every frame it covers, so it46/// answers with `at` and the harness draws now. A cover that has reached its47/// end holds one factor, and a screen that has read no moment carries no cover48/// at all, so both state nothing.49pub fn next_frame(unit: &Unit, at: f64) -> Option<f64> {50 let shade = unit.shade?;51 let seconds = match shade.down {52 true => SLEEP,53 false => WAKE,54 };5556 (at < shade.since + seconds).then_some(at)57}5859#[cfg(test)]60mod tests {61 use super::*;62 use media_screen::Moment;6364 /// A unit that read one moment on the screen topic.65 fn unit(moment: Moment, at: f64) -> Unit {66 let mut unit = Unit::default();67 unit.fold(moment, at);68 unit69 }7071 #[test]72 fn a_screen_that_has_read_no_moment_carries_no_cover() {73 assert_eq!(cover(&Unit::default(), 0.0), 0.0);74 assert_eq!(cover(&Unit::default(), 600.0), 0.0);75 }7677 #[test]78 fn the_screen_goes_dark_over_four_seconds() {79 let unit = unit(Moment::Sleep, 10.0);80 assert_eq!(cover(&unit, 10.0), 0.0);81 assert_eq!(cover(&unit, 12.0), 0.5);82 assert_eq!(cover(&unit, 14.0), 1.0);83 assert_eq!(cover(&unit, 30.0), 1.0);84 }8586 #[test]87 fn a_press_brings_the_screen_back_in_under_half_a_second() {88 let mut unit = unit(Moment::Sleep, 10.0);89 unit.fold(Moment::Wake, 20.0);9091 let halfway = cover(&unit, 20.2);92 assert_eq!(cover(&unit, 20.0), 1.0);93 assert!((halfway - 0.5).abs() < 1e-9, "{halfway}");94 assert_eq!(cover(&unit, 20.4), 0.0);95 }9697 #[test]98 fn the_factor_every_element_draws_at_is_the_complement_of_the_cover() {99 let unit = unit(Moment::Sleep, 10.0);100101 assert_eq!(fade(&Unit::default(), 0.0), 1.0);102 assert_eq!(fade(&unit, 10.0), 1.0);103 assert_eq!(fade(&unit, 12.0), 0.5);104 assert_eq!(fade(&unit, 14.0), 0.0);105 }106107 #[test]108 fn the_fade_to_black_asks_for_a_frame_for_the_whole_four_seconds() {109 let unit = unit(Moment::Sleep, 10.0);110 assert_eq!(next_frame(&unit, 10.0), Some(10.0));111 assert_eq!(next_frame(&unit, 13.9), Some(13.9));112 assert_eq!(next_frame(&unit, 14.0), None);113 }114115 #[test]116 fn the_return_asks_for_a_frame_for_400_ms() {117 let mut unit = unit(Moment::Sleep, 10.0);118 unit.fold(Moment::Wake, 20.0);119120 assert_eq!(next_frame(&unit, 20.39), Some(20.39));121 assert_eq!(next_frame(&unit, 20.41), None);122 }123124 #[test]125 fn a_screen_that_has_read_no_moment_asks_for_no_frame() {126 assert_eq!(next_frame(&Unit::default(), 600.0), None);127 }128129 #[test]130 fn a_press_during_the_fade_never_darkens_the_screen_first() {131 let mut unit = unit(Moment::Sleep, 10.0);132 unit.fold(Moment::Wake, 12.0);133134 assert_eq!(cover(&unit, 12.0), 0.5);135 assert!(cover(&unit, 12.1) < 0.5);136 assert_eq!(cover(&unit, 12.4), 0.0);137 }138}
idle/src/idle/volume.rs 91.7%
1//! The volume row: a speaker glyph, a short bar, and the number.2//!3//! The row comes and goes on a clock of its own, and it draws alone: a level4//! change brings up the row and nothing else on the screen. The bus holds the5//! level and the muted flag, and the client only reads them, because the idle6//! sidecar owns every change to them.78use iced_widget::canvas::{LineJoin, Path, Stroke, Style};9use iced_winit::core::{Color, Point, Rectangle};1011use super::{Frame, Layout};12use crate::look;13use crate::unit::Unit;1415/// The bar fills at this level, which is unity. A level above it fills no16/// further.17const FULL: f64 = 100.0;1819/// A fade takes this long to reach full and this long to reach clear. The out20/// is longer than the in, so the row leaves more slowly than it arrives.21const FADE_IN: f64 = 0.35;22const FADE_OUT: f64 = 0.6;23/// The row leaves this many seconds after the last press. Each press restarts24/// the wait, so a run of presses holds the row on screen.25const HOLD: f64 = 4.0;2627/// The number reserves this much width at the right margin, so the bar and the28/// glyph hold their place as the number moves between one and three digits.29const NUMBER_WIDTH: f32 = 84.0;30/// The bar is short because the number beside it carries the reading, and the31/// bar shows the level at a glance.32const BAR_WIDTH: f32 = 220.0;33const BAR_HEIGHT: f32 = 12.0;34const BAR_RADIUS: f32 = 3.0;35/// The glyph's box, and the gap between the glyph and the bar.36const GLYPH_WIDTH: f32 = 26.0;37const GLYPH_HEIGHT: f32 = 30.0;38const GLYPH_GAP: f32 = 16.0;39/// The number's line box, in canvas pixels. The bar and the glyph centre on40/// the middle of it and the dark surface covers it, so the three parts read as41/// one row whatever the number's own type size.42///43/// `volume.lua` writes this measure as `theme.type.small`, the same `\fs` it44/// draws the number at. The number here draws at `look::SMALL`, which is that45/// `\fs` through the face metric, and the measure is that size's line box,46/// which is the `\fs` again. The two readings meet at 34 canvas pixels.47const NUMBER_BOX: f32 = look::line_box(look::SMALL);48/// The row appears over whatever frame is on screen, with nothing under it, and49/// on a bright frame the glyph and the number would vanish. So the row carries50/// a dark surface of its own, and this is the padding around the three parts.51const PAD_X: f32 = 24.0;52const PAD_Y: f32 = 12.0;53const SURFACE_RADIUS: f32 = 14.0;5455/// The speaker, one closed polygon of the driver box and the cone, in the56/// glyph's own box. The image carries no icon font, so the mark is a path.57const SPEAKER: [(f32, f32); 6] = [58 (0.0, 10.0),59 (10.0, 10.0),60 (22.0, 0.0),61 (22.0, 30.0),62 (10.0, 20.0),63 (0.0, 20.0),64];65/// The muted state draws this slash across the speaker, so one element carries66/// both the level and the mute.67const SLASH: [(f32, f32); 4] = [(2.0, 24.0), (24.0, 2.0), (24.0, 8.0), (2.0, 30.0)];68/// The slash draws in the speaker's own colour, and the two would read as one69/// shape without an outline between them. This is the width of that outline70/// outside the slash.71const SLASH_BORDER: f32 = 2.0;7273/// The row's own fade, from 0 off screen to 1 full.74///75/// The idle command pod publishes a press after it applies a level from the bus,76/// and never for the retained value a client reads when it first connects. So77/// the row answers a press, and it stays off screen while a pod restores the78/// level it starts with.79pub fn fade(unit: &Unit, at: f64) -> f64 {80 let Some(pressed) = unit.pressed else {81 return 0.0;82 };8384 let since = at - pressed;85 let arriving = unit.pressed_from + since / FADE_IN;86 let leaving = 1.0 - (since - HOLD) / FADE_OUT;8788 arriving.min(leaving).clamp(0.0, 1.0)89}9091/// The second the row next changes, for [`super::Idle::next_frame`].92///93/// The row is the one element with a still phase of its own, so it is the one94/// element that names a second ahead of `at` rather than a draw now. It has95/// three phases. The two fades change the row on every frame they cover, and96/// they answer with `at`. Between them the row is up and steady, so it names97/// the second the row starts to leave and the loop sleeps through the four98/// seconds a person reads it. A row that has left states nothing.99///100/// A press moves `pressed` and lifts `pressed_from` from where the row stands,101/// and the harness drops the second it holds on a key, so a press that lands102/// during the hold is drawn at once and schedules its own leaving.103pub fn next_frame(unit: &Unit, at: f64) -> Option<f64> {104 let pressed = unit.pressed?;105 // A row lifted from part way up reaches full sooner, by exactly the part106 // of the fade it started above.107 let full = pressed + (1.0 - unit.pressed_from) * FADE_IN;108 let leaving = pressed + HOLD;109110 if at < full {111 Some(at)112 } else if at < leaving {113 Some(leaving)114 } else if at < leaving + FADE_OUT {115 Some(at)116 } else {117 None118 }119}120121/// How much of the bar the level fills, in canvas pixels.122fn filled(level: i64) -> f32 {123 BAR_WIDTH * (level as f64 / FULL).clamp(0.0, 1.0) as f32124}125126/// Where the parts of the row stand.127///128/// The three parts hang off the right margin, so the row measures itself when129/// it draws. The canvas width follows the screen, and a row measured once at130/// startup holds the margin of another screen.131struct Row {132 surface: Rectangle,133 bar: Rectangle,134 glyph: Point,135 number: Point,136}137138fn row(layout: &Layout) -> Row {139 // The row is the third line of the top-right column, under the clock and140 // the activity line, because the low middle of the screen holds the mark.141 let top = look::MARGIN_Y + 2.0 * look::LINE_PITCH;142 let right = layout.right();143 // The bar and the glyph centre on the middle of the number's line, so the144 // three parts read as one row.145 let middle = top + NUMBER_BOX / 2.0;146147 let bar_x = right - NUMBER_WIDTH - BAR_WIDTH;148 let glyph_x = bar_x - GLYPH_GAP - GLYPH_WIDTH;149 let surface_x = glyph_x - PAD_X;150151 Row {152 surface: Rectangle {153 x: surface_x,154 y: top - PAD_Y,155 width: right + PAD_X - surface_x,156 // The number's line is the tallest of the three parts, so the157 // surface covers it and the padding.158 height: NUMBER_BOX + 2.0 * PAD_Y,159 },160 bar: Rectangle {161 x: bar_x,162 y: middle - BAR_HEIGHT / 2.0,163 width: BAR_WIDTH,164 height: BAR_HEIGHT,165 },166 glyph: Point::new(glyph_x, middle - GLYPH_HEIGHT / 2.0),167 number: Point::new(right, top),168 }169}170171/// One closed polygon in the glyph's own box, placed at `at`.172fn polygon(points: &[(f32, f32)], at: Point) -> Path {173 Path::new(|path| {174 for (index, (x, y)) in points.iter().enumerate() {175 let point = Point::new(at.x + x, at.y + y);176 match index {177 0 => path.move_to(point),178 _ => path.line_to(point),179 }180 }181 path.close();182 })183}184185/// Draw the element into the frame, in canvas units.186///187/// The glyph alone carries the muted state, so the bar reads the level in both188/// states. Every part draws at the row's own fade, because the row arrives and189/// leaves on a clock no other element reads, and then at the shade's factor190/// with the rest of the screen.191pub fn draw(frame: &mut Frame, layout: &Layout, unit: &Unit, at: f64, light: f32) {192 let fade = fade(unit, at) as f32;193 if fade <= 0.0 {194 return;195 }196197 let row = row(layout);198 let ink = match unit.volume.muted {199 true => look::muted(),200 false => look::text(),201 };202 let shaded = |color, alpha| look::under(look::faded(color, alpha), light);203204 frame.fill(205 &look::rounded(row.surface, SURFACE_RADIUS),206 shaded(Color::BLACK, look::SURFACE * fade),207 );208 frame.fill(209 &look::rounded(row.bar, BAR_RADIUS),210 shaded(look::track(), look::TRACK_OPACITY * fade),211 );212213 let filled = filled(unit.volume.level);214 if filled >= 1.0 {215 frame.fill(216 &look::rounded(217 Rectangle {218 width: filled,219 ..row.bar220 },221 BAR_RADIUS,222 ),223 shaded(look::fill(), fade),224 );225 }226227 frame.fill(&polygon(&SPEAKER, row.glyph), shaded(ink, fade));228 if unit.volume.muted {229 let slash = polygon(&SLASH, row.glyph);230 // The toolkit centres a stroke on the path it follows, and the outline231 // this glyph needs stands outside the slash, so the stroke is twice the232 // border and the fill covers the half that fell inside. The width is233 // in surface pixels, for the reason the canvas scale is documented on234 // Layout.235 frame.stroke(236 &slash,237 Stroke {238 width: 2.0 * SLASH_BORDER * layout.scale,239 style: Style::Solid(shaded(Color::BLACK, fade)),240 line_join: LineJoin::Round,241 ..Stroke::default()242 },243 );244 frame.fill(&slash, shaded(ink, fade));245 }246247 frame.fill_text(look::line(248 unit.volume.level.to_string(),249 row.number,250 look::Anchor::TopRight,251 look::SMALL,252 shaded(look::text(), fade),253 ));254}255256#[cfg(test)]257mod tests {258 use super::*;259 use iced_winit::core::Size;260 use media_screen::Moment;261 use media_screen::volume::Volume;262263 /// A unit that read a level and then a press of it.264 fn pressed(at: f64) -> Unit {265 let mut unit = Unit::default();266 let volume = Volume {267 level: 40,268 muted: false,269 };270 unit.fold(271 Moment::Level {272 volume,273 pressed: false,274 },275 0.0,276 );277 unit.fold(278 Moment::Level {279 volume,280 pressed: true,281 },282 at,283 );284 unit285 }286287 #[test]288 fn the_catch_up_shows_no_row() {289 let mut unit = Unit::default();290 unit.fold(291 Moment::Level {292 volume: Volume::default(),293 pressed: false,294 },295 1.0,296 );297 assert_eq!(fade(&unit, 1.0), 0.0);298 assert_eq!(fade(&unit, 2.0), 0.0);299 }300301 #[test]302 fn a_press_brings_the_row_in_over_350_ms() {303 let unit = pressed(1.0);304 assert_eq!(fade(&unit, 1.0), 0.0);305 assert!((fade(&unit, 1.175) - 0.5).abs() < 1e-9);306 assert_eq!(fade(&unit, 1.35), 1.0);307 assert_eq!(fade(&unit, 4.0), 1.0);308 }309310 #[test]311 fn the_row_leaves_four_seconds_after_the_last_press() {312 let unit = pressed(1.0);313 assert_eq!(fade(&unit, 5.0), 1.0);314 assert!((fade(&unit, 5.3) - 0.5).abs() < 1e-9);315 assert!(fade(&unit, 5.6) < 1e-9);316 assert_eq!(fade(&unit, 60.0), 0.0);317 }318319 #[test]320 fn the_bar_fills_at_unity_and_no_further() {321 assert_eq!(filled(0), 0.0);322 assert_eq!(filled(50), BAR_WIDTH / 2.0);323 assert_eq!(filled(100), BAR_WIDTH);324 assert_eq!(filled(140), BAR_WIDTH);325 }326327 #[test]328 fn the_two_fades_ask_for_a_frame_now() {329 let unit = pressed(10.0);330 assert_eq!(next_frame(&unit, 10.0), Some(10.0));331 assert_eq!(next_frame(&unit, 10.34), Some(10.34));332 assert_eq!(next_frame(&unit, 14.3), Some(14.3));333 }334335 #[test]336 fn the_row_sleeps_through_its_hold_and_wakes_to_leave() {337 let unit = pressed(10.0);338 // The row is up and steady from the end of the fade in, so every339 // second of the hold names the one moment the row changes again.340 assert_eq!(next_frame(&unit, 10.35), Some(14.0));341 assert_eq!(next_frame(&unit, 13.9), Some(14.0));342 }343344 #[test]345 fn a_press_that_lifts_a_leaving_row_shortens_its_fade_in() {346 let mut unit = pressed(10.0);347 // The row is halfway out at 14.3 seconds, and the press lifts it from348 // there, so it reaches full in half of the 350 ms fade.349 unit.fold(350 Moment::Level {351 volume: unit.volume,352 pressed: true,353 },354 14.3,355 );356357 assert_eq!(next_frame(&unit, 14.47), Some(14.47));358 assert_eq!(next_frame(&unit, 14.48), Some(18.3));359 }360361 #[test]362 fn a_row_that_has_left_asks_for_no_frame() {363 assert_eq!(next_frame(&pressed(10.0), 14.61), None);364 assert_eq!(next_frame(&Unit::default(), 600.0), None);365 }366367 #[test]368 fn the_row_stands_two_line_pitches_under_the_top_margin() {369 let row = row(&Layout::for_surface(Size::new(1920.0, 1080.0)));370 assert_eq!(row.number.x, 1780.0);371 // The top margin is 90, and a pitch is a 34 pixel line box and 12372 // pixels of air.373 assert!((row.number.y - 182.0).abs() < 0.2, "{}", row.number.y);374 assert_eq!(row.bar.x, 1780.0 - 84.0 - 220.0);375 assert_eq!(row.glyph.x, row.bar.x - 16.0 - 26.0);376 }377378 #[test]379 fn the_surface_covers_the_three_parts_and_the_padding() {380 let row = row(&Layout::for_surface(Size::new(1920.0, 1080.0)));381 assert_eq!(row.surface.x, row.glyph.x - PAD_X);382 assert_eq!(row.surface.x + row.surface.width, 1780.0 + PAD_X);383 assert_eq!(row.surface.height, NUMBER_BOX + 2.0 * PAD_Y);384 }385386 #[test]387 fn the_bar_and_the_glyph_centre_on_the_number_s_line() {388 let row = row(&Layout::for_surface(Size::new(1920.0, 1080.0)));389 let middle = |shape: Rectangle| shape.y + shape.height / 2.0;390 assert_eq!(middle(row.bar), row.number.y + NUMBER_BOX / 2.0);391 assert_eq!(row.glyph.y + GLYPH_HEIGHT / 2.0, middle(row.bar));392 }393}
idle/src/look.rs 99.1%
1// The liken look in one place. Every view reads its colors and its measures2// from here, so a change to either lands in one file.3//4// The colors are the brand's, through the liken-iced crate, which parses them5// out of liken.css. The measures below are this display's own: a type scale6// and margins for a screen a person reads from a couch, which no stylesheet7// for a page states.89use iced_widget::canvas::{Path, Text};10use iced_winit::core::alignment::Vertical;11use iced_winit::core::text::{Alignment, LineHeight};12use iced_winit::core::{Color, Font, Pixels, Point, Rectangle, Size};13use liken_iced::palette;1415/// The ground the client fills. It is the clear color of every frame and every16/// capture.17///18/// The ground is black rather than the brand's `--page`, because the idle19/// screen shares one output with a film. A film's black and the screen's black20/// have to be the same black, or the panel shows a seam where one ends.21pub const BACKGROUND: Color = Color::BLACK;2223/// The color of every line of text on the screen, the dark scheme's `--ink`.24pub fn text() -> Color {25 palette::dark().ink26}2728/// The accent that fills a bar, the dark scheme's `--link`. It is the palest29/// lichen green, so it reads over the track below at a distance.30pub fn fill() -> Color {31 palette::dark().link32}3334/// The track a bar's fill runs over, the light scheme's `--link`. It is the35/// darkest green of the family, dark enough that the fill reads over it.36pub fn track() -> Color {37 palette::light().link38}3940/// The color of text that reads under the rest, the dark scheme's41/// `--ink-muted`.42pub fn muted() -> Color {43 palette::dark().ink_muted44}4546/// The same color at another opacity, for an element that fades on its own.47pub const fn faded(color: Color, alpha: f32) -> Color {48 Color { a: alpha, ..color }49}5051// The canvas. The display draws in one space 1080 rows tall, and the width52// follows the real surface's own ratio, so a canvas pixel is square. A 16:953// surface gives 1920, the width this space has always held. A fixed 1920 on a54// 21:9 screen stretches every vector drawing by a third and pulls every margin55// inside where it belongs.56pub const CANVAS_HEIGHT: f32 = 1080.0;5758/// The canvas width for one surface size, rounded the way `display/theme.lua`59/// rounds it. The surface can change size while the client runs, so every60/// element measures against the screen that is there now rather than a width61/// read once at startup.62pub fn canvas_width(surface: (u32, u32)) -> f32 {63 if surface.0 == 0 || surface.1 == 0 {64 return CANVAS_HEIGHT * 16.0 / 9.0;65 }66 (CANVAS_HEIGHT * surface.0 as f32 / surface.1 as f32 + 0.5).floor()67}6869// The side margin every flush-left and flush-right element keeps, and the top70// margin. The bottom margin is the top one by symmetry: the clock and the71// activity line hang from it, and the identity block stands the same distance72// off the bottom edge.73pub const MARGIN_X: f32 = 140.0;74pub const MARGIN_Y: f32 = 90.0;7576// The face's metric: its bounding height over its em, 1326 units over77// 1000. The bounding height is `usWinAscent` plus `usWinDescent` and the78// em is `unitsPerEm`, from the `OS/2` and `head` tables of79// `SourceSans3-Regular.otf`, the face the brand crate carries and both80// renderers draw.81//82// libass scales a face so that its bounding height fills the size an ASS83// `\fs` states, so one `\fs` number states two measures: the line box the84// text draws in, in canvas pixels, and the type size, which is that box85// divided by this metric. A layout measure `display/theme.lua` writes in86// `\fs` units is a canvas measure here and passes through unchanged; only87// a type size goes through the metric.88const FACE_METRIC: f32 = 1326.0 / 1000.0;8990/// The line box one type size draws in, in canvas pixels. A line's anchor falls91/// on that box in both renderers, so a line placed by its top or its bottom92/// puts its baseline where libass puts it. It is also the `\fs` number93/// `display/theme.lua` states the size as.94pub const fn line_box(size: f32) -> f32 {95 size * FACE_METRIC96}9798/// The type size that draws in a line box `height` canvas pixels tall, which is99/// the other direction of [`line_box`].100pub const fn type_size(height: f32) -> f32 {101 height / FACE_METRIC102}103104// The type scale, in canvas pixels. The sizes are large enough to read from a105// couch at 1080. They are `display/theme.lua`'s `\fs40`, `\fs34`, and `\fs28`106// through `type_size`, each rounded to a whole pixel.107pub const LABEL: f32 = 30.0;108pub const SMALL: f32 = 26.0;109pub const TINY: f32 = 21.0;110111/// The small size's line box, `theme.lua`'s `\fs34`, as the canvas measure112/// every layout built on that box reads. It is stated, not read back113/// through `line_box(SMALL)`, because the type size is rounded to a whole114/// pixel and its box is then up to half a metric off the number the Lua115/// states.116pub const SMALL_BOX: f32 = 34.0;117118/// The drop from one line of the top-right column to the next: one line box of119/// the small size, and 12 canvas pixels of air. It is `theme.lua`'s120/// `theme.type.small + 12`, where the `\fs` is the line box and the 12 is121/// canvas pixels. The clock hangs at the top margin, the activity line one122/// pitch under it, and the volume row one pitch under that, so the three read123/// as a column and no two of them touch.124pub const LINE_PITCH: f32 = SMALL_BOX + 12.0;125126/// The one family the whole display draws in, the family127/// `display/theme.lua` names for the playback overlay. It comes from the128/// brand crate, which carries the two files and loads them into the toolkit129/// before the first frame, so this screen and every other liken display130/// draw one face.131pub use liken_iced::font::FAMILY as FONT;132133/// Where a line's position falls on the line, in the ASS anchors134/// `display/theme.lua` states. The display draws with three of the nine.135#[derive(Debug, Clone, Copy, PartialEq, Eq)]136pub enum Anchor {137 /// `\an1`. The identity block stacks upward from the bottom margin, so138 /// each of its lines is placed by its own bottom.139 BottomLeft,140 /// `\an3`. The preview legend draws on the bottom margin at the right.141 BottomRight,142 /// `\an9`. The clock, the activity line, and the volume row hang from the143 /// top margin at the right, so each is placed by its own top.144 TopRight,145}146147/// One line of text, the way `display/theme.lua`'s `theme.text` states one:148/// an anchor, a position in canvas units, a size from the type scale, and a149/// colour.150pub fn line(content: String, at: Point, anchor: Anchor, size: f32, color: Color) -> Text {151 Text {152 content,153 position: at,154 color,155 size: Pixels(size),156 line_height: LineHeight::Absolute(Pixels(line_box(size))),157 font: Font::with_name(FONT),158 align_x: match anchor {159 Anchor::BottomLeft => Alignment::Left,160 Anchor::BottomRight | Anchor::TopRight => Alignment::Right,161 },162 align_y: match anchor {163 Anchor::BottomLeft | Anchor::BottomRight => Vertical::Bottom,164 Anchor::TopRight => Vertical::Top,165 },166 ..Text::default()167 }168}169170/// One rounded rectangle. A radius wider than half the shape has no meaning, so171/// a bar with a few pixels of fill rounds by what it has.172///173/// Two elements draw bars, the volume row and the charge beside a part174/// in the identity block, and both round their ends this way.175pub fn rounded(shape: Rectangle, radius: f32) -> Path {176 let radius = radius.min(shape.width / 2.0).min(shape.height / 2.0);177 Path::rounded_rectangle(178 Point::new(shape.x, shape.y),179 Size::new(shape.width, shape.height),180 radius.into(),181 )182}183184/// One colour under the shade. `light` runs from 1 at full brightness to 0 on185/// a screen the quiet window has darkened.186///187/// The shade is a factor every element applies to its own colours, and not a188/// black rectangle over them. `iced_wgpu` renders a layer in four passes,189/// quads then meshes then images then text, and a canvas frame is one layer,190/// so a rectangle drawn last still lands under every line of text. Over the191/// black ground this display fills, an alpha scaled by `light` composites to192/// the pixels a black cover would give. `display/theme.lua` fades its whole193/// overlay the same way, through `theme.fade`.194pub const fn under(color: Color, light: f32) -> Color {195 faded(color, color.a * light)196}197198/// A part of the screen far enough under full brightness that a glance tells199/// it from the rest, and bright enough to read. `display/theme.lua` states it200/// as the ASS alpha byte 0xA8, which leaves 87 of the 255 steps of light.201pub const DIM: f32 = 87.0 / 255.0;202/// A bar's track, under the fill that reads against it. `theme.lua` states it203/// as the ASS alpha byte 0x50.204pub const TRACK_OPACITY: f32 = 175.0 / 255.0;205/// The dark surface an element carries when it draws over a frame with no206/// scrim under it. `theme.lua` states it as `scrim_edge_alpha`, the ASS alpha207/// byte 0x34.208pub const SURFACE: f32 = 203.0 / 255.0;209210#[cfg(test)]211mod tests {212 use super::*;213214 #[test]215 fn the_ground_is_black() {216 assert_eq!(BACKGROUND, Color::from_rgb(0.0, 0.0, 0.0));217 }218219 #[test]220 fn fading_keeps_the_color() {221 let half = faded(fill(), 0.5);222 assert_eq!((half.r, half.g, half.b), (fill().r, fill().g, fill().b));223 assert_eq!(half.a, 0.5);224 }225226 #[test]227 fn the_text_is_the_brand_ink() {228 assert_eq!(text(), palette::dark().ink);229 assert_ne!(text(), muted());230 }231232 #[test]233 fn the_shade_scales_an_alpha_and_leaves_the_colour_alone() {234 let full = faded(text(), 1.0);235236 assert_eq!(under(full, 1.0), full);237 assert_eq!(under(full, 0.0).a, 0.0);238 assert_eq!(under(full, 0.5).a, 0.5);239 assert_eq!(240 (under(full, 0.5).r, under(full, 0.5).g),241 (text().r, text().g)242 );243 }244245 #[test]246 fn the_shade_scales_a_part_that_is_already_faded() {247 let dim = faded(text(), DIM);248 assert_eq!(under(dim, 0.5).a, DIM / 2.0);249 }250251 #[test]252 fn the_type_scale_is_the_lua_scale_through_the_face_metric() {253 assert_eq!(LABEL, type_size(40.0).round());254 assert_eq!(SMALL, type_size(34.0).round());255 assert_eq!(TINY, type_size(28.0).round());256 }257258 #[test]259 fn a_size_and_its_line_box_are_the_two_readings_of_one_ass_size() {260 // `theme.lua` states the small size as `\fs34`, so the line box the261 // small size draws in is 34 canvas pixels. The size is rounded to a262 // whole pixel, so its box lands within half a metric of that number.263 let box_ = line_box(SMALL);264265 assert!((box_ - 34.0).abs() < FACE_METRIC / 2.0, "{box_}");266 assert!((type_size(box_) - SMALL).abs() < 0.001, "{box_}");267 }268269 #[test]270 fn a_line_takes_the_face_the_size_and_the_box_that_size_states() {271 let drawn = line(272 "9:01 am".into(),273 Point::new(1780.0, 90.0),274 Anchor::TopRight,275 SMALL,276 text(),277 );278279 assert_eq!(drawn.size, Pixels(SMALL));280 assert_eq!(281 drawn.line_height,282 LineHeight::Absolute(Pixels(line_box(SMALL)))283 );284 assert_eq!(drawn.font, Font::with_name(FONT));285 }286287 #[test]288 fn each_anchor_places_a_line_by_the_corner_it_names() {289 let placed = |anchor| {290 let drawn = line(String::new(), Point::ORIGIN, anchor, SMALL, text());291 (drawn.align_x, drawn.align_y)292 };293294 assert_eq!(295 placed(Anchor::BottomLeft),296 (Alignment::Left, Vertical::Bottom)297 );298 assert_eq!(299 placed(Anchor::BottomRight),300 (Alignment::Right, Vertical::Bottom)301 );302 assert_eq!(placed(Anchor::TopRight), (Alignment::Right, Vertical::Top));303 }304305 #[test]306 fn the_fill_reads_over_the_track() {307 let brightness = |color: Color| color.r + color.g + color.b;308 assert!(brightness(fill()) > brightness(track()));309 }310}
idle/src/main.rs 60.0%
1// The binary reads the flags and the wiring. A bad flag stops the run before a2// window opens.34use idle_screen::client::Client;5use idle_screen::harness::options::help;6use idle_screen::harness::{self, Invocation, Options};7use idle_screen::wiring::Wiring;89fn main() {10 // The environment is read once here. The client takes the broker, the11 // topics, and the seeds, and the harness takes the two window facts: the12 // app-id routes the surface to a screen, and the grace arms the watchdog.13 let wiring = Wiring::from_environment();1415 match Options::parse(std::env::args().skip(1)) {16 Ok(Invocation::Help) => print!("{}", help()),17 Ok(Invocation::Run(mut options)) => {18 options.app_id = wiring.app_id.clone();19 options.window_grace = wiring.window_grace;20 if let Err(error) = harness::run(Client::open(wiring), options) {21 eprintln!("idle-screen: {error}");22 std::process::exit(1);23 }24 }25 Err(error) => {26 eprintln!("idle-screen: {error}");27 std::process::exit(2);28 }29 }30}
idle/src/unit.rs 100.0%
1// The unit as the screen holds it: what the environment seeded, and what the2// bus has said since.3//4// Every field is a fact or the second a moment landed. No animation keeps its5// own state here, because each one is a function of the wall clock and the6// moments before it. A view reads the seconds below against the clock it is7// drawn at, so a captured frame is reproducible and a dropped frame is8// harmless.9//10// A moment that turns an ease around records where that ease stood as well as11// the second it landed. The ease that follows leaves from there, so a press12// that lands in the middle of a fade never makes the screen jump. The reading13// comes from the element that draws the curve, so one module states each ease14// and this one states the moments.1516use crate::idle::{energy, identity, shade, volume};17use media_screen::Moment;18use media_screen::status::{Activity, Component, Status};19use media_screen::volume::Volume;2021/// One part of the unit: a screen, a set of speakers, or a controller.22#[derive(Debug, Clone, Default, PartialEq)]23pub struct Part {24 pub name: String,25 /// `display`, `sink`, or `remote`. A part the environment seeded carries26 /// none, because the seed names the parts and nothing else.27 pub kind: String,28 /// The presence of a part that has any. `None` is a part with no live29 /// state, which draws at full brightness always.30 pub connected: Option<bool>,31 /// The charge the part reports, from 0 to 100. The identity block draws32 /// it as a bar in the left margin. `None` is a part that reports none, and33 /// its line carries the name alone.34 pub battery: Option<i64>,35 /// Whether the focus mark names this part. The seed carries no focus, so no36 /// marker draws before the first status.37 pub focused: bool,38 /// The second this part last came back, from a status that carried it from39 /// disconnected to connected. The identity block flashes the name white40 /// from here.41 pub returned: Option<f64>,42 /// The second this part last went away, from a status that carried it from43 /// connected to disconnected. The identity block eases the name down to the44 /// dim alpha from here, so a controller that disconnects leaves over the45 /// same 400 ms it returns over.46 pub left: Option<f64>,47 /// The second a focus moment last named this part. The marker beats from48 /// here. The status says whether the marker draws at all, and the moment49 /// carries the timing, so a beat that lands before the status still shows.50 pub marked: Option<f64>,51 /// The name's brightness at `returned` or `left`, so a part that52 /// flips presence mid-ease turns around from the level it stood at53 /// rather than jumping to one end.54 pub brightness_from: f32,55 /// The name's white flash at `returned`, so a second return inside56 /// one flash climbs from the level it holds.57 pub flash_from: f32,58 /// The marker's beat at `marked`, so a second focus moment inside59 /// one beat climbs from the level it holds.60 pub focus_from: f32,61}6263/// The shade over the whole screen: which way it moves, and the second it64/// started. It eases down over 4000 ms and up over 400 ms.65#[derive(Debug, Clone, Copy, PartialEq)]66pub struct Shade {67 pub down: bool,68 pub since: f64,69 /// The cover at that second, from 0 clear to 1 opaque. A press that lands70 /// while the screen still fades to black clears it from the grey it71 /// reached, and not from black.72 pub from: f64,73}7475/// The ramp the energy runs, from the moment the activity last changed.76///77/// The target and the duration follow from the activity the unit holds now, so78/// this is the other half: the second the ramp started, the energy it started79/// from, and the animation clock the mark had reached by then. The clock is80/// recorded rather than counted again, because it is an integral over every81/// ramp before this one, and the unit holds none of those ramps.82#[derive(Debug, Clone, Copy, Default, PartialEq)]83pub struct Ramp {84 pub since: f64,85 pub from: f64,86 pub phase: f64,87}8889/// The unit the client draws.90#[derive(Debug, Clone, Default, PartialEq)]91pub struct Unit {92 /// The unit's friendly name, seeded from `IDLE_PLAYER_NAME` and replaced by93 /// the first status.94 pub name: String,95 /// The parts, in the order the status lists them: the display, then each96 /// sink, then each remote.97 pub parts: Vec<Part>,98 pub activity: Activity,99 /// The energy's ramp, which starts over on every change of activity.100 pub ramp: Ramp,101 /// The title of the last `Play` the unit named. It stays after the `Play`102 /// ends, because the activity line fades out with the mark's motion rather103 /// than vanishing on the frame the activity changes.104 pub title: Option<String>,105 pub volume: Volume,106 /// The second of the last volume press. The volume row draws from here, and107 /// the broker's catch-up sets no second, so a pod that starts draws no row.108 pub pressed: Option<f64>,109 /// The row's own fade at that second, from 0 off screen to 1 full. A press110 /// that lands while the row fades out lifts it from where it stands, so a111 /// run of presses holds one row on screen instead of blinking it.112 pub pressed_from: f64,113 /// The shade, once a shade moment has arrived. A client that has read none114 /// draws no shade.115 pub shade: Option<Shade>,116 /// The second a new Wayland surface came up after a `present`. The mark117 /// starts its arrival motion from here.118 pub presented: Option<f64>,119}120121impl Unit {122 /// The unit before the broker answers, from the seeds the operator set. A123 /// seeded part reports no presence, so the identity block draws every name124 /// at full brightness until the first status.125 pub fn seeded(name: String, components: Vec<String>) -> Self {126 Self {127 name,128 parts: components129 .into_iter()130 .map(|name| Part {131 name,132 ..Part::default()133 })134 .collect(),135 ..Self::default()136 }137 }138139 /// Fold one moment in, at `at` seconds on the screen's clock. Every moment140 /// the unit holds arrives here, so one path carries what the broker says.141 pub fn fold(&mut self, moment: Moment, at: f64) {142 match moment {143 Moment::Status(status) => self.receive(status, at),144 Moment::Level { volume, pressed } => {145 if pressed {146 self.pressed_from = volume::fade(self, at);147 self.pressed = Some(at);148 }149 self.volume = volume;150 }151 Moment::Sleep => self.cover(true, at),152 Moment::Wake => self.cover(false, at),153 Moment::Focus { remote } => self.mark(remote, at),154 // A `present` asks for a new Wayland surface, which the harness155 // owns. `presented` records the second the new one came up.156 Moment::Present => {}157 // A press is the client's own to answer, and158 // `Client::receive` reads it before this fold. The stock159 // idle screen draws no list, so the unit changes for none160 // of them.161 Moment::Press(_) => {}162 }163 }164165 /// Ease the shade toward black or toward clear.166 ///167 /// An ease already running that way runs on, and a cover already at that168 /// end stays there. The idle command pod sends a wake on every press, so169 /// without that rule a run of presses would restart the ease and hold the170 /// screen dim for as long as the presses keep arriving.171 fn cover(&mut self, down: bool, at: f64) {172 let running = match self.shade {173 Some(shade) => shade.down == down,174 None => !down,175 };176 if running {177 return;178 }179180 self.shade = Some(Shade {181 down,182 since: at,183 from: shade::cover(self, at),184 });185 }186187 /// Replace the whole unit with one status. A part that keeps its name keeps188 /// the seconds it drew from, so a status that changed one field does not189 /// restart every fade.190 fn receive(&mut self, status: Status, at: f64) {191 if !status.display_name.is_empty() {192 self.name = status.display_name;193 }194 // The operator publishes a status on every pass over the `Player`, and195 // a repeated one carries the activity the unit already holds. A ramp196 // that started over on each of those would stretch for as long as the197 // statuses keep arriving and never reach its target.198 if status.activity != self.activity {199 self.ramp = energy::ramp(self, status.activity, at);200 }201 self.activity = status.activity;202 if let Some(play) = &status.play {203 let title = [&play.title, &play.name]204 .into_iter()205 .find(|text| !text.is_empty());206 if let Some(title) = title {207 self.title = Some(title.clone());208 }209 }210211 let previous = std::mem::take(&mut self.parts);212 self.parts = status213 .components214 .into_iter()215 .filter(|component| !component.name.is_empty())216 .map(|component| {217 let was = previous.iter().find(|part| part.name == component.name);218 part(component, was, at)219 })220 .collect();221 }222223 /// Beat one controller's marker. `remote` counts the parts of kind224 /// `remote` in the order the status lists them. An index that names no225 /// controller changes nothing, because a moment can arrive for a controller226 /// this unit's status has not listed yet.227 fn mark(&mut self, remote: usize, at: f64) {228 if let Some(part) = self229 .parts230 .iter_mut()231 .filter(|part| part.kind == Component::REMOTE)232 .nth(remote)233 {234 let from = identity::focus(part, at);235 part.focus_from = from;236 part.marked = Some(at);237 }238 }239}240241/// Whether a part draws at full brightness. A part that reports no presence242/// draws lit, because a part that cannot be absent must not read as243/// present-for-now. Only a part that reports it is away draws dim.244fn lit(connected: Option<bool>) -> bool {245 connected != Some(false)246}247248/// One part of a status, against the part of that name the unit already held.249/// A part that changed presence takes the second it changed, and the identity250/// block eases its line from there.251///252/// The two seconds answer two different questions. `left` is the moment a lit253/// part went away, and a part that reported no presence at all is lit, so a254/// controller the environment seeded eases down the first time a status says255/// it is away. `returned` is the moment a part the status called away came256/// back connected, which is the flash that tells a person the controller257/// returned.258///259/// A part this unit has not listed before takes neither second, so a260/// controller that is already away when the first status lands draws dim261/// without easing there.262///263/// A part that turns also records the level the identity block drew it264/// at, read through that block's own curve, so the ease that follows265/// leaves from there. A part that did not turn keeps the levels it266/// already held.267fn part(component: Component, was: Option<&Part>, at: f64) -> Part {268 let went_away = was.is_some_and(|was| lit(was.connected)) && !lit(component.connected);269 let came_back =270 was.is_some_and(|was| was.connected == Some(false)) && component.connected == Some(true);271 let brightness_from = match was {272 Some(was) if went_away || came_back => identity::brightness(was, at),273 Some(was) => was.brightness_from,274 None => 0.0,275 };276 let flash_from = match was {277 Some(was) if came_back => identity::flash(was, at),278 Some(was) => was.flash_from,279 None => 0.0,280 };281282 Part {283 name: component.name,284 kind: component.kind,285 connected: component.connected,286 battery: component.battery,287 focused: component.focused == Some(true),288 returned: came_back289 .then_some(at)290 .or_else(|| was.and_then(|was| was.returned)),291 left: went_away292 .then_some(at)293 .or_else(|| was.and_then(|was| was.left)),294 marked: was.and_then(|was| was.marked),295 brightness_from,296 flash_from,297 focus_from: was.map_or(0.0, |was| was.focus_from),298 }299}300301#[cfg(test)]302mod tests {303 use super::*;304 use media_screen::status::Play;305306 fn seeded() -> Unit {307 Unit::seeded(308 "The Den".into(),309 vec!["The screen".into(), "The speakers".into()],310 )311 }312313 fn component(name: &str, kind: &str, connected: Option<bool>) -> Component {314 Component {315 name: name.into(),316 kind: kind.into(),317 connected,318 battery: None,319 focused: None,320 }321 }322323 fn status(components: Vec<Component>) -> Status {324 Status {325 display_name: "The Den".into(),326 activity: Activity::Idle,327 play: None,328 components,329 }330 }331332 #[test]333 fn the_seeds_name_the_unit_and_its_parts() {334 let unit = seeded();335 assert_eq!(unit.name, "The Den");336 assert_eq!(unit.parts.len(), 2);337 assert_eq!(unit.parts[0].name, "The screen");338 assert_eq!(unit.parts[0].connected, None);339 assert!(!unit.parts[0].focused);340 }341342 #[test]343 fn the_first_status_replaces_the_whole_block() {344 let mut unit = seeded();345 unit.fold(346 Moment::Status(Status {347 display_name: "The Living Room".into(),348 activity: Activity::Starting,349 play: None,350 components: vec![component("A remote", "remote", Some(true))],351 }),352 1.0,353 );354355 assert_eq!(unit.name, "The Living Room");356 assert_eq!(unit.activity, Activity::Starting);357 assert_eq!(unit.parts.len(), 1);358 assert_eq!(unit.parts[0].kind, "remote");359 }360361 #[test]362 fn a_status_with_no_display_name_keeps_the_name_the_unit_has() {363 let mut unit = seeded();364 unit.fold(365 Moment::Status(Status {366 display_name: String::new(),367 ..status(Vec::new())368 }),369 1.0,370 );371 assert_eq!(unit.name, "The Den");372 }373374 #[test]375 fn a_part_with_no_name_draws_no_line() {376 let mut unit = seeded();377 unit.fold(378 Moment::Status(status(vec![379 component("", "sink", None),380 component("The speakers", "sink", None),381 ])),382 1.0,383 );384 assert_eq!(unit.parts.len(), 1);385 assert_eq!(unit.parts[0].name, "The speakers");386 }387388 #[test]389 fn a_part_that_came_back_takes_the_second_it_returned() {390 let mut unit = seeded();391 unit.fold(392 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),393 1.0,394 );395 assert_eq!(unit.parts[0].returned, None);396397 unit.fold(398 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),399 4.0,400 );401 assert_eq!(unit.parts[0].returned, Some(4.0));402403 // A later status that changed nothing does not flash the part again.404 unit.fold(405 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),406 9.0,407 );408 assert_eq!(unit.parts[0].returned, Some(4.0));409 }410411 #[test]412 fn a_part_that_went_away_takes_the_second_it_left() {413 let mut unit = seeded();414 unit.fold(415 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),416 1.0,417 );418 assert_eq!(unit.parts[0].left, None);419420 unit.fold(421 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),422 4.0,423 );424 assert_eq!(unit.parts[0].left, Some(4.0));425426 // A later status that changed nothing does not start the fade again.427 unit.fold(428 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),429 9.0,430 );431 assert_eq!(unit.parts[0].left, Some(4.0));432 }433434 #[test]435 fn a_part_the_seed_named_takes_the_second_it_left() {436 let mut unit = Unit::seeded("The Den".into(), vec!["A remote".into()]);437 assert_eq!(unit.parts[0].connected, None);438439 unit.fold(440 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),441 2.0,442 );443 assert_eq!(unit.parts[0].left, Some(2.0));444 }445446 #[test]447 fn a_part_that_came_back_mid_fade_records_the_level_it_stood_at() {448 let mut unit = seeded();449 unit.fold(450 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),451 1.0,452 );453 unit.fold(454 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),455 4.0,456 );457 assert_eq!(unit.parts[0].brightness_from, 1.0);458459 // The return lands 200 ms into the 400 ms fade, so the name460 // stands at half and the ease up leaves from there.461 unit.fold(462 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),463 4.2,464 );465 assert_eq!(unit.parts[0].returned, Some(4.2));466 assert_eq!(unit.parts[0].brightness_from, 0.5);467 }468469 #[test]470 fn a_part_that_did_not_turn_keeps_the_level_it_already_recorded() {471 let mut unit = seeded();472 unit.fold(473 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),474 1.0,475 );476 unit.fold(477 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),478 4.0,479 );480 unit.fold(481 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),482 9.0,483 );484 assert_eq!(unit.parts[0].brightness_from, 1.0);485 }486487 #[test]488 fn a_focus_moment_inside_a_beat_records_the_level_the_marker_held() {489 let mut unit = seeded();490 unit.fold(491 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),492 1.0,493 );494495 unit.fold(Moment::Focus { remote: 0 }, 3.0);496 assert_eq!(unit.parts[0].focus_from, 0.0);497498 // The second moment lands 250 ms into the beat's 500 ms fall, so499 // the marker holds half and the next beat climbs from there.500 unit.fold(Moment::Focus { remote: 0 }, 3.37);501 assert_eq!(unit.parts[0].marked, Some(3.37));502 assert_eq!(unit.parts[0].focus_from, 0.5);503 }504505 #[test]506 fn a_part_that_arrives_away_takes_no_second_to_fade_from() {507 let mut unit = seeded();508 unit.fold(509 Moment::Status(status(vec![component("A remote", "remote", Some(false))])),510 1.0,511 );512 assert_eq!(unit.parts[0].left, None);513 }514515 #[test]516 fn a_part_that_arrives_connected_never_flashed() {517 let mut unit = seeded();518 unit.fold(519 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),520 1.0,521 );522 assert_eq!(unit.parts[0].returned, None);523 }524525 #[test]526 fn the_title_comes_from_the_play_and_stays_after_it_ends() {527 let mut unit = seeded();528 unit.fold(529 Moment::Status(Status {530 activity: Activity::Playing,531 play: Some(Play {532 name: "den-tv-1".into(),533 title: "A Film".into(),534 }),535 ..status(Vec::new())536 }),537 1.0,538 );539 assert_eq!(unit.title.as_deref(), Some("A Film"));540541 unit.fold(Moment::Status(status(Vec::new())), 5.0);542 assert_eq!(unit.title.as_deref(), Some("A Film"));543 assert_eq!(unit.activity, Activity::Idle);544 }545546 #[test]547 fn a_play_with_no_title_reads_its_object_name() {548 let mut unit = seeded();549 unit.fold(550 Moment::Status(Status {551 play: Some(Play {552 name: "den-tv-1".into(),553 title: String::new(),554 }),555 ..status(Vec::new())556 }),557 1.0,558 );559 assert_eq!(unit.title.as_deref(), Some("den-tv-1"));560 }561562 #[test]563 fn the_catch_up_sets_the_level_and_a_press_sets_the_second() {564 let mut unit = seeded();565 let volume = Volume {566 level: 40,567 muted: false,568 };569570 unit.fold(571 Moment::Level {572 volume,573 pressed: false,574 },575 1.0,576 );577 assert_eq!(unit.volume, volume);578 assert_eq!(unit.pressed, None);579580 unit.fold(581 Moment::Level {582 volume: Volume {583 level: 45,584 muted: false,585 },586 pressed: true,587 },588 2.5,589 );590 assert_eq!(unit.volume.level, 45);591 assert_eq!(unit.pressed, Some(2.5));592 }593594 #[test]595 fn the_shade_holds_the_way_it_moves_and_the_second_it_started() {596 let mut unit = seeded();597 assert_eq!(unit.shade, None);598599 unit.fold(Moment::Sleep, 30.0);600 assert_eq!(601 unit.shade,602 Some(Shade {603 down: true,604 since: 30.0,605 from: 0.0606 })607 );608609 // The screen reached black at 34.0, four seconds after the sleep, so610 // the wake clears from a full cover.611 unit.fold(Moment::Wake, 41.5);612 assert_eq!(613 unit.shade,614 Some(Shade {615 down: false,616 since: 41.5,617 from: 1.0618 })619 );620 }621622 #[test]623 fn a_press_during_the_fade_to_black_clears_from_the_grey_it_reached() {624 let mut unit = seeded();625 unit.fold(Moment::Sleep, 10.0);626 unit.fold(Moment::Wake, 12.0);627628 // Halfway through the four seconds the cover stands at half.629 assert_eq!(630 unit.shade,631 Some(Shade {632 down: false,633 since: 12.0,634 from: 0.5635 })636 );637 }638639 #[test]640 fn a_run_of_presses_holds_one_ease_rather_than_restarting_it() {641 let mut unit = seeded();642 unit.fold(Moment::Sleep, 10.0);643 unit.fold(Moment::Wake, 12.0);644 unit.fold(Moment::Wake, 12.2);645646 assert_eq!(unit.shade.map(|shade| shade.since), Some(12.0));647 }648649 #[test]650 fn a_wake_on_a_clear_screen_starts_no_ease() {651 let mut unit = seeded();652 unit.fold(Moment::Wake, 3.0);653 assert_eq!(unit.shade, None);654 }655656 #[test]657 fn the_ramp_starts_over_on_a_change_of_activity_and_not_on_a_repeat() {658 let mut unit = seeded();659 unit.fold(660 Moment::Status(Status {661 activity: Activity::Starting,662 ..status(Vec::new())663 }),664 2.0,665 );666 assert_eq!(unit.ramp.since, 2.0);667668 unit.fold(669 Moment::Status(Status {670 activity: Activity::Starting,671 ..status(Vec::new())672 }),673 2.5,674 );675 assert_eq!(unit.ramp.since, 2.0);676677 unit.fold(Moment::Status(status(Vec::new())), 3.2);678 assert_eq!(unit.ramp.since, 3.2);679 assert_eq!(unit.ramp.from, 1.0);680 }681682 #[test]683 fn a_press_during_the_fade_out_lifts_the_row_from_where_it_stands() {684 let mut unit = seeded();685 let volume = Volume {686 level: 40,687 muted: false,688 };689690 unit.fold(691 Moment::Level {692 volume,693 pressed: false,694 },695 0.0,696 );697 unit.fold(698 Moment::Level {699 volume,700 pressed: true,701 },702 1.0,703 );704 assert_eq!(unit.pressed_from, 0.0);705706 // The row leaves four seconds after the press, over 600 ms, so at 5.3707 // seconds it stands halfway out.708 unit.fold(709 Moment::Level {710 volume,711 pressed: true,712 },713 5.3,714 );715 assert_eq!(unit.pressed, Some(5.3));716 assert!(717 (unit.pressed_from - 0.5).abs() < 1e-9,718 "{}",719 unit.pressed_from720 );721 }722723 #[test]724 fn a_focus_moment_beats_the_controller_it_counted_to() {725 let mut unit = seeded();726 unit.fold(727 Moment::Status(status(vec![728 component("The screen", "display", None),729 component("A remote", "remote", Some(true)),730 component("Another remote", "remote", Some(true)),731 ])),732 1.0,733 );734735 unit.fold(Moment::Focus { remote: 1 }, 3.0);736737 assert_eq!(unit.parts[1].marked, None);738 assert_eq!(unit.parts[2].marked, Some(3.0));739 }740741 #[test]742 fn a_focus_moment_that_names_no_controller_changes_nothing() {743 let mut unit = seeded();744 let before = unit.clone();745 unit.fold(Moment::Focus { remote: 4 }, 3.0);746 assert_eq!(unit, before);747 }748749 #[test]750 fn a_part_carries_the_charge_the_status_reports() {751 let mut unit = seeded();752 unit.fold(753 Moment::Status(status(vec![Component {754 battery: Some(62),755 ..component("A remote", "remote", Some(true))756 }])),757 1.0,758 );759 assert_eq!(unit.parts[0].battery, Some(62));760761 // The device stops reporting a level, so the line drops it.762 unit.fold(763 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),764 2.0,765 );766 assert_eq!(unit.parts[0].battery, None);767 }768769 #[test]770 fn a_part_that_keeps_its_name_keeps_the_seconds_it_draws_from() {771 let mut unit = seeded();772 unit.fold(773 Moment::Status(status(vec![component("A remote", "remote", Some(true))])),774 1.0,775 );776 unit.fold(Moment::Focus { remote: 0 }, 2.0);777778 unit.fold(779 Moment::Status(status(vec![Component {780 focused: Some(true),781 ..component("A remote", "remote", Some(true))782 }])),783 3.0,784 );785786 assert_eq!(unit.parts[0].marked, Some(2.0));787 assert!(unit.parts[0].focused);788 }789790 #[test]791 fn a_present_leaves_the_unit_alone() {792 let mut unit = seeded();793 let before = unit.clone();794 unit.fold(Moment::Present, 3.0);795 assert_eq!(unit, before);796 }797}
idle/src/wiring.rs 100.0%
1// The variables the operator sets on the idle container that this client2// reads for itself. `media-operator` names them in `wire.go` on the writing3// side, so the two files are one contract and a name here matches a name4// there.5//6// Everything about the bus, the two windows, and the unit's controllers7// is the `media-screen` crate's contract, and `media_screen::Wiring`8// reads it from the same environment. What is left here is what this9// client alone draws with: the seeds for the first frame, the app-id10// its surface asks for, its window watchdog, and the preview keys.11//12// A pod cannot discover the broker in front of it, the topic base its cluster13// chose, or the Wayland app-id its display claim delivered. The operator holds14// all three and passes them down, so every value below arrives in the15// environment and none is guessed.1617use std::time::Duration;1819/// The unit's friendly name, and its parts joined with newlines. They seed the20/// identity block, so the first frame is never blank and a workstation needs no21/// broker. The first status replaces both.22pub const PLAYER_NAME: &str = "IDLE_PLAYER_NAME";23pub const PLAYER_COMPONENTS: &str = "IDLE_PLAYER_COMPONENTS";2425/// The Wayland app-id the surface must request. The display operator writes it26/// into the container at run time from the claim's CDI spec, and the27/// compositor routes the surface to the right screen by it.28pub const APP_ID: &str = "DISPLAY_APP_ID";2930/// The seconds the client waits for a window before it exits. An unset or31/// non-positive value leaves the watchdog off, so a run outside a pod never32/// exits for a missing window.33pub const WINDOW_GRACE: &str = "IDLE_WINDOW_GRACE_SECONDS";3435/// The variable that binds the preview keys, which stand in for the bus on a36/// workstation. `local/idle` sets it, and the operator sets it on no pod, so a37/// cluster's idle screen binds no key and draws no legend.38pub const PREVIEW: &str = "IDLE_PREVIEW";3940/// Everything the operator told this container.41#[derive(Debug, Clone, Default, PartialEq)]42pub struct Wiring {43 /// The broker, the topics, the controllers, and the two windows, as the44 /// `media-screen` crate reads them.45 pub screen: media_screen::Wiring,46 pub player_name: String,47 pub components: Vec<String>,48 pub app_id: String,49 pub window_grace: Option<Duration>,50 /// Whether the preview keys stand in for the bus. It is true only where51 /// [`PREVIEW`] is `1`.52 pub preview: bool,53}5455impl Wiring {56 /// What this process runs under.57 pub fn from_environment() -> Self {58 Self::read(|name| std::env::var(name).ok())59 }6061 /// The same read against any source of values. The environment is global to62 /// a process, so a test states the variables here instead of setting them63 /// and racing every other test in the binary.64 pub fn read(value: impl Fn(&str) -> Option<String>) -> Self {65 let read = |name| value(name).unwrap_or_default();6667 Self {68 // The crate reads the same source, so one environment answers both69 // halves and neither one names the other's variables.70 screen: media_screen::Wiring::read(&value),71 player_name: read(PLAYER_NAME),72 components: split_lines(&read(PLAYER_COMPONENTS)),73 app_id: read(APP_ID),74 window_grace: grace(&read(WINDOW_GRACE)),75 // One exact value binds the keys. Anything else, an unset variable76 // included, leaves them unbound, so a variable that survives into77 // a pod by accident cannot put a legend on a screen in a house.78 preview: read(PREVIEW) == "1",79 }80 }81}8283/// The parts, one per line. The operator joins them with newlines and sets the84/// variable only for a unit that lists any, so an absent value is a unit with85/// no parts and not an error. A blank line names no part and draws nothing, so86/// it is dropped here rather than left as an empty row in the identity block.87pub fn split_lines(text: &str) -> Vec<String> {88 text.lines()89 .filter(|line| !line.is_empty())90 .map(str::to_string)91 .collect()92}9394/// The window grace, in seconds. Anything but a positive number leaves the95/// watchdog off.96fn grace(text: &str) -> Option<Duration> {97 let seconds: f64 = text.trim().parse().ok()?;98 if seconds <= 0.0 || !seconds.is_finite() {99 return None;100 }101 Some(Duration::from_secs_f64(seconds))102}103104#[cfg(test)]105mod tests {106 use super::*;107108 fn wiring(pairs: &[(&str, &str)]) -> Wiring {109 let pairs: Vec<(String, String)> = pairs110 .iter()111 .map(|(name, value)| (name.to_string(), value.to_string()))112 .collect();113 Wiring::read(|name| {114 pairs115 .iter()116 .find(|(set, _)| set == name)117 .map(|(_, value)| value.clone())118 })119 }120121 #[test]122 fn an_empty_environment_wires_nothing() {123 assert_eq!(Wiring::read(|_| None), Wiring::default());124 }125126 #[test]127 fn a_process_reads_its_own_environment() {128 assert_eq!(Wiring::from_environment(), Wiring::default());129 }130131 #[test]132 fn every_variable_this_client_reads_lands_in_the_wiring() {133 let read = wiring(&[134 (PLAYER_NAME, "The Den"),135 (PLAYER_COMPONENTS, "The screen\nThe speakers"),136 (APP_ID, "media-den-tv"),137 (WINDOW_GRACE, "30"),138 ]);139140 assert_eq!(read.player_name, "The Den");141 assert_eq!(read.components, ["The screen", "The speakers"]);142 assert_eq!(read.app_id, "media-den-tv");143 assert_eq!(read.window_grace, Some(Duration::from_secs(30)));144 }145146 #[test]147 fn the_crates_own_variables_reach_the_crates_wiring() {148 let read = wiring(&[149 (media_screen::wiring::BUS_ADDRESS, "broker:1883"),150 (151 media_screen::wiring::STATUS_TOPIC,152 "media/players/den/tv/status",153 ),154 (media_screen::wiring::FADE_AFTER_SECONDS, "600"),155 ]);156157 assert_eq!(read.screen.bus_address, "broker:1883");158 assert_eq!(read.screen.status_topic, "media/players/den/tv/status");159 assert_eq!(read.screen.fade_after, Duration::from_secs(600));160 }161162 #[test]163 fn a_unit_with_no_parts_lists_none() {164 assert!(split_lines("").is_empty());165 assert_eq!(split_lines("The screen"), ["The screen"]);166 }167168 #[test]169 fn a_blank_line_names_no_part() {170 assert_eq!(171 split_lines("The screen\n\nA remote\n"),172 ["The screen", "A remote"]173 );174 }175176 #[test]177 fn a_positive_grace_arms_the_watchdog() {178 assert_eq!(grace("30"), Some(Duration::from_secs(30)));179 assert_eq!(grace(" 1.5 "), Some(Duration::from_secs_f64(1.5)));180 }181182 #[test]183 fn anything_but_a_positive_grace_leaves_it_off() {184 assert_eq!(grace(""), None);185 assert_eq!(grace("0"), None);186 assert_eq!(grace("-5"), None);187 assert_eq!(grace("soon"), None);188 assert_eq!(grace("inf"), None);189 }190191 #[test]192 fn one_exact_value_binds_the_preview_keys() {193 assert!(wiring(&[(PREVIEW, "1")]).preview);194 assert!(!wiring(&[(PREVIEW, "true")]).preview);195 assert!(!wiring(&[]).preview);196 }197}