media-screen

InputUnitCoveredTotalPercent
Rustlines1277128399.5%

Rust

1277 of 1283 lines, 99.5%.

FileCovered linesTotal linesPercent
src/lib.rs77100.0%
src/metrics.rs515396.2%
src/panel.rs2121100.0%
src/reader.rs21121299.5%
src/reader/alarm.rs3737100.0%
src/screen.rs38838999.7%
src/screen/commands.rs636498.4%
src/screen/keys.rs5656100.0%
src/screen/power.rs495098.0%
src/screen/press.rs5454100.0%
src/status.rs7777100.0%
src/volume.rs112112100.0%
src/wiring.rs151151100.0%
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 level `media-operator` relays,7//! and the cycle request. [`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. A client also names topics of its own, and [`Reader`]11//! subscribes to those on the same connection, so a client reads back12//! the retained state it owns.13//!14//! Two clients read it: this repository's idle screen, and the library15//! layer's media browser, which takes it as a git dependency pinned to16//! a release tag. The crate opens no window, holds no keymap of its17//! own, and names no toolkit.1819pub mod metrics;20pub mod panel;21pub mod reader;22pub mod screen;23pub mod status;24pub mod volume;25pub mod wiring;2627pub use reader::{Bus, Reader, Waker};28pub use screen::{Effect, Moment, Publish, Screen};29pub use wiring::Wiring;3031/// Read one JSON object off a topic. Every payload on these topics is an32/// object, so a payload that is not one is not this operator's and it changes33/// nothing. The check is here because a derived reader also takes a JSON array34/// as the same fields in order, and a two-element array is not a status.35pub(crate) fn object<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Option<T> {36    let value: serde_json::Value = serde_json::from_slice(payload).ok()?;37    if !value.is_object() {38        return None;39    }40    serde_json::from_value(value).ok()41}
src/metrics.rs 96.2%
1//! The two gauges this crate reports on its own, through whatever recorder2//! the client that links it installed. [`build_info`] runs once, at start.3//! [`bus_connected`] runs from [`crate::reader`], on every change the4//! connection goes through.5//!6//! Neither function opens a listener or names a port: that is the7//! consuming binary's own setting, read the way every other one is, in8//! its own `wiring`. A client with no recorder installed pays for a9//! macro call that finds nowhere to go, which is what lets a10//! workstation run with none.1112/// `liken_build_info{component, version}`, milestone 65's one gauge every13/// process in the organization reports under its own name. `component` is14/// this binary's identity, fixed in its own source; `version` is the tag15/// the operator resolved this client's image from, so a mixed fleet shows16/// on one panel.17pub fn build_info(component: &str, version: &str) {18    metrics::gauge!(19        "liken_build_info",20        "component" => component.to_string(),21        "version" => version.to_string(),22    )23    .set(1.0);24}2526/// `media_bus_connected`, 1 while the session is up and 0 the moment it27/// is not. [`crate::reader::read`] calls this on every `ConnAck` and every28/// socket error, so a scrape never reads a session that ended with no word29/// of it.30pub fn bus_connected(connected: bool) {31    metrics::gauge!("media_bus_connected").set(if connected { 1.0 } else { 0.0 });32}3334#[cfg(test)]35mod tests {36    use metrics::with_local_recorder;37    use metrics_util::debugging::{DebugValue, DebuggingRecorder};3839    use super::*;4041    /// Read the one gauge value a body sets, under a recorder of the test's42    /// own. A local recorder never touches the process-wide one, so the43    /// crate's other tests never race it.44    fn gauge(name: &str, body: impl FnOnce()) -> Option<(f64, Vec<(String, String)>)> {45        let recorder = DebuggingRecorder::new();46        let snapshotter = recorder.snapshotter();47        with_local_recorder(&recorder, body);48        snapshotter49            .snapshot()50            .into_vec()51            .into_iter()52            .find_map(|(key, _, _, value)| {53                if key.key().name() != name {54                    return None;55                }56                let DebugValue::Gauge(value) = value else {57                    return None;58                };59                let labels = key60                    .key()61                    .labels()62                    .map(|label| (label.key().to_string(), label.value().to_string()))63                    .collect();64                Some((value.into_inner(), labels))65            })66    }6768    #[test]69    fn build_info_reports_the_component_and_the_version() {70        let (value, labels) = gauge("liken_build_info", || {71            build_info("idle-screen", "2026.09.10-001")72        })73        .expect("build_info sets the gauge");7475        assert_eq!(value, 1.0);76        assert_eq!(77            labels,78            [79                ("component".to_string(), "idle-screen".to_string()),80                ("version".to_string(), "2026.09.10-001".to_string()),81            ]82        );83    }8485    #[test]86    fn bus_connected_reports_one_while_up() {87        let (value, _) = gauge("media_bus_connected", || bus_connected(true))88            .expect("bus_connected sets the gauge");89        assert_eq!(value, 1.0);90    }9192    #[test]93    fn bus_connected_reports_zero_once_the_session_ends() {94        let (value, _) = gauge("media_bus_connected", || bus_connected(false))95            .expect("bus_connected sets the gauge");96        assert_eq!(value, 0.0);97    }98}
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::{Deserialize, 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/// The payload as a reader takes it off the topic. A client reads the32/// retained desire back before it states one of its own.33#[derive(Debug, Deserialize)]34pub struct Stated {35    #[serde(default)]36    desire: String,37}3839impl Stated {40    /// The desire as one of the two words this crate states. A word it does41    /// not state is no desire, so a newer writer's word changes nothing here.42    pub fn desire(&self) -> Option<&'static str> {43        match self.desire.as_str() {44            ON => Some(ON),45            OFF => Some(OFF),46            _ => None,47        }48    }49}5051#[cfg(test)]52mod tests {53    use super::*;5455    #[test]56    fn a_reader_takes_each_desire_back_and_no_other_word() {57        let read =58            |payload: &[u8]| crate::object::<Stated>(payload).and_then(|stated| stated.desire());59        assert_eq!(read(br#"{"desire":"on"}"#), Some(ON));60        assert_eq!(read(br#"{"desire":"off"}"#), Some(OFF));61        assert_eq!(read(br#"{"desire":"dim"}"#), None);62        assert_eq!(read(b""), None);63    }6465    #[test]66    fn each_desire_is_one_word_on_the_topic() {67        assert_eq!(Desire { desire: ON }.payload(), br#"{"desire":"on"}"#);68        assert_eq!(Desire { desire: OFF }.payload(), br#"{"desire":"off"}"#);69    }70}
src/reader.rs 99.5%
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 windows3//! and the power ask's deadline, 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;2425mod alarm;2627use alarm::Alarm;2829/// The port a broker answers on when the address names none.30const DEFAULT_PORT: u16 = 1883;3132/// The keepalive this client asks for. It is the interval `media-operator`'s33/// own bus client asks for, so every client of one broker keeps the same34/// clock.35const KEEPALIVE: Duration = Duration::from_secs(30);3637/// The largest packet this client sends or accepts. rumqttc caps both at38/// ten kilobytes unless told otherwise, and a play request for a whole39/// season of episodes with the work that follows it is larger than that.40/// The broker sets no limit of its own.41const MAX_PACKET_SIZE: usize = 256 * 1024;4243/// The bounds of the wait after a failed session. The wait starts at the44/// floor, doubles on each failure up to the ceiling, and returns to the floor45/// on the next session, so a broker that is down is no tight reconnect loop46/// and a broker that returns is reached within the ceiling. They are the47/// bounds `media-operator`'s own bus client uses.48const RECONNECT_MIN: Duration = Duration::from_secs(1);49const RECONNECT_MAX: Duration = Duration::from_secs(30);5051/// The capacity of `rumqttc`'s outbound request queue, which carries the52/// subscribes and every publish the rules ask for. The inbound path to the53/// client is an unbounded channel, and it drops nothing.54const QUEUE_DEPTH: usize = 64;5556/// A handle that wakes the client's event loop from any thread.57pub type Waker = Arc<dyn Fn() + Send + Sync>;5859/// What a client needs from the bus. It is a trait so a client's own tests60/// fold real moments and see a real request with no socket under them.61pub trait Bus: std::fmt::Debug {62    /// Every moment that arrived since the last call. The call never blocks.63    fn drain(&self) -> Vec<Moment>;6465    /// Ask for the shade, from the client's own reading of a press. The stock66    /// idle client asks on back; a client with levels asks at its top level,67    /// because only the client knows whether back has anywhere to go.68    fn sleep(&self);6970    /// Publish one payload on a topic of the client's own, on the71    /// connection this crate already holds. A client with a request of72    /// its own, such as the library layer's browser asking for a73    /// `Play`, must not open a second connection to the same broker74    /// under a second identifier. The rules in [`Screen`] neither read75    /// the topic nor act on it. The client hears a message back on it76    /// only when the client named it to [`Reader::open`].77    fn publish(&self, topic: &str, payload: Vec<u8>, retained: bool);7879    /// Wake the loop on every delivery, so a press shows on the next frame80    /// rather than at the next scheduled second.81    fn wake_on_delivery(&self, wake: Waker);82}8384/// The slot the threads read their waker from. The loop does not exist yet85/// when the reader connects, so the waker arrives after the threads start,86/// and each one reads the slot on every delivery.87type WakerSlot = Arc<Mutex<Option<Waker>>>;8889/// The subscription, held by the client.90pub struct Reader {91    moments: mpsc::Receiver<Moment>,92    /// The rules. Both threads hold a weak reference to this one value, so93    /// dropping the reader ends both of them, and a client that opened a94    /// reader and let it go leaves no thread behind.95    screen: Arc<Mutex<Screen>>,96    threads: Threads,97}9899impl std::fmt::Debug for Reader {100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {101        f.debug_struct("Reader").finish_non_exhaustive()102    }103}104105impl Reader {106    /// Connect to the broker the wiring names and subscribe. The answer is107    /// `None` when the operator named no broker or no topics, which is how a108    /// client runs on a workstation with its seeds alone.109    ///110    /// `client_id` must name this client alone, because a broker closes the111    /// older connection when two arrive under one identifier.112    ///113    /// `client_topics` are topics the client owns. The reader subscribes to114    /// them beside the screen's own on every session, and every message on115    /// one arrives as a [`Moment::Message`]. They count as topics: a client116    /// that names one opens a reader over a wiring that names none.117    pub fn open(wiring: &Wiring, client_id: &str, client_topics: &[String]) -> Option<Self> {118        let screen = Screen::new(wiring).reading(client_topics);119        let (host, port) = broker(&wiring.bus_address)?;120        if screen.filters().is_empty() {121            return None;122        }123124        let mut options = MqttOptions::new(client_id, host, port);125        options.set_keep_alive(KEEPALIVE);126        options.set_max_packet_size(MAX_PACKET_SIZE, MAX_PACKET_SIZE);127        let (client, connection) = Broker::new(options, QUEUE_DEPTH);128        // A scrape before the first session sees a broker configured but not129        // yet reached, not the silence an unset MEDIA_BUS_ADDRESS reports.130        crate::metrics::bus_connected(false);131132        let (sender, moments) = mpsc::channel();133        let screen = Arc::new(Mutex::new(screen));134        let threads = Threads {135            screen: Arc::downgrade(&screen),136            client,137            sender,138            waker: Arc::default(),139            alarm: Arc::default(),140        };141142        // The connection thread never leaves its loop, so the two windows run143        // on a thread of their own. A deadline on the connection itself would144        // have to cancel a read of the socket to fire, and a cancelled read145        // can lose the press it was in the middle of.146        let reading = threads.clone();147        spawn("media-bus", move || {148            let mut connection = connection;149            read(&reading, connection.iter());150        })?;151        let ticking = threads.clone();152        spawn("media-clock", move || clock(&ticking))?;153154        Some(Self {155            moments,156            screen,157            threads,158        })159    }160}161162impl Bus for Reader {163    /// The call costs the decoding and nothing else, because the threads164    /// decoded every moment before the channel.165    fn drain(&self) -> Vec<Moment> {166        self.moments.try_iter().collect()167    }168169    /// The shade comes back through [`Bus::drain`] the way every other moment170    /// does, so the client folds one stream.171    fn sleep(&self) {172        let effects = fold(&self.screen, &self.threads.alarm, |screen| {173            screen.sleep(Instant::now())174        });175        perform(&self.threads, effects);176    }177178    /// The publish is queued rather than sent, the way every publish the179    /// rules ask for is, because the reader thread is what drives the180    /// connection.181    fn publish(&self, topic: &str, payload: Vec<u8>, retained: bool) {182        send(183            &self.threads.client,184            Publish {185                topic: topic.to_string(),186                payload,187                retained,188            },189        );190    }191192    /// Without a waker a moment waits in the channel for the next scheduled193    /// wake, and a press then shows up to a second late.194    fn wake_on_delivery(&self, wake: Waker) {195        *self196            .threads197            .waker198            .lock()199            .expect("no reader panics with the lock") = Some(wake);200    }201}202203/// The reader thread ends on its next event after the drop, because it finds204/// the rules gone. The clock can sleep with no timeout, so the drop closes its205/// alarm to end it.206impl Drop for Reader {207    fn drop(&mut self) {208        self.threads.alarm.close();209    }210}211212/// What each thread of this crate holds: the rules, the connection to publish213/// on, the channel to the client, the slot the client loop's waker arrives in,214/// and the alarm that wakes the clock thread.215#[derive(Clone)]216struct Threads {217    screen: Weak<Mutex<Screen>>,218    client: Broker,219    sender: mpsc::Sender<Moment>,220    waker: WakerSlot,221    alarm: Arc<Alarm>,222}223224impl Threads {225    /// The rules, while a client still holds them. A thread that reads226    /// nothing here ends, because the client its work is for is gone.227    fn screen(&self) -> Option<Arc<Mutex<Screen>>> {228        self.screen.upgrade()229    }230}231232/// Start one of this crate's threads. A client that spawns none draws its233/// seeds and hears nothing for the life of the pod, so the line says why.234fn spawn(name: &str, body: impl FnOnce() + Send + 'static) -> Option<()> {235    std::thread::Builder::new()236        .name(name.into())237        .spawn(body)238        .inspect_err(|error| eprintln!("media-screen: {name}: {error}"))239        .ok()240        .map(|_| ())241}242243/// The reader thread. It subscribes on every connection, because a broker244/// holds no subscription across a session, and it folds each message through245/// the rules before the channel, so the client's loop takes finished values.246fn read(threads: &Threads, events: impl Iterator<Item = Result<Event, ConnectionError>>) {247    let mut backoff = RECONNECT_MIN;248    for event in events {249        let Some(screen) = threads.screen() else {250            return;251        };252        let effects = match event {253            Ok(Event::Incoming(Packet::ConnAck(_))) => {254                crate::metrics::bus_connected(true);255                backoff = RECONNECT_MIN;256                fold(&screen, &threads.alarm, |screen| {257                    let effects = screen.connected();258                    let filters = screen.filters().into_iter().map(|path| SubscribeFilter {259                        path,260                        qos: QoS::AtMostOnce,261                    });262                    // The subscribe is queued rather than sent, because this263                    // thread is the one that drives the connection. A blocking264                    // send would wait for a reader that is this loop.265                    let _ = threads.client.try_subscribe_many(filters);266                    effects267                })268            }269            Ok(Event::Incoming(Packet::Publish(message))) => {270                fold(&screen, &threads.alarm, |screen| {271                    screen.deliver(272                        &message.topic,273                        &message.payload,274                        message.retain,275                        Instant::now(),276                    )277                })278            }279            Err(error) => {280                crate::metrics::bus_connected(false);281                // The client reconnects on its own, so the line is the record282                // and not a request for anything.283                eprintln!("media-screen: bus: {error}");284                std::thread::sleep(backoff);285                backoff = next_backoff(backoff);286                Vec::new()287            }288            Ok(_) => Vec::new(),289        };290        if !perform(threads, effects) {291            // The client dropped its reader, so nothing reads what this292            // thread decodes.293            return;294        }295    }296}297298/// The clock thread, which is the two windows and the power ask's deadline.299/// It sleeps to the earliest armed deadline, or with no timeout while300/// nothing is armed, so a screen at rest wakes this thread for nothing. A fold on another thread that arms a301/// window, or moves one earlier, rings the alarm, and the clock reads the302/// deadline again.303fn clock(threads: &Threads) {304    loop {305        let Some(screen) = threads.screen() else {306            return;307        };308        // The ring count is read before the deadline, so a window armed309        // between this read and the wait still ends the wait.310        let seen = threads.alarm.seen();311        let deadline = screen312            .lock()313            .expect("no thread panics with the lock")314            .next_deadline();315        // The clock gives up the rules before the wait, so the reader thread316        // and the client both lock them while this one waits, and a dropped317        // reader frees them.318        drop(screen);319        if !threads.alarm.wait(seen, deadline) {320            return;321        }322323        let Some(screen) = threads.screen() else {324            return;325        };326        let effects = fold(&screen, &threads.alarm, |screen| {327            screen.tick(Instant::now())328        });329        if !perform(threads, effects) {330            return;331        }332    }333}334335/// The wait after the next failure: twice this one, and never above the336/// ceiling.337fn next_backoff(backoff: Duration) -> Duration {338    (backoff * 2).min(RECONNECT_MAX)339}340341/// Run one fold under the lock and print the lines it wrote, one per342/// operation a person caused, before the lock is released, so two threads'343/// lines never interleave out of the order the rules ran them in.344///345/// A fold that arms a window, or moves the armed one earlier, rings the346/// clock's alarm. A window that moved later or disarmed rings nothing: the347/// clock wakes at the earlier moment it already waits for, finds the window348/// not yet due, and waits again. So a press, which restarts the quiet window349/// later, does not wake the clock.350fn fold(351    screen: &Mutex<Screen>,352    alarm: &Alarm,353    rule: impl FnOnce(&mut Screen) -> Vec<Effect>,354) -> Vec<Effect> {355    let mut screen = screen.lock().expect("no thread panics with the lock");356    let before = screen.next_deadline();357    let effects = rule(&mut screen);358    for line in screen.take_lines() {359        eprintln!("media-screen: {line}");360    }361    let earlier = screen362        .next_deadline()363        .is_some_and(|at| before.is_none_or(|then| at < then));364    drop(screen);365    if earlier {366        alarm.ring();367    }368    effects369}370371/// Perform one fold's effects: send each moment to the client, publish each372/// message on the connection, and wake the loop once if anything reached the373/// client. The answer is false only when the client dropped its receiver,374/// which ends the thread that called.375///376/// The wake follows the sends, so the loop reads a whole fold on one pass377/// rather than one moment per wake.378fn perform(threads: &Threads, effects: Vec<Effect>) -> bool {379    let mut drew = false;380    for effect in effects {381        match effect {382            Effect::Moment(moment) => {383                if threads.sender.send(moment).is_err() {384                    return false;385                }386                drew = true;387            }388            Effect::Publish(publish) => send(&threads.client, publish),389        }390    }391    if drew392        && let Some(wake) = threads393            .waker394            .lock()395            .expect("no client panics with the lock")396            .as_ref()397    {398        wake();399    }400    true401}402403/// One publish, queued rather than sent, because the reader thread is what404/// drives the connection. It goes at QoS 0, and the rules say which messages405/// the broker retains.406fn send(client: &Broker, publish: Publish) {407    if let Err(error) = client.try_publish(408        publish.topic,409        QoS::AtMostOnce,410        publish.retained,411        publish.payload,412    ) {413        eprintln!("media-screen: bus: {error}");414    }415}416417/// The identifier this client connects under. It must name this client alone,418/// because a broker closes the older connection when two arrive under one419/// identifier. `prefix` is the client's own name, so two different clients on420/// one machine do not collide either.421pub fn client_id(prefix: &str, hostname: &str) -> String {422    match hostname.trim() {423        "" => prefix.to_string(),424        host => format!("{prefix}-{host}"),425    }426}427428/// The name this machine answers to. In a pod it is the pod's own name, which429/// is unique in the cluster.430pub fn hostname() -> String {431    std::fs::read_to_string("/etc/hostname").unwrap_or_default()432}433434/// The broker's host and port. An address with no port answers on the MQTT435/// default. An empty address is no broker at all.436///437/// An IPv6 literal carries colons of its own, so the port follows the438/// brackets the URI form puts around the address, and a bare literal is the439/// host alone on the default port.440fn broker(address: &str) -> Option<(String, u16)> {441    let address = address.trim();442    if address.is_empty() {443        return None;444    }445    if let Some(rest) = address.strip_prefix('[') {446        let (host, after) = rest.split_once(']')?;447        return match after {448            "" => Some((host.to_string(), DEFAULT_PORT)),449            after => Some((host.to_string(), after.strip_prefix(':')?.parse().ok()?)),450        };451    }452    if address.matches(':').count() > 1 {453        return Some((address.to_string(), DEFAULT_PORT));454    }455    match address.rsplit_once(':') {456        Some((host, port)) => Some((host.to_string(), port.parse().ok()?)),457        None => Some((address.to_string(), DEFAULT_PORT)),458    }459}460461#[cfg(test)]462mod tests;
src/reader/alarm.rs 100.0%
1//! The alarm the clock thread waits on. The clock sleeps until the armed2//! deadline, or with no timeout while nothing is armed, so an idle screen3//! wakes this thread for nothing. Another thread can arm or move a window4//! while the clock sleeps: the reader thread on a delivery, and the client5//! through [`crate::Bus::sleep`]. That thread rings the alarm, and the clock6//! reads the deadline again.78use std::sync::{Condvar, Mutex};9use std::time::Instant;1011/// A ring count and a closed mark behind one lock, and the condition12/// variable the clock waits on.13///14/// The count guards against a lost ring. The clock reads the count before it15/// reads the deadline, and its wait returns at once when the count moved16/// since that read. A window armed between the clock's read of the deadline17/// and the start of its wait therefore still ends the wait.18#[derive(Debug, Default)]19pub(super) struct Alarm {20    state: Mutex<State>,21    bell: Condvar,22}2324#[derive(Debug, Default)]25struct State {26    rings: u64,27    closed: bool,28}2930impl Alarm {31    /// The ring count now. The clock reads it before it reads the deadline,32    /// and hands it to [`Alarm::wait`].33    pub(super) fn seen(&self) -> u64 {34        self.lock().rings35    }3637    /// Wake the clock, because a window was armed or moved earlier.38    pub(super) fn ring(&self) {39        self.lock().rings += 1;40        self.bell.notify_all();41    }4243    /// Wake the clock for the last time, because the client dropped its44    /// reader. The clock sleeps with no timeout while nothing is armed, so45    /// without this call the thread never ends.46    pub(super) fn close(&self) {47        self.lock().closed = true;48        self.bell.notify_all();49    }5051    /// Sleep until `deadline`, or with no timeout when it is `None`, and52    /// return early on a ring after `seen`. The answer is false only when the53    /// alarm closed, which ends the clock thread.54    ///55    /// The loop absorbs spurious wakeups: a return from the condition56    /// variable that no ring, close, or deadline caused waits again.57    pub(super) fn wait(&self, seen: u64, deadline: Option<Instant>) -> bool {58        let mut state = self.lock();59        loop {60            if state.closed {61                return false;62            }63            if state.rings != seen {64                return true;65            }66            state = match deadline {67                None => self68                    .bell69                    .wait(state)70                    .expect("no thread panics with the lock"),71                Some(at) => {72                    let now = Instant::now();73                    if now >= at {74                        return true;75                    }76                    self.bell77                        .wait_timeout(state, at - now)78                        .expect("no thread panics with the lock")79                        .080                }81            };82        }83    }8485    fn lock(&self) -> std::sync::MutexGuard<'_, State> {86        self.state.lock().expect("no thread panics with the lock")87    }88}8990#[cfg(test)]91mod tests;
src/screen.rs 99.7%
1//! The rules a screen client holds for one `Player`: the quiet window and the2//! off window, the focus gate, the shade, the level the operator relays,3//! the cycle 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.910mod commands;11pub mod keys;12mod power;13pub mod press;1415use std::time::{Duration, Instant};1617use crate::panel;18use crate::status::{Activity, Power, Status};19use crate::volume::Volume;20use crate::wiring::{Remote, Wiring};2122/// The suffix that turns a remote's focus topic into its cycle topic, the23/// same path `media-operator`'s `remoteFocusCycleTopic` builds, so a client24/// needs no second topic list.25const CYCLE_SUFFIX: &str = "/cycle";2627/// The action a power press on a unit whose screen is wired through a28/// Receiver publishes on the power topic for a toggle. The equipment29/// operator answers it by flipping the room's power and selecting the30/// input.31const POWER_TOGGLE: &str = "toggle";3233/// One thing the client draws.34///35/// Each one is a fact the screen shows or a moment it moves on, and36/// none of them is a decision the client makes again. The shade moments37/// say which way the cover eases. A focus names the controller a live38/// mark landed on, by its place in `spec.remotes`.39#[derive(Debug, Clone, PartialEq)]40pub enum Moment {41    /// One press this crate does not act on itself, under the kernel's42    /// name for the control. The client holds its own table from these43    /// names to what they do there, so a letter key on a remote with a44    /// keyboard reaches a client that types.45    Press(String),46    /// The quiet window ran out, or the client asked for the shade.47    Sleep,48    /// A press, a live mark, or a starting `Play` lifted the shade.49    Wake,50    /// A live mark named this `Player`. `remote` is the controller's place in51    /// `spec.remotes`, which is the order the status lists the parts in.52    Focus { remote: usize },53    /// The unit's whole presentable state.54    Status(Status),55    /// The unit's listening level, as `media-operator` relays it. `pressed` is56    /// false for the broker's catch-up and true for a live message, which is57    /// a person changing the level with a remote or at the device. A client58    /// draws the bar for a pressed level only when `Volume::draws_after` the59    /// level it held says so.60    Level { volume: Volume, pressed: bool },61    /// A person took the up-next offer on the scrubber. The bytes are the62    /// `request` of the `Play`'s next block, which this crate never reads:63    /// the client that wrote the `Play` reads its own words back and starts64    /// what follows. The moment fires whether or not the unit is idle,65    /// because the unit is never idle when this arrives.66    PlayNext(Vec<u8>),67    /// One message on a topic the client owns. The rules read nothing in68    /// the payload and hold no state from it, because the topic is the69    /// client's own. `retained` is the broker's mark on the delivery, so a70    /// client tells the catch-up on a topic it owns from a live write.71    Message {72        topic: String,73        payload: Vec<u8>,74        retained: bool,75    },76    /// The bus session started. A client that owns retained state77    /// republishes it here, which is the bus rule that each connected78    /// program republishes the retained state it owns when its session79    /// reconnects. The moment arrives on the first session too, because a80    /// client cannot tell that one from a reconnect.81    Connected,82}8384/// One message this crate sends on the bus. The client never builds one:85/// [`crate::Reader`] performs each of these on the connection it already86/// holds.87#[derive(Debug, Clone, PartialEq, Eq)]88pub struct Publish {89    pub topic: String,90    pub payload: Vec<u8>,91    pub retained: bool,92}9394/// What one fold leaves to do: something the client draws, or something the95/// crate sends.96#[derive(Debug, Clone, PartialEq)]97pub enum Effect {98    Moment(Moment),99    Publish(Publish),100}101102/// One controller's mark and whether this bus session already delivered one.103/// The first message of a session is the broker's retained catch-up, a104/// restore and not a person, so it sets the gate and pulses nothing.105///106/// `cycle_asked` is set while this client's cycle request on the controller107/// waits for its answer. On a controller that one unit lists, the operator108/// answers with the same mark, and that repeat is the press's feedback.109/// Every other repeat of the mark is a publisher that sent it again, and a110/// person did nothing.111#[derive(Debug, Clone, Default, PartialEq, Eq)]112struct Mark {113    player: String,114    caught_up: bool,115    cycle_asked: bool,116}117118/// Which window the armed deadline belongs to.119#[derive(Debug, Clone, Copy, PartialEq, Eq)]120enum Window {121    /// The quiet window, which brings the shade down.122    Quiet,123    /// The off window, which states the off desire.124    Off,125}126127/// The state a screen client holds for one unit.128#[derive(Debug)]129pub struct Screen {130    /// The `Player`'s own object name, the value a focus mark holds when it131    /// names this unit. An empty name matches no mark, so a client that read132    /// none answers no press.133    player_name: String,134    status_topic: String,135    /// The unit's volume topic, empty for a `Player` whose level the operator136    /// does not relay. Empty is the speaker gate: the client subscribes to no137    /// level.138    volume_topic: String,139    commands_topic: String,140    panel_topic: String,141    /// The topic a power press publishes its ask on in the room mode. A142    /// current operator sets it for every unit, and an operator that143    /// predates the power mode sets it only for a unit with a `Receiver`.144    power_topic: String,145    /// The power mode the last status that stated one named, or `None`146    /// while no status stated one. [`Screen::room_power`] reads it at each147    /// press, so a `Receiver` that is wired or removed while the client runs148    /// moves the next press.149    power: Option<Power>,150    /// The unit's controllers, in `spec.remotes` order, so a controller's151    /// index in this list is the index a focus moment carries.152    remotes: Vec<Remote>,153    /// The last mark each controller's focus topic delivered, one per entry154    /// of `remotes`.155    marks: Vec<Mark>,156    /// The topics the client owns. They are the client's own configuration157    /// and not the `Player`'s wiring, so they arrive from the client and not158    /// from a variable the operator sets.159    client_topics: Vec<String>,160161    /// The quiet window. Zero never arms the timer.162    fade_after: Duration,163    /// The off window, clamped to at least the fade. Zero leaves the desire164    /// at on forever.165    off_after: Duration,166167    /// Whether the last status named the activity `Idle`, the only state the168    /// timer arms in. It starts false, so a client answers no press until the169    /// retained status reaches it.170    idle: bool,171    /// Whether the shade is down.172    asleep: bool,173    /// The panel desire as the client knows it: the retained desire the174    /// broker delivered, or the one this client last stated. `None` is a175    /// desire not read yet, and the client states none until a press or a176    /// window changes the panel. The client writes no hardware; the177    /// operator reads the desire from the bus and overrides the screen's178    /// `Display`.179    desire: Option<&'static str>,180    /// The armed window and the moment it runs out.181    deadline: Option<(Instant, Window)>,182    /// The moment a power ask that waits for `Idle` is dropped, and the key183    /// the ask is answered as, or `None` while no ask waits. [`commands`]184    /// holds the rule.185    power_ask: Option<(Instant, &'static str)>,186    /// The lines the folds since the last [`Screen::take_lines`] wrote, one187    /// per operation a person caused: a press and what it did or why it did188    /// nothing, a window that brought the shade down, a panel desire. A189    /// repeat, a catch-up, and a status that moves nothing write none.190    lines: Vec<String>,191}192193impl Screen {194    /// The screen one wiring describes.195    pub fn new(wiring: &Wiring) -> Self {196        Self {197            player_name: wiring.player_name.clone(),198            status_topic: wiring.status_topic.clone(),199            volume_topic: wiring.volume_topic.clone(),200            commands_topic: wiring.commands_topic.clone(),201            panel_topic: wiring.panel_topic.clone(),202            power_topic: wiring.power_topic.clone(),203            power: None,204            marks: vec![Mark::default(); wiring.remotes.len()],205            remotes: wiring.remotes.clone(),206            client_topics: Vec::new(),207            fade_after: wiring.fade_after,208            off_after: wiring.off_after,209            idle: false,210            asleep: false,211            // A client that starts has not read the retained desire yet. It212            // adopts the one the broker holds, so a pod that restarts in a213            // dark room keeps the room dark.214            desire: None,215            deadline: None,216            power_ask: None,217            lines: Vec::new(),218        }219    }220221    /// The lines the folds wrote since the last call, oldest first.222    /// [`crate::Reader`] prints them, so a client that holds a reader logs223    /// them with no code of its own.224    pub fn take_lines(&mut self) -> Vec<String> {225        std::mem::take(&mut self.lines)226    }227228    /// The same screen, plus the topics the client owns. A client that keeps229    /// retained state of its own, such as a mark for the person watching,230    /// names those topics here and reads every message on them back as a231    /// [`Moment::Message`]. An empty name is no topic and is dropped.232    ///233    /// The rules read nothing on these topics. They are a second234    /// subscription on the one connection this crate holds, so a client235    /// opens no session of its own under a second identifier.236    #[must_use]237    pub fn reading(mut self, client_topics: &[String]) -> Self {238        self.client_topics = client_topics239            .iter()240            .filter(|topic| !topic.is_empty())241            .cloned()242            .collect();243        self244    }245246    /// The topics to subscribe to. An empty topic is one the operator did not247    /// set, and a unit whose level the operator does not relay has no volume248    /// topic at all.249    ///250    /// A press on any of the unit's controllers reaches the quiet window, so251    /// every events topic is read. The focus topic is retained, so each mark252    /// arrives on subscribe and the gate stands before the first press. The253    /// panel topic is read for the same reason: the retained desire arrives254    /// before the client states one.255    pub fn filters(&self) -> Vec<String> {256        let mut filters = vec![257            self.status_topic.clone(),258            self.volume_topic.clone(),259            self.commands_topic.clone(),260            self.panel_topic.clone(),261            self.power_topic.clone(),262        ];263        for remote in &self.remotes {264            filters.push(remote.events.clone());265            filters.push(remote.focus.clone());266        }267        filters.retain(|topic| !topic.is_empty());268        filters.extend(self.client_topics.iter().cloned());269        filters270    }271272    /// The earlier of the moment the armed window runs out and the moment a273    /// held power ask is dropped, and nothing while neither is armed.274    pub fn next_deadline(&self) -> Option<Instant> {275        let window = self.deadline.map(|(at, _)| at);276        match (window, self.power_ask.map(|(at, _)| at)) {277            (Some(window), Some(ask)) => Some(window.min(ask)),278            (window, ask) => window.or(ask),279        }280    }281282    /// Fold one message from any subscription, and the topic says which283    /// message this is. A topic this screen did not subscribe to, and a284    /// payload that does not decode, are both nothing at all, so a newer285    /// message on a topic has no effect rather than a crash.286    ///287    /// `retained` is the broker's own mark on a delivery from its retained288    /// store. A retained level is the catch-up, which no person changed, so289    /// it sets the level and shows no indicator; a live level is a change.290    pub fn deliver(291        &mut self,292        topic: &str,293        payload: &[u8],294        retained: bool,295        now: Instant,296    ) -> Vec<Effect> {297        if !self.status_topic.is_empty() && topic == self.status_topic {298            return self.on_status(payload, now);299        }300        // The volume topic carries a state and not a named command, so it is301        // read before the command vocabulary below.302        if !self.volume_topic.is_empty() && topic == self.volume_topic {303            return on_level(payload, retained);304        }305        // A controller's presses are checked before the commands topic,306        // because a key event is not the operator's command vocabulary.307        if let Some(index) = self.remote_for(topic, |remote| &remote.events) {308            return self.on_press(index, payload, now);309        }310        // The mark is a state on its own retained topic, read before the311        // command vocabulary for the same reason the level is.312        if let Some(index) = self.remote_for(topic, |remote| &remote.focus) {313            return self.on_focus(index, payload, now);314        }315        if !self.commands_topic.is_empty() && topic == self.commands_topic {316            return self.on_command(payload, now);317        }318        if !self.panel_topic.is_empty() && topic == self.panel_topic {319            return self.on_panel(payload, retained, now);320        }321        if !self.power_topic.is_empty() && topic == self.power_topic {322            return self.on_power(payload, now);323        }324        // The client's own topics are read last, so a topic that is also one325        // of the screen's fires the screen's rule alone and never twice.326        if self.client_topics.iter().any(|owned| owned == topic) {327            return vec![Effect::Moment(Moment::Message {328                topic: topic.to_string(),329                payload: payload.to_vec(),330                retained,331            })];332        }333        Vec::new()334    }335336    /// The armed window running out: the quiet window brings the shade down337    /// and starts the off window, and the off window states the off desire338    /// and arms nothing. A held power ask whose deadline passed is dropped339    /// first.340    pub fn tick(&mut self, now: Instant) -> Vec<Effect> {341        self.expire_power_ask(now);342        let Some((at, window)) = self.deadline else {343            return Vec::new();344        };345        if now < at {346            return Vec::new();347        }348        let mut effects = Vec::new();349        match window {350            Window::Quiet => {351                self.asleep = true;352                // The shade coming down starts the second window.353                self.rearm(now);354                self.shade(Some(Moment::Sleep), &mut effects);355                self.lines.push(format!(356                    "the quiet window of {} s ran out, so the shade is down",357                    self.fade_after.as_secs()358                ));359            }360            Window::Off => {361                self.deadline = None;362                let desire = self.desire(panel::OFF, &mut effects);363                self.lines.push(format!(364                    "the off window of {} s ran out{desire}",365                    self.off_after.as_secs()366                ));367            }368        }369        effects370    }371372    /// Bring the shade down on the client's own reading of a press. The stock373    /// idle client asks on back; a client with levels asks only at the top374    /// one, because only the client knows whether back has anywhere to go.375    ///376    /// It acts only while the unit plays nothing and the screen is awake, and377    /// it makes the same three moves the quiet window makes, so the shade and378    /// the panel desire behave the same whichever one asked.379    pub fn sleep(&mut self, now: Instant) -> Vec<Effect> {380        if !self.idle || self.asleep {381            return Vec::new();382        }383        self.asleep = true;384        self.rearm(now);385        let mut effects = Vec::new();386        self.shade(Some(Moment::Sleep), &mut effects);387        self.lines388            .push("the client asked for the shade, so the shade is down".into());389        effects390    }391392    /// The start of every bus session. A fresh session redelivers every393    /// retained mark, so each one is a catch-up again and pulses nothing. The394    /// mark itself stands across the reconnect, so the gate does not open or395    /// close on a broker restart alone.396    ///397    /// The panel desire is this client's own retained state, so a desire the398    /// client holds goes out again on every session, and a broker that399    /// restarted holds it again. A client that has read no desire and stated400    /// none sends nothing, because a restart is not a reason to change the401    /// panel.402    ///403    /// [`Moment::Connected`] tells the client the same thing, so a client404    /// republishes the retained state it owns on the topics it named.405    pub fn connected(&mut self) -> Vec<Effect> {406        for mark in &mut self.marks {407            mark.caught_up = false;408        }409        let mut effects = Vec::new();410        self.publish_desire(&mut effects);411        effects.push(Effect::Moment(Moment::Connected));412        effects413    }414415    /// Fold one status. `Idle` is the only activity the timer arms in, so a416    /// status that leaves `Idle` disarms it. The same status lifts the shade417    /// if the screen sleeps, so a `Play` started from another room shows its418    /// film and not a black screen. A status that moves the unit into `Idle`419    /// answers a held power ask, after the client draws the status.420    ///421    /// The operator republishes the status on any change to the payload, a422    /// controller's `Connected` flap included, so only a status that moved423    /// the unit into or out of `Idle` restarts the quiet window; a republish424    /// of the same activity leaves the window where it stands. The client425    /// draws every status either way.426    fn on_status(&mut self, payload: &[u8], now: Instant) -> Vec<Effect> {427        let Some(status) = crate::status::parse(payload) else {428            return Vec::new();429        };430        let idle = status.activity == Activity::Idle;431        // The mode is read before the activity is compared, because the432        // operator republishes the status when only the mode moves, and a433        // held power ask below is answered in the mode this status states.434        if let Some(power) = status.power {435            self.power = Some(power);436        }437        let mut effects = vec![Effect::Moment(Moment::Status(status))];438        if idle == self.idle {439            return effects;440        }441        self.idle = idle;442        let mut moment = None;443        if !self.idle && self.asleep {444            self.asleep = false;445            moment = Some(Moment::Wake);446        }447        self.rearm(now);448        self.shade(moment, &mut effects);449        if self.idle450            && let Some((_, key)) = self.power_ask.take()451        {452            self.answer_power_ask(key, &mut effects);453        }454        effects455    }456457    /// Fold one message off the panel topic. The retained desire is what the458    /// panel shows now, so a client that holds no desire yet adopts it. An459    /// adopted off desire is a dark panel, so the shade comes down with it:460    /// a press then wakes the screen and states the on desire. A live message461    /// is this client's own publish coming back, and it changes nothing.462    fn on_panel(&mut self, payload: &[u8], retained: bool, now: Instant) -> Vec<Effect> {463        if !retained || self.desire.is_some() {464            return Vec::new();465        }466        let Some(adopted) =467            crate::object::<panel::Stated>(payload).and_then(|stated| stated.desire())468        else {469            return Vec::new();470        };471        self.desire = Some(adopted);472        if adopted == panel::ON || self.asleep {473            return Vec::new();474        }475        self.asleep = true;476        self.rearm(now);477        vec![Effect::Moment(Moment::Sleep)]478    }479480    /// Fold one key event. The checks run in this order. The cycle key481    /// asks the operator to move the mark and does nothing else. A power482    /// key, in the room mode, reaches the equipment and never the client:483    /// it publishes the toggle and nothing else, and the shade and the484    /// panel desire stand as they were. A sleeping screen wakes on any485    /// other press, so a person gets the screen back with whatever control486    /// they touched, and that press does nothing else. Every other key, while the unit plays487    /// nothing, reaches the client. Every press restarts the quiet window.488    ///489    /// A press acts only while the remote's mark names this `Player`. A pad490    /// pointed at another room touches nothing here, not the shade and not491    /// the client. A release changes nothing at all: the standing pod holds492    /// the repeat and stops it at the release, so this crate has nothing to493    /// stop.494    fn on_press(&mut self, index: usize, payload: &[u8], now: Instant) -> Vec<Effect> {495        let Some(press) = press::parse(payload) else {496            return Vec::new();497        };498        // A press is one line, and only the press: a repeat and a release499        // are the same act of a person, and the line already says what the500        // press did.501        let trigger = format!(502            "{} from remote {}",503            press.key,504            remote_name(&self.remotes[index].events)505        );506        if !self.holds_focus(index) {507            if press.down() {508                let mark = &self.marks[index].player;509                self.lines.push(if mark.is_empty() {510                    format!(511                        "{trigger} ignored, because no focus mark names a player for this remote"512                    )513                } else {514                    format!("{trigger} ignored, because focus is on player {mark}")515                });516            }517            return Vec::new();518        }519        if !press.edge() {520            return Vec::new();521        }522        let down = press.down();523524        let mut moment = None;525        let mut forwarded = None;526        let mut publish = None;527        let mut line = None;528        let mut power = false;529        if self.idle && press.down() && press.key == keys::CYCLE {530            publish = self.cycle(index);531            if publish.is_some() {532                self.marks[index].cycle_asked = true;533            }534            line = Some(match &publish {535                Some(cycle) => format!(536                    "{trigger}: cycle focus, published the cycle request to {}",537                    cycle.topic538                ),539                None => format!("{trigger} ignored, because the remote has no focus topic"),540            });541        } else if let Some(action) =542            keys::power_action(&press.key).filter(|_| self.idle && self.room_power())543        {544            // A room with a receiver answers the power key itself, so the545            // key never reaches the client and the shade never operates:546            // power turns the equipment, and nothing else. Only the547            // equipment operator knows whether the press turns the room off548            // or on, because it reads the TV's power. A wake here would549            // state the on desire, the session would turn awake, and the550            // equipment operator would wake the TV and the receiver and551            // cancel the standby the same press asked for. So the press552            // leaves the shade and the desire as they were. A press that553            // turns the room on wakes the TV through the equipment554            // operator, and the next press wakes this screen the way any555            // press does. A held key that repeated would flip the equipment556            // on and off under the hand, so only the press publishes. In the557            // screen mode the press falls through to the ordinary rules558            // below, and the client lowers its shade.559            //560            // The two deterministic power functions of a TV remote publish561            // off and on in place of the toggle (keys::POWER_OFF), and the562            // equipment operator leaves a room that is already off or on563            // as it is.564            power = true;565            if press.down() {566                publish = Some(self.power_publish(action));567                line = Some(if action == POWER_TOGGLE {568                    format!(569                        "{trigger}: power, published the toggle to {}",570                        self.power_topic571                    )572                } else {573                    format!(574                        "{trigger}: power {action}, published {action} to {}",575                        self.power_topic576                    )577                });578            }579        } else if self.asleep && press.key == keys::POWER_OFF {580            // A Power Off Function keeps a device in standby when repeated581            // (HDMI-CEC 1.3a, CEC 13.13.3), so on a unit with no Receiver it582            // leaves a sleeping screen asleep, where every other key wakes583            // it.584            power = true;585            if press.down() {586                line = Some(format!(587                    "{trigger} ignored, because the screen is already asleep"588                ));589            }590        } else if self.asleep {591            self.asleep = false;592            moment = Some(Moment::Wake);593            line = Some(format!("{trigger} woke the screen and did nothing else"));594        } else if self.idle && keys::owned(&press.key) {595            // A repeat of the cycle key asks nothing: one press is one596            // cycle, and the key is the crate's, so it never reaches the597            // client.598        } else if self.idle {599            if press.down() {600                line = Some(format!("{trigger} passed to the client"));601            }602            forwarded = Some(press.key);603        }604605        self.rearm(now);606        let mut effects = Vec::new();607        let mut desire = self.shade(moment, &mut effects);608        // A client that read no desire does not know whether the panel is609        // dark. A press is a person in the room, so it states the on desire.610        // A power press states none, for the reason its branch gives.611        if self.desire.is_none() && down && !power {612            desire = self.desire(panel::ON, &mut effects);613        }614        if let Some(line) = line {615            self.lines.push(line + &desire);616        }617        if let Some(key) = forwarded {618            effects.push(Effect::Moment(Moment::Press(key)));619        }620        if let Some(publish) = publish {621            effects.push(Effect::Publish(publish));622        }623        effects624    }625626    /// Fold one mark off a controller's focus topic. It sets the gate every627    /// time. A live message that moves the mark to this `Player` is a person628    /// pointing the controller here: it lifts the shade, restarts the quiet629    /// window, and pulses the display with the controller's index. So does630    /// the repeat that answers this client's own cycle request. The631    /// session's first message is the broker's retained catch-up, so it sets632    /// the gate and does nothing else. A mark that names another `Player`, or633    /// a `Play` name left from an older operator, gates closed and pulses634    /// nothing.635    ///636    /// Any other repeat of the mark this client already holds is a publisher637    /// that sent the same mark again, such as an operator after a restart,638    /// and not a person. It changes nothing, because a wake here would light639    /// a sleeping screen in a dark room.640    ///641    /// A mark that does not name this `Player` only closes the gate here. The642    /// standing pod synthesises the repeat and stops it at the release, so a643    /// control held as the mark moves away needs nothing stopped here.644    fn on_focus(&mut self, index: usize, payload: &[u8], now: Instant) -> Vec<Effect> {645        let mark = String::from_utf8_lossy(payload).into_owned();646        let held = &self.marks[index];647        let live = held.caught_up;648        let moved = held.player != mark || held.cycle_asked;649        let names_this_player = self.names_this_player(&mark);650        self.marks[index] = Mark {651            player: mark,652            caught_up: true,653            cycle_asked: false,654        };655        if !names_this_player || !live || !moved {656            return Vec::new();657        }658659        let mut moment = None;660        if self.asleep {661            self.asleep = false;662            moment = Some(Moment::Wake);663        }664        self.rearm(now);665        let mut effects = Vec::new();666        self.shade(moment, &mut effects);667        effects.push(Effect::Moment(Moment::Focus { remote: index }));668        effects669    }670671    /// The cycle request the operator arbitrates, on the controller's own672    /// cycle topic, not retained, because a cycle is an event and not a673    /// state. It is the same message the playback pod's command sidecar674    /// publishes during a film.675    fn cycle(&self, index: usize) -> Option<Publish> {676        let focus = &self.remotes[index].focus;677        if focus.is_empty() {678            return None;679        }680        Some(Publish {681            topic: focus.clone() + CYCLE_SUFFIX,682            payload: Vec::new(),683            retained: false,684        })685    }686687    /// Whether a power press is an ask for the room, read at the press and at688    /// the answer to a held power ask. The room mode needs the power topic to689    /// publish on. A client that read no mode follows the rule of an operator690    /// that predates the field, which set the topic only for a unit with a691    /// `Receiver`.692    fn room_power(&self) -> bool {693        !self.power_topic.is_empty() && self.power != Some(Power::Screen)694    }695696    /// The ask a power press publishes on a unit whose screen is wired697    /// through a Receiver: toggle, on, or off, not retained, because an ask698    /// is an event and not a state. A power ask the playback pod held until699    /// `Idle` publishes the same ask.700    fn power_publish(&self, action: &str) -> Publish {701        Publish {702            topic: self.power_topic.clone(),703            payload: format!(r#"{{"action":"{action}"}}"#).into_bytes(),704            retained: false,705        }706    }707708    /// Add one fold's shade moment. A wake also states the on desire, which709    /// is what lifts the override. No moment is the ordinary case of a fold710    /// that changed no state, and it adds nothing.711    ///712    /// The answer is the end of a line that says the desire went out, or713    /// nothing when no desire moved.714    fn shade(&mut self, moment: Option<Moment>, effects: &mut Vec<Effect>) -> String {715        let Some(moment) = moment else {716            return String::new();717        };718        let wake = moment == Moment::Wake;719        effects.push(Effect::Moment(moment));720        if wake {721            return self.desire(panel::ON, effects);722        }723        String::new()724    }725726    /// Hold the new desire and publish it. An unchanged desire publishes727    /// nothing, because the broker holds the last one. A client that holds728    /// no desire yet publishes any desire, because it has no value to match.729    ///730    /// The answer is the end of a line that says where the desire went, or731    /// nothing when it did not move.732    fn desire(&mut self, desire: &'static str, effects: &mut Vec<Effect>) -> String {733        if self.desire == Some(desire) {734            return String::new();735        }736        self.desire = Some(desire);737        self.publish_desire(effects);738        if self.panel_topic.is_empty() {739            return format!(", and the player has no panel topic for the {desire} desire");740        }741        format!(", published panel desire {desire} to {}", self.panel_topic)742    }743744    /// The desire this client holds now, retained, so the operator reads the745    /// current one the moment it subscribes. A `Player` with no panel topic746    /// states no desire.747    fn publish_desire(&self, effects: &mut Vec<Effect>) {748        let Some(desire) = self.desire else {749            return;750        };751        if self.panel_topic.is_empty() {752            return;753        }754        effects.push(Effect::Publish(Publish {755            topic: self.panel_topic.clone(),756            payload: panel::Desire { desire }.payload(),757            retained: true,758        }));759    }760761    /// Restart the armed window from now. The quiet window runs only while762    /// the screen is awake, the unit plays nothing, and the policy is above763    /// zero; every other state leaves it disarmed. The off window runs from764    /// the moment the shade came down, so the two windows measure one quiet765    /// stretch, and it arms only while the panel is not already dark.766    fn rearm(&mut self, now: Instant) {767        self.deadline = None;768        if !self.idle {769            return;770        }771        if self.asleep {772            if self.off_after.is_zero() || self.desire == Some(panel::OFF) {773                return;774            }775            self.deadline = Some((now + (self.off_after - self.fade_after), Window::Off));776            return;777        }778        if self.fade_after.is_zero() {779            return;780        }781        self.deadline = Some((now + self.fade_after, Window::Quiet));782    }783784    /// Whether this controller's mark names this `Player` right now.785    fn holds_focus(&self, index: usize) -> bool {786        self.names_this_player(&self.marks[index].player)787    }788789    /// Compare one mark against the `Player`'s own name. A client that read790    /// no name matches no mark and answers no press.791    fn names_this_player(&self, mark: &str) -> bool {792        !self.player_name.is_empty() && mark == self.player_name793    }794795    /// Which controller a topic belongs to, by a scan over the unit's own796    /// controllers. An empty topic names none, so a controller the operator797    /// gave no focus topic matches nothing.798    fn remote_for(&self, topic: &str, of: impl Fn(&Remote) -> &String) -> Option<usize> {799        if topic.is_empty() {800            return None;801        }802        self.remotes.iter().position(|remote| of(remote) == topic)803    }804}805806/// Fold one message off the volume topic into the moment the client draws.807/// The screen holds no level of its own, because no rule here reads it.808fn on_level(payload: &[u8], retained: bool) -> Vec<Effect> {809    let Some(volume) = crate::volume::parse(payload) else {810        return Vec::new();811    };812    vec![Effect::Moment(Moment::Level {813        volume,814        pressed: !retained,815    })]816}817818/// The `Remote` a controller topic belongs to, as `namespace/name`. Every819/// controller topic is `<base>/remotes/<namespace>/<name>/<kind>`, and the820/// base can hold slashes of its own, so the name is found from the remotes821/// segment and not from the start. A topic of another shape names itself,822/// so a line never loses its trigger.823fn remote_name(topic: &str) -> String {824    let parts: Vec<&str> = topic.split('/').collect();825    parts826        .iter()827        .rposition(|part| *part == "remotes")828        .filter(|index| index + 2 < parts.len())829        .map_or_else(830            || topic.to_string(),831            |index| format!("{}/{}", parts[index + 1], parts[index + 2]),832        )833}834835#[cfg(test)]836mod tests;
src/screen/commands.rs 98.4%
1//! The commands topic: the asks the playback pod's command sidecar2//! publishes for the client under the film. The up-next ask and the home3//! ask pass to the client at once. The power ask waits for the unit's4//! `Idle` status, because the equipment operator decides off or on from5//! the room it reads, and a toggle that went out while the `Play` still6//! ran would reach the room before the ending did.78use std::time::{Duration, Instant};910use serde::Deserialize;1112use super::{Effect, Moment, Screen, keys};1314/// The ask the playback pod's command sidecar publishes when a person takes15/// the up-next offer on the scrubber. The client that wrote the `Play` reads16/// it and starts what follows.17const PLAY_NEXT: &str = "play-next";1819/// The ask the same sidecar publishes when a person presses home during a20/// film. The client reads it as a press of the home key, just before the21/// `Play` ends.22const HOME: &str = "home";2324/// The ask the same sidecar publishes when a person presses power during a25/// film, just before the `Play` ends.26const POWER: &str = "power";2728/// The ask the same sidecar publishes when a TV remote's Power Off Function29/// reaches it during a film, just before the `Play` ends. It is answered as30/// a press of [`keys::POWER_OFF`], so a room already off stays off.31const POWER_OFF: &str = "power-off";3233/// The key each held power ask is answered as.34fn ask_key(ask: &str) -> &'static str {35    if ask == POWER_OFF {36        keys::POWER_OFF37    } else {38        keys::POWER_PRESS39    }40}4142/// How long a power ask waits for the unit's `Idle` status. The sidecar43/// publishes the ending just after the ask, and the operator publishes44/// `Idle` when it reads the ending, so the wait is normally short. An ask45/// that outlives this deadline has no ending behind it, for example46/// because the sidecar stopped before it published one. It is dropped,47/// because a toggle long after the press would turn the room off or on48/// with no person behind it.49const POWER_ASK_WAIT: Duration = Duration::from_secs(10);5051/// The two fields of the commands topic this crate reads. The request is52/// whatever object the writer of the `Play` put there, so it is kept as a53/// value and passed on unread.54#[derive(Deserialize)]55struct Command {56    #[serde(default)]57    action: String,58    #[serde(default)]59    request: Option<serde_json::Value>,60}6162/// The request as bytes, for a client that parses it with its own types. A63/// message that carries none gives an empty request.64fn request_bytes(request: Option<serde_json::Value>) -> Vec<u8> {65    let Some(value) = request else {66        return Vec::new();67    };68    serde_json::to_vec(&value).unwrap_or_default()69}7071impl Screen {72    /// Fold one message off the commands topic. The ask a person makes on the73    /// up-next offer acts whether or not the unit is idle, because the unit is74    /// never idle when it arrives.75    ///76    /// A home ask reaches the client as a press of the home key, so the77    /// client binds one name for home.78    ///79    /// A power ask while the unit plays is held until the status reads80    /// `Idle`, and a power ask while the unit is idle is answered at once.81    pub(super) fn on_command(&mut self, payload: &[u8], now: Instant) -> Vec<Effect> {82        let Some(command) = crate::object::<Command>(payload) else {83            return Vec::new();84        };85        match command.action.as_str() {86            PLAY_NEXT => {87                self.lines.push(format!(88                    "{} asked for {PLAY_NEXT}, passed to the client",89                    self.commands_topic90                ));91                vec![Effect::Moment(Moment::PlayNext(request_bytes(92                    command.request,93                )))]94            }95            HOME => {96                self.lines.push(format!(97                    "{} asked for {HOME}, passed to the client as {}",98                    self.commands_topic,99                    keys::HOME100                ));101                vec![Effect::Moment(Moment::Press(keys::HOME.into()))]102            }103            ask @ (POWER | POWER_OFF) if self.idle => {104                let mut effects = Vec::new();105                self.answer_power_ask(ask_key(ask), &mut effects);106                effects107            }108            ask @ (POWER | POWER_OFF) => {109                // A second ask while one waits restarts the deadline and110                // is still one ask, so the room toggles once. The later111                // ask names the key it is answered as.112                self.power_ask = Some((now + POWER_ASK_WAIT, ask_key(ask)));113                self.lines.push(format!(114                    "{} asked for {ask} while the player plays, held until it is Idle for at most {} s",115                    self.commands_topic,116                    POWER_ASK_WAIT.as_secs()117                ));118                Vec::new()119            }120            _ => Vec::new(),121        }122    }123124    /// Answer a power ask the way a press of its key answers while the unit125    /// is idle. In the room mode, the key's ask goes out on the power topic,126    /// and the shade and the panel desire stand, for the reason the press's127    /// power branch gives. In the screen mode, the client reads the ask as a128    /// press of the key and lowers its shade.129    pub(super) fn answer_power_ask(&mut self, key: &'static str, effects: &mut Vec<Effect>) {130        let ask = if key == keys::POWER_OFF {131            POWER_OFF132        } else {133            POWER134        };135        let asked = format!(136            "{} asked for {ask} and the player is Idle",137            self.commands_topic138        );139        let action = keys::power_action(key).unwrap_or("toggle");140        if !self.room_power() {141            self.lines142                .push(format!("{asked}, so passed to the client as {key}"));143            effects.push(Effect::Moment(Moment::Press(key.into())));144            return;145        }146        let what = if action == "toggle" {147            "the toggle".to_string()148        } else {149            action.to_string()150        };151        self.lines.push(format!(152            "{asked}, so published {what} to {}",153            self.power_topic154        ));155        effects.push(Effect::Publish(self.power_publish(action)));156    }157158    /// Drop a held power ask whose deadline passed. The deadline is a clock,159    /// not a poll: the reader's clock thread wakes at160    /// [`Screen::next_deadline`], and no status is read again.161    pub(super) fn expire_power_ask(&mut self, now: Instant) {162        let Some((at, key)) = self.power_ask else {163            return;164        };165        if now < at {166            return;167        }168        self.power_ask = None;169        let ask = if key == keys::POWER_OFF {170            POWER_OFF171        } else {172            POWER173        };174        self.lines.push(format!(175            "{} asked for {ask}, and no Idle status arrived within {} s, so the ask is dropped",176            self.commands_topic,177            POWER_ASK_WAIT.as_secs()178        ));179    }180}
src/screen/keys.rs 100.0%
1// The keys this crate answers itself while nothing plays, beside the2// playback pod's table in `media-operator`'s `keybindings.go`. The two3// share the cycle key. This crate owns it because no client draws a4// list for it: the cycle key is answered for the operator. The volume5// keys belong to neither table. `media-operator` reads them off the6// remote's events topic and sets the level of the unit's devices, and a7// screen draws the level the operator relays. Every other key passes8// through to the client under the kernel's name, and the client binds9// it. The crate holds no table of the keys a client may want, because a10// remote with a keyboard sends letters, and only the client knows what11// a letter does on its screen.1213/// The key that asks the operator to move the focus mark to the next unit. It14/// is the same name during a film and between films.15pub const CYCLE: &str = "KEY_CYCLEWINDOWS";1617/// The three back synonyms. A shell sends whichever one it was built18/// with, so a client reads all three. This crate never sleeps the19/// screen on a press: only the client knows whether back has anywhere20/// to go, and the client asks for the shade with21/// [`super::Screen::sleep`].22pub const BACK: [&str; 3] = ["KEY_BACK", "KEY_ESC", "KEY_EXIT"];2324/// The three power synonyms. A shell sends whichever one it was built25/// with, so a client reads all three. A unit whose screen is wired26/// through a Receiver answers a power press itself as a toggle on the27/// bus; a unit that is not forwards the key, and the client lowers its28/// shade. `media-operator` holds a copy of this list as `powerKeys` in29/// `media-operator/ensure.go`, because a Rust list cannot reach Go: a30/// change to one changes both.31pub const POWER: [&str; 3] = [POWER_PRESS, POWER_OFF, "KEY_POWER2"];3233/// The two power synonyms that toggle the room. A Bluetooth remote's power34/// button and a TV remote's Power Toggle Function send one of them.35pub const TOGGLE: [&str; 2] = [POWER_PRESS, "KEY_POWER2"];3637/// The key the kernel's `rc-cec` keymap names a TV remote's Power Off38/// Function. HDMI-CEC 1.3a, CEC 13.13.3, says it puts the device in standby39/// and keeps it there when repeated, so it asks the room for off and never40/// toggles.41pub const POWER_OFF: &str = "KEY_SLEEP";4243/// The key the kernel's `rc-cec` keymap names a TV remote's Power On44/// Function. It puts the device on and keeps it on when repeated, so it45/// asks the room for on. It is no power synonym for a unit with no46/// Receiver: there it wakes a sleeping screen the way any press does.47pub const POWER_ON: &str = "KEY_WAKEUP";4849/// The key name a power ask reaches the client under, on a unit with no50/// Receiver. The playback pod publishes the ask on the `Player`'s commands51/// topic during a film, and this crate turns it into a press once the unit52/// is idle, so a client binds the power names alone.53pub const POWER_PRESS: &str = "KEY_POWER";5455/// The key name a home ask reaches the client under. The playback pod56/// publishes the ask on the `Player`'s commands topic during a film, and57/// this crate turns it into a press, so a client binds one name for home58/// whether the press came off a remote or out of an ask.59pub const HOME: &str = "KEY_HOMEPAGE";6061/// Whether this crate acts on the key itself. This is the one check62/// that keeps a key from the client; every key it refuses passes63/// through.64pub fn owned(key: &str) -> bool {65    key == CYCLE66}6768/// Whether one kernel key name is a back synonym.69pub fn back(key: &str) -> bool {70    BACK.contains(&key)71}7273/// Whether one kernel key name is a power synonym.74pub fn power(key: &str) -> bool {75    POWER.contains(&key)76}7778/// The ask a power key publishes on the power topic of a unit with a79/// Receiver: off and on for the two deterministic functions, the toggle for80/// every other power key, and nothing for a key that is no power key.81pub fn power_action(key: &str) -> Option<&'static str> {82    match key {83        POWER_OFF => Some("off"),84        POWER_ON => Some("on"),85        _ if TOGGLE.contains(&key) => Some("toggle"),86        _ => None,87    }88}8990#[cfg(test)]91mod tests {92    use super::*;9394    #[test]95    fn the_cycle_key_is_this_crates_own() {96        assert!(owned(CYCLE));97    }9899    #[test]100    fn a_key_this_crate_acts_on_no_further_is_none_of_its_own() {101        for key in [102            "KEY_UP",103            "KEY_ENTER",104            "KEY_BACK",105            "KEY_A",106            "KEY_HOMEPAGE",107            "KEY_BACKSPACE",108            "KEY_PLAYPAUSE",109            "KEY_VOLUMEUP",110            "KEY_VOLUMEDOWN",111            "KEY_MUTE",112            "KEY_UNMUTE",113            "",114        ] {115            assert!(!owned(key));116        }117    }118119    #[test]120    fn the_three_back_synonyms_are_the_clients_to_answer() {121        for key in BACK {122            assert!(back(key));123            assert!(!owned(key));124        }125        assert!(!back("KEY_UP"));126    }127128    #[test]129    fn the_three_power_synonyms_are_none_of_the_crates_own() {130        for key in POWER {131            assert!(power(key));132            assert!(!owned(key));133        }134        assert!(!power("KEY_UP"));135        assert!(!power("KEY_BACK"));136    }137138    #[test]139    fn each_power_key_names_its_ask() {140        assert_eq!(power_action("KEY_POWER"), Some("toggle"));141        assert_eq!(power_action("KEY_POWER2"), Some("toggle"));142        assert_eq!(power_action(POWER_OFF), Some("off"));143        assert_eq!(power_action(POWER_ON), Some("on"));144        assert_eq!(power_action("KEY_UP"), None);145    }146}
src/screen/power.rs 98.0%
1//! The power topic's asks for the screen. A unit whose screen is wired2//! through a Receiver publishes the room's power on the power topic, and3//! `media-operator` writes each of those asks into the Receiver's4//! `status.session.powerAsk` (`roompower.go`). Two asks come back on the5//! same topic when the TV speaks for the room over HDMI-CEC: wake when a6//! person picks this unit's input in the TV's source menu while the screen7//! sleeps, and sleep when the TV goes to standby while the screen is awake.8//! The equipment operator's CEC node workload holds no broker connection,9//! so it writes each ask in the Television's `status.screenAsk`, and10//! `media-operator` publishes it on the power topic (`screenask.go`). The11//! screen answers each the way a press or the quiet window would, so the12//! panel desire, and the Receiver session that follows it, move with the13//! TV.14//!15//! The operator relays the two asks only to a unit with a `Receiver`. A16//! client in the screen mode still ignores them, so an ask that was in flight17//! when the `Receiver` was removed does not move a screen that handles power18//! itself.1920use std::time::Instant;2122use serde::Deserialize;2324use super::{Effect, Moment, Screen};25use crate::panel;2627/// The ask to wake the screen and ask for its panel.28const WAKE: &str = "wake";2930/// The ask to lower the shade and turn the panel off.31const SLEEP: &str = "sleep";3233/// The one field of the power topic this crate reads.34#[derive(Deserialize)]35struct Ask {36    #[serde(default)]37    action: String,38}3940impl Screen {41    /// Fold one message off the power topic. Every action but wake and42    /// sleep is a room power ask for `media-operator`, such as this screen's43    /// own toggle coming back, and changes nothing here.44    pub(super) fn on_power(&mut self, payload: &[u8], now: Instant) -> Vec<Effect> {45        let Some(ask) = crate::object::<Ask>(payload) else {46            return Vec::new();47        };48        let asked = match ask.action.as_str() {49            WAKE => "wake",50            SLEEP => "sleep",51            _ => return Vec::new(),52        };53        if !self.room_power() {54            self.lines.push(format!(55                "{} asked the screen to {asked}, ignored, because the power mode is screen",56                self.power_topic57            ));58            return Vec::new();59        }60        if asked == WAKE {61            self.wake_asked(now)62        } else {63            self.sleep_asked(now)64        }65    }6667    /// Wake a sleeping screen and state the on desire, the way a press on a68    /// sleeping screen does. A screen that is awake with its panel on has69    /// nothing to do.70    fn wake_asked(&mut self, now: Instant) -> Vec<Effect> {71        let moment = self.asleep.then_some(Moment::Wake);72        self.asleep = false;73        self.rearm(now);74        let mut effects = Vec::new();75        let mut desire = self.shade(moment, &mut effects);76        if desire.is_empty() {77            desire = self.desire(panel::ON, &mut effects);78        }79        if !effects.is_empty() {80            self.lines.push(format!(81                "{} asked to wake the screen{desire}",82                self.power_topic83            ));84        }85        effects86    }8788    /// Lower the shade and state the off desire at once, so the room goes89    /// dark with the TV and not after the off window. It acts only while the90    /// unit plays nothing: a film goes on, the same rule the quiet window91    /// follows.92    fn sleep_asked(&mut self, now: Instant) -> Vec<Effect> {93        if !self.idle {94            return Vec::new();95        }96        let moment = (!self.asleep).then_some(Moment::Sleep);97        self.asleep = true;98        let mut effects = Vec::new();99        self.shade(moment, &mut effects);100        let desire = self.desire(panel::OFF, &mut effects);101        self.rearm(now);102        if !effects.is_empty() {103            self.lines.push(format!(104                "{} asked the screen to sleep, so the shade is down{desire}",105                self.power_topic106            ));107        }108        effects109    }110}
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 the24    /// cycle key and a power key ask 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":"play-next"}"#), None);101    }102}
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    /// Where a power press goes. `None` is a status that states no mode: an25    /// operator that predates the field, or one that has not matched the26    /// unit against the `Receiver`s yet. The screen keeps the mode it last27    /// read through such a status.28    #[serde(default, deserialize_with = "power_mode")]29    pub power: Option<Power>,30}3132/// Where a power press goes, as the operator states it in `power`.33#[derive(Debug, Clone, Copy, PartialEq, Eq)]34pub enum Power {35    /// The unit's screen is wired through a `Receiver`. A power press is an36    /// ask on the power topic, and the equipment operator turns the room.37    Room,38    /// The unit's screen is wired through no `Receiver`. The client handles a39    /// power press itself and lowers its shade.40    Screen,41}4243/// Read the `power` word. A word this client does not name states no mode,44/// so the screen keeps the one it holds rather than guessing at a new one.45fn power_mode<'de, D: serde::Deserializer<'de>>(source: D) -> Result<Option<Power>, D::Error> {46    Ok(match Option::<String>::deserialize(source)?.as_deref() {47        Some("room") => Some(Power::Room),48        Some("screen") => Some(Power::Screen),49        _ => None,50    })51}5253/// What the unit is doing. The three words are the operator's own, and the54/// screen draws a different motion for each: `Starting` ramps the mark up55/// while a person waits, `Playing` stops it because a film covers the surface,56/// and `Idle` eases it back to rest.57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]58pub enum Activity {59    #[default]60    Idle,61    Starting,62    Playing,63}6465impl Activity {66    /// The activity one word names. A word this client does not name reads as67    /// `Idle`, which draws the mark at rest and no activity line, because68    /// every comparison downstream is against `Starting` and `Playing` alone.69    pub fn from_word(word: &str) -> Self {70        match word {71            "Starting" => Self::Starting,72            "Playing" => Self::Playing,73            _ => Self::Idle,74        }75    }76}7778impl<'de> Deserialize<'de> for Activity {79    fn deserialize<D: serde::Deserializer<'de>>(source: D) -> Result<Self, D::Error> {80        Ok(Self::from_word(&String::deserialize(source)?))81    }82}8384/// The `Play` on the unit. `name` is the object a person finds with `kubectl`,85/// and `title` is the one line the screen draws.86#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]87pub struct Play {88    #[serde(default)]89    pub name: String,90    #[serde(default)]91    pub title: String,92}9394/// One part of the unit: a screen, a set of speakers, or a controller.95#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]96pub struct Component {97    /// A part that carries no name defaults to an empty one, and the98    /// identity block drops it, because one unnamed part must not cost99    /// the whole retained status and leave the screen on stale state.100    #[serde(default)]101    pub name: String,102    /// `display`, `sink`, or `remote`. It is the screen's whole vocabulary for103    /// a part, and it says what to draw rather than which `DeviceClass` the104    /// part came from.105    #[serde(default)]106    pub kind: String,107    /// The presence of a part that has any. A wired screen and its speakers108    /// report none and carry no key, and the screen draws them at full109    /// brightness always, because a part that cannot be absent must not read110    /// as present-for-now.111    #[serde(default)]112    pub connected: Option<bool>,113    /// The charge the part reports, from 0 to 100. A part that runs on no114    /// battery, and one whose device reports no level, carries no key, and the115    /// screen draws the name alone.116    #[serde(default)]117    pub battery: Option<i64>,118    /// True on the one controller whose focus mark names this unit. It appears119    /// nowhere else, so exactly one unit draws the marker for a controller that120    /// several units list.121    #[serde(default)]122    pub focused: Option<bool>,123}124125impl Component {126    /// The kind name of a controller, the one part that carries presence and127    /// focus. A focus moment counts the controllers in the order the status128    /// lists them, which is the `Player`'s `spec.remotes` order.129    pub const REMOTE: &'static str = "remote";130}131132/// Read one message off the status topic. A payload that does not decode is no133/// status at all and changes nothing, because a half-read status on a screen is134/// worse than the last good one.135pub fn parse(payload: &[u8]) -> Option<Status> {136    crate::object(payload)137}138139#[cfg(test)]140mod tests {141    use super::*;142143    #[test]144    fn the_whole_status_decodes() {145        let status = parse(146            br#"{"displayName":"The Den","activity":"Starting",147                 "play":{"name":"den-tv-1","title":"A Film"},148                 "components":[149                   {"name":"The screen","kind":"display"},150                   {"name":"A remote","kind":"remote","connected":true,151                    "battery":62,"focused":true}]}"#,152        )153        .expect("the status decodes");154155        assert_eq!(status.display_name, "The Den");156        assert_eq!(status.activity, Activity::Starting);157        assert_eq!(158            status.play.as_ref().map(|play| play.title.as_str()),159            Some("A Film")160        );161        assert_eq!(status.components[0].kind, "display");162        assert_eq!(status.components[0].connected, None);163        assert_eq!(status.components[1].connected, Some(true));164        assert_eq!(status.components[0].battery, None);165        assert_eq!(status.components[1].battery, Some(62));166        assert_eq!(status.components[1].focused, Some(true));167    }168169    #[test]170    fn a_unit_at_rest_names_no_play_and_no_parts() {171        let status = parse(br#"{"displayName":"The Den","activity":"Idle"}"#).expect("it decodes");172        assert_eq!(status.activity, Activity::Idle);173        assert_eq!(status.play, None);174        assert!(status.components.is_empty());175    }176177    #[test]178    fn the_power_mode_decodes_and_an_unknown_word_states_none() {179        let cases = [180            (r#""power":"room","#, Some(Power::Room)),181            (r#""power":"screen","#, Some(Power::Screen)),182            (r#""power":"later","#, None),183            (r#""power":null,"#, None),184            ("", None),185        ];186        for (field, want) in cases {187            let payload = format!(r#"{{{field}"displayName":"The Den"}}"#);188            let status = parse(payload.as_bytes()).expect("the status decodes");189            assert_eq!(status.power, want, "{field}");190        }191    }192193    #[test]194    fn a_word_this_client_does_not_name_is_idle() {195        assert_eq!(Activity::from_word("Playing"), Activity::Playing);196        assert_eq!(Activity::from_word("Buffering"), Activity::Idle);197        assert_eq!(Activity::from_word(""), Activity::Idle);198    }199200    #[test]201    fn text_that_does_not_parse_is_no_status() {202        assert_eq!(parse(b""), None);203        assert_eq!(parse(b"{"), None);204        assert_eq!(parse(b"[]"), None);205        assert_eq!(parse(br#"["The Den","Idle",null,[]]"#), None);206    }207208    #[test]209    fn a_part_with_no_name_leaves_the_rest_of_the_status_readable() {210        let status = parse(211            br#"{"displayName":"The Den","components":[212                   {"kind":"remote"},{"name":"A remote","kind":"remote"}]}"#,213        )214        .expect("the status decodes");215216        assert_eq!(status.display_name, "The Den");217        assert_eq!(status.components[0].name, "");218        assert_eq!(status.components[1].name, "A remote");219    }220}
src/volume.rs 100.0%
1// The listening level as `Player` state. One retained topic carries it, and2// `media-operator` is its only writer: it relays the level that the unit's3// `Receiver` or `Sink` reports, divided by the device's `spec.volume.max`, so4// every screen draws one scale whatever device sets the room's level. A screen5// reads the topic and draws it, and publishes nothing on it. A `Receiver` that6// shows its own volume overlay on the TV marks its level with7// `"indicator": "receiver"`, and a screen tracks that level and draws no bar,8// so the TV shows one indicator and not two.910use serde::Deserialize;1112/// The bottom and the top of the normalized range. The top is the device's13/// `spec.volume.max`, the highest level a press can ask for.14pub const MIN_LEVEL: f64 = 0.0;15pub const MAX_LEVEL: f64 = 1.0;1617/// Who draws the volume indicator for the level on the topic. The operator18/// omits the field unless a `Receiver` draws its own overlay, so an absent19/// field, and a value this build does not know, both mean the screen draws.20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]21#[serde(rename_all = "lowercase")]22pub enum Indicator {23    /// The receiver draws its own overlay on the TV, and the screen draws no24    /// bar.25    Receiver,26    /// The `Player`'s screen draws the bar.27    #[default]28    #[serde(other)]29    Player,30}3132/// The whole payload on the volume topic. The operator writes the level and33/// the mute on every message, and each one still defaults here, so a partial34/// payload reads as the zero value instead of failing to decode.35#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]36pub struct Volume {37    #[serde(default)]38    pub level: f64,39    #[serde(default)]40    pub muted: bool,41    #[serde(default)]42    pub indicator: Indicator,43}4445impl Default for Volume {46    /// The state the client holds before any message reaches it: the top of47    /// the range, unmuted, drawn by the screen.48    fn default() -> Self {49        Self {50            level: MAX_LEVEL,51            muted: false,52            indicator: Indicator::Player,53        }54    }55}5657impl Volume {58    /// The level held inside 0.0 to 1.0. A level outside the range is clamped59    /// rather than refused, so a message cannot draw a row past the top.60    pub fn clamped(self) -> Self {61        Self {62            level: self.level.clamp(MIN_LEVEL, MAX_LEVEL),63            ..self64        }65    }6667    /// Whether a live message carrying this volume draws the bar, after the68    /// screen held `before`. A level the receiver draws on the TV draws no69    /// bar. A message that differs from `before` only in the indicator is70    /// the operator republishing the retained level after the indicator71    /// changed, and no person changed the level, so it draws no bar either.72    /// A press at the end of the scale repeats the whole payload, and draws.73    pub fn draws_after(self, before: Volume) -> bool {74        let only_the_indicator = self.indicator != before.indicator75            && self.level == before.level76            && self.muted == before.muted;77        self.indicator == Indicator::Player && !only_the_indicator78    }7980    /// The level as the whole percent a screen prints beside the bar.81    pub fn percent(self) -> i64 {82        // The level is clamped to 0.0 to 1.0, so the product fits in an i64.83        #[allow(clippy::cast_possible_truncation)]84        let percent = (self.clamped().level * 100.0).round() as i64;85        percent86    }87}8889/// Read one message off the volume topic. A payload that does not decode is no90/// state at all and changes nothing.91pub fn parse(payload: &[u8]) -> Option<Volume> {92    crate::object::<Volume>(payload).map(Volume::clamped)93}9495#[cfg(test)]96mod tests {97    use super::*;9899    #[test]100    fn the_state_decodes() {101        assert_eq!(102            parse(br#"{"level":0.63,"muted":true}"#),103            Some(Volume {104                level: 0.63,105                muted: true,106                ..Volume::default()107            })108        );109    }110111    #[test]112    fn the_indicator_decodes() {113        for (payload, indicator) in [114            (r#"{"level":0.63,"muted":false}"#, Indicator::Player),115            (116                r#"{"level":0.63,"muted":false,"indicator":"receiver"}"#,117                Indicator::Receiver,118            ),119            (120                r#"{"level":0.63,"muted":false,"indicator":"hologram"}"#,121                Indicator::Player,122            ),123        ] {124            assert_eq!(125                parse(payload.as_bytes()).map(|volume| volume.indicator),126                Some(indicator),127                "{payload}"128            );129        }130    }131132    fn at(level: f64, muted: bool, indicator: Indicator) -> Volume {133        Volume {134            level,135            muted,136            indicator,137        }138    }139140    #[test]141    fn a_live_level_draws_unless_the_receiver_draws_it() {142        use Indicator::{Player, Receiver};143        for (before, after, draws) in [144            (at(0.4, false, Player), at(0.45, false, Player), true),145            (at(0.4, false, Player), at(0.4, false, Player), true),146            (at(0.4, false, Player), at(0.4, true, Player), true),147            (at(0.4, false, Receiver), at(0.45, false, Receiver), false),148            (at(0.4, false, Player), at(0.4, false, Receiver), false),149            (at(0.4, false, Receiver), at(0.4, false, Player), false),150            (at(0.4, false, Receiver), at(0.45, false, Player), true),151        ] {152            assert_eq!(153                after.draws_after(before),154                draws,155                "{before:?} then {after:?}"156            );157        }158    }159160    #[test]161    fn a_whole_number_level_decodes_as_a_float() {162        assert_eq!(163            parse(br#"{"level":1,"muted":false}"#),164            Some(Volume::default())165        );166    }167168    #[test]169    fn a_level_outside_the_range_is_clamped() {170        assert_eq!(171            parse(br#"{"level":4.0,"muted":false}"#).map(|v| v.level),172            Some(MAX_LEVEL)173        );174        assert_eq!(175            parse(br#"{"level":-0.5,"muted":false}"#).map(|v| v.level),176            Some(MIN_LEVEL)177        );178    }179180    #[test]181    fn a_field_the_message_omits_reads_as_its_zero_value() {182        assert_eq!(183            parse(br#"{"level":0.4}"#),184            Some(Volume {185                level: 0.4,186                muted: false,187                ..Volume::default()188            })189        );190        assert_eq!(parse(br#"{}"#).map(|state| state.level), Some(0.0));191    }192193    #[test]194    fn text_that_does_not_parse_is_no_state() {195        assert_eq!(parse(b""), None);196        assert_eq!(parse(b"0.4"), None);197        assert_eq!(parse(b"[0.4,true]"), None);198        assert_eq!(parse(br#"{"level":"loud"}"#), None);199    }200201    #[test]202    fn a_client_with_no_message_holds_the_top_unmuted() {203        assert_eq!(Volume::default().level, MAX_LEVEL);204        assert!(!Volume::default().muted);205    }206207    #[test]208    fn the_percent_rounds_the_level() {209        for (level, percent) in [(0.0, 0), (0.625, 63), (0.632, 63), (1.0, 100), (1.5, 100)] {210            assert_eq!(211                Volume {212                    level,213                    muted: false,214                    ..Volume::default()215                }216                .percent(),217                percent218            );219        }220    }221}
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, which `media-operator` alone writes.28/// The operator sets the variable only for a unit whose level it relays, so an29/// empty value is the speaker gate: the client subscribes to no level and30/// draws no volume row.31pub const VOLUME_TOPIC: &str = "MEDIA_PLAYER_VOLUME_TOPIC";3233/// The address this client's `/metrics` listener binds, written `host:port`.34/// An empty value is milestone 65's off switch: no listener opens, and the35/// gauges below cost a lookup that finds nowhere to go. A delegate's operator36/// sets a port of its own choosing here, the way it sets every other address.37pub const METRICS_ADDRESS: &str = "MEDIA_METRICS_ADDRESS";3839/// The version this client's image carries, for the `liken_build_info` gauge.40/// The operator reads its own tag off the pod it already resolved this41/// client's image from, so the client never guesses at a version no one told42/// it.43pub const VERSION: &str = "MEDIA_VERSION";4445/// The `Player`'s commands topic. It carries the playback pod's `play-next`,46/// the ask a person makes on the up-next offer the scrubber draws. The47/// presses reach a client on the controllers' own topics, and a client brings48/// its own shade down in its own process.49pub const COMMANDS_TOPIC: &str = "MEDIA_PLAYER_COMMANDS_TOPIC";5051/// The topic the client states the panel desire on. The operator builds it52/// whole, the way it builds the commands and status topics, so the client53/// parses no topic.54pub const PANEL_TOPIC: &str = "MEDIA_PLAYER_PANEL_TOPIC";5556/// The topic a power press publishes its ask on while the unit's status57/// states the room mode. A current operator sets it for every unit, so58/// wiring or removing a `Receiver` leaves the pod as it is. An operator that59/// predates the power mode sets it only for a unit with a `Receiver`, and a60/// client that read no mode treats an empty value as the screen mode.61pub const POWER_TOPIC: &str = "MEDIA_PLAYER_POWER_TOPIC";6263/// The two lists of the unit's controllers, newline-joined and aligned by64/// position: each controller's events topic and the focus topic that carries65/// its mark. They are the same two variables the playback pod's command66/// sidecar reads. A line's number is the controller's place in `spec.remotes`,67/// which is the index a focus moment carries, so a blank focus line leaves68/// that controller with no focus topic rather than shifting the pairing.69pub const REMOTE_EVENTS_TOPICS: &str = "MEDIA_REMOTE_EVENTS_TOPICS";70pub const REMOTE_FOCUS_TOPICS: &str = "MEDIA_REMOTE_FOCUS_TOPICS";7172/// The quiet window in seconds, where zero means the screen never fades on73/// its own, and the off window in seconds, where zero leaves the panel lit.74/// The operator settles both for every `Player`, so an unset or unreadable75/// value fades nothing and darkens nothing rather than guessing a window a76/// cluster never asked for.77pub const FADE_AFTER_SECONDS: &str = "IDLE_FADE_AFTER_SECONDS";78pub const OFF_AFTER_SECONDS: &str = "IDLE_OFF_AFTER_SECONDS";7980/// One of the unit's controllers as a client reads it: the topic its presses81/// arrive on, and the topic its focus mark stands on. A controller with no82/// focus topic carries an empty one, and a press from it reaches nothing,83/// because the mark is the whole gate.84#[derive(Debug, Clone, Default, PartialEq, Eq)]85pub struct Remote {86    pub events: String,87    pub focus: String,88}8990/// Everything the operator told this container.91#[derive(Debug, Clone, Default, PartialEq, Eq)]92pub struct Wiring {93    pub bus_address: String,94    pub player_name: String,95    pub status_topic: String,96    pub volume_topic: String,97    pub commands_topic: String,98    pub panel_topic: String,99    /// The topic a power press publishes its ask on in the room mode. Empty100    /// keeps every power press on the client.101    pub power_topic: String,102    /// The controllers in `spec.remotes` order.103    pub remotes: Vec<Remote>,104    /// The quiet window. Zero never arms the timer.105    pub fade_after: Duration,106    /// The off window, clamped to at least the fade, so the panel never goes107    /// dark behind a still-lit image. Zero leaves the desire at on forever.108    pub off_after: Duration,109    /// Where this client's `/metrics` listener binds. Empty turns it off.110    pub metrics_address: String,111    /// This client's own version, for the `liken_build_info` gauge.112    pub version: String,113}114115impl Wiring {116    /// What this process runs under.117    pub fn from_environment() -> Self {118        Self::read(|name| std::env::var(name).ok())119    }120121    /// The same read against any source of values. The environment is global to122    /// a process, so a test states the variables here instead of setting them123    /// and racing every other test in the binary.124    pub fn read(value: impl Fn(&str) -> Option<String>) -> Self {125        let read = |name| value(name).unwrap_or_default();126127        let fade_after = seconds(&read(FADE_AFTER_SECONDS));128        let off_after = seconds(&read(OFF_AFTER_SECONDS));129        Self {130            bus_address: read(BUS_ADDRESS),131            player_name: read(PLAYER_NAME),132            status_topic: read(STATUS_TOPIC),133            volume_topic: read(VOLUME_TOPIC),134            commands_topic: read(COMMANDS_TOPIC),135            panel_topic: read(PANEL_TOPIC),136            power_topic: read(POWER_TOPIC),137            remotes: remotes(&read(REMOTE_EVENTS_TOPICS), &read(REMOTE_FOCUS_TOPICS)),138            fade_after,139            // A window of zero is the panel never going dark, whatever the140            // fade is, so the clamp runs only on a window a cluster stated.141            off_after: match off_after.is_zero() {142                true => Duration::ZERO,143                false => off_after.max(fade_after),144            },145            metrics_address: read(METRICS_ADDRESS),146            version: read(VERSION),147        }148    }149}150151/// Pair each controller's events topic with the focus topic on the same line152/// of the second list. A blank line is kept, because the two lists stay153/// aligned by position and the line number is the controller's index.154fn remotes(events: &str, focuses: &str) -> Vec<Remote> {155    let focuses = lines(focuses);156    lines(events)157        .into_iter()158        .enumerate()159        .map(|(index, events)| Remote {160            events,161            focus: focuses.get(index).cloned().unwrap_or_default(),162        })163        .collect()164}165166/// One of the newline-joined lists the operator sets on a container. An unset167/// variable is no lines at all, not one empty line.168fn lines(text: &str) -> Vec<String> {169    if text.is_empty() {170        return Vec::new();171    }172    text.split('\n').map(str::to_string).collect()173}174175/// One window, in seconds. Anything but a positive whole number is no window176/// at all, because the operator settles this field for every `Player` and a177/// guessed default here would dim a screen the cluster never asked to dim.178fn seconds(text: &str) -> Duration {179    match text.trim().parse::<i64>() {180        Ok(seconds) if seconds > 0 => Duration::from_secs(seconds as u64),181        _ => Duration::ZERO,182    }183}184185#[cfg(test)]186mod tests {187    use super::*;188189    fn wiring(pairs: &[(&str, &str)]) -> Wiring {190        let pairs: Vec<(String, String)> = pairs191            .iter()192            .map(|(name, value)| (name.to_string(), value.to_string()))193            .collect();194        Wiring::read(|name| {195            pairs196                .iter()197                .find(|(set, _)| set == name)198                .map(|(_, value)| value.clone())199        })200    }201202    #[test]203    fn a_process_reads_its_own_environment() {204        // The gates run this binary with none of these variables set, so what205        // the read returns is the empty wiring. A client under that wiring206        // opens no reader and draws its seeds.207        assert_eq!(Wiring::from_environment(), Wiring::default());208    }209210    #[test]211    fn an_empty_environment_wires_nothing() {212        assert_eq!(Wiring::read(|_| None), Wiring::default());213    }214215    #[test]216    fn every_variable_lands_in_the_wiring() {217        let read = wiring(&[218            (BUS_ADDRESS, "broker:1883"),219            (PLAYER_NAME, "den-tv"),220            (STATUS_TOPIC, "media/players/den/tv/status"),221            (VOLUME_TOPIC, "media/players/den/tv/volume"),222            (COMMANDS_TOPIC, "media/players/den/tv/commands"),223            (PANEL_TOPIC, "media/players/den/tv/panel"),224            (POWER_TOPIC, "media/players/den/tv/power"),225            (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),226            (REMOTE_FOCUS_TOPICS, "focus/sofa\nfocus/armchair"),227            (FADE_AFTER_SECONDS, "600"),228            (OFF_AFTER_SECONDS, "1800"),229            (METRICS_ADDRESS, "0.0.0.0:9200"),230            (VERSION, "2026.09.10-001"),231        ]);232233        assert_eq!(read.bus_address, "broker:1883");234        assert_eq!(read.player_name, "den-tv");235        assert_eq!(read.status_topic, "media/players/den/tv/status");236        assert_eq!(read.volume_topic, "media/players/den/tv/volume");237        assert_eq!(read.commands_topic, "media/players/den/tv/commands");238        assert_eq!(read.panel_topic, "media/players/den/tv/panel");239        assert_eq!(read.power_topic, "media/players/den/tv/power");240        assert_eq!(read.fade_after, Duration::from_secs(600));241        assert_eq!(read.off_after, Duration::from_secs(1800));242        assert_eq!(read.metrics_address, "0.0.0.0:9200");243        assert_eq!(read.version, "2026.09.10-001");244    }245246    #[test]247    fn the_two_remote_lists_pair_by_position() {248        let read = wiring(&[249            (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),250            (REMOTE_FOCUS_TOPICS, "focus/sofa\nfocus/armchair"),251        ]);252253        assert_eq!(254            read.remotes,255            [256                Remote {257                    events: "events/sofa".into(),258                    focus: "focus/sofa".into()259                },260                Remote {261                    events: "events/armchair".into(),262                    focus: "focus/armchair".into()263                },264            ]265        );266    }267268    #[test]269    fn a_missing_focus_topic_leaves_that_controller_blank_and_shifts_nothing() {270        let read = wiring(&[271            (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),272            (REMOTE_FOCUS_TOPICS, "\nfocus/armchair"),273        ]);274275        assert_eq!(read.remotes[0].focus, "");276        assert_eq!(read.remotes[1].focus, "focus/armchair");277    }278279    #[test]280    fn a_focus_list_shorter_than_the_events_list_shifts_nothing() {281        let read = wiring(&[282            (REMOTE_EVENTS_TOPICS, "events/sofa\nevents/armchair"),283            (REMOTE_FOCUS_TOPICS, "focus/sofa"),284        ]);285286        assert_eq!(read.remotes[0].focus, "focus/sofa");287        assert_eq!(read.remotes[1].focus, "");288    }289290    #[test]291    fn a_unit_with_no_controllers_lists_none() {292        assert!(lines("").is_empty());293        assert_eq!(lines("first"), ["first"]);294        assert_eq!(lines("first\n"), ["first", ""]);295        assert_eq!(lines("first\nsecond"), ["first", "second"]);296    }297298    #[test]299    fn the_quiet_window_reads_the_seconds() {300        assert_eq!(seconds("600"), Duration::from_secs(600));301        assert_eq!(seconds(" 60 "), Duration::from_secs(60));302    }303304    #[test]305    fn anything_but_a_positive_window_is_no_window() {306        assert_eq!(seconds("0"), Duration::ZERO);307        assert_eq!(seconds(""), Duration::ZERO);308        assert_eq!(seconds("soon"), Duration::ZERO);309        assert_eq!(seconds("-5"), Duration::ZERO);310    }311312    #[test]313    fn the_off_window_never_lands_before_the_fade() {314        let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "60")]);315        assert_eq!(read.off_after, Duration::from_secs(600));316317        let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "600")]);318        assert_eq!(read.off_after, Duration::from_secs(600));319    }320321    #[test]322    fn an_off_window_of_zero_leaves_the_panel_lit_whatever_the_fade_is() {323        let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "0")]);324        assert_eq!(read.off_after, Duration::ZERO);325326        let read = wiring(&[(FADE_AFTER_SECONDS, "600"), (OFF_AFTER_SECONDS, "soon")]);327        assert_eq!(read.off_after, Duration::ZERO);328    }329}