media-screen
| Input | Unit | Covered | Total | Percent |
| Rust | lines | 1277 | 1283 | 99.5% |
Rust
1277 of 1283 lines, 99.5%.
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}