media-operator

InputUnitCoveredTotalPercent
Gostatements3151364086.6%
Rust (media-screen)lines876876100.0%
Rust (idle-screen)lines3328349295.3%

Go

3151 of 3640 statements, 86.6%.

FileCovered statementsTotal statementsPercent
album.go303293.8%
api.go1010100.0%
apiclient.go16317095.9%
art.go12813098.5%
artserve.go0180.0%
basekeys.go272896.4%
bus.go9610690.6%
claims.go475585.5%
codes.go535498.1%
command.go20925282.9%
commandkeys.go415082.0%
discovery.go272896.4%
edl/album.go869491.5%
edl/format.go646795.5%
evdev.go405967.8%
focus.go565798.2%
idlepod.go747598.7%
images.go363797.3%
input.go2727100.0%
keybindings.go88100.0%
keymap.go606493.8%
local/edl/main.go2238.7%
local/present/main.go10011686.2%
main.go0110.0%
mpvipc.go242788.9%
mqtt.go768095.0%
musicart.go12314187.2%
operate.go33048068.8%
panel.go2121100.0%
peripherals.go4545100.0%
player.go132846.4%
playerstatus.go789185.7%
pod.go848895.5%
remote.go7613058.5%
remotekeys.go868996.6%
remotepod.go1515100.0%
report.go5050100.0%
resolve.go16917298.3%
screen.go646894.1%
standing.go627286.1%
status.go778590.6%
topics.go747697.4%
trickplay.go13214193.6%
volume.go4646100.0%
watch.go12212498.4%
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%.

FileCovered linesTotal linesPercent
media-screen/src/lib.rs77100.0%
media-screen/src/panel.rs77100.0%
media-screen/src/reader.rs183183100.0%
media-screen/src/screen.rs267267100.0%
media-screen/src/screen/keys.rs5656100.0%
media-screen/src/screen/press.rs5454100.0%
media-screen/src/status.rs5858100.0%
media-screen/src/volume.rs102102100.0%
media-screen/src/wiring.rs142142100.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%.

FileCovered linesTotal linesPercent
idle/src/client.rs24724999.2%
idle/src/clock.rs2929100.0%
idle/src/harness.rs15520376.4%
idle/src/harness/capture.rs758093.8%
idle/src/harness/frame.rs17719292.2%
idle/src/harness/graphics.rs11913786.9%
idle/src/harness/options.rs16616998.2%
idle/src/harness/stats.rs16316499.4%
idle/src/harness/timeline.rs9393100.0%
idle/src/harness/watchdog.rs727694.7%
idle/src/idle.rs10510699.1%
idle/src/idle/activity.rs455483.3%
idle/src/idle/clock.rs2020100.0%
idle/src/idle/energy.rs261261100.0%
idle/src/idle/identity.rs43145794.3%
idle/src/idle/mark.rs5151100.0%
idle/src/idle/preview.rs22122996.5%
idle/src/idle/shade.rs7171100.0%
idle/src/idle/volume.rs18920691.7%
idle/src/look.rs11211399.1%
idle/src/main.rs91560.0%
idle/src/unit.rs418418100.0%
idle/src/wiring.rs9999100.0%
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}