Skip to main content

mx_remote/runtime/
mod.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! The running client: the socket, the two threads that drive it, and the
5//! read surface over what they discover.
6
7mod control;
8mod info;
9mod schedule;
10
11#[cfg(test)]
12mod control_tests;
13#[cfg(test)]
14mod tests;
15
16// The reuseport check reads a Linux socket option, and pinning an addressless
17// interface is Linux-only, so the whole file is.
18#[cfg(all(test, target_os = "linux"))]
19mod socket;
20
21use std::io;
22use std::net::Ipv4Addr;
23use std::path::PathBuf;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::{Arc, Mutex, MutexGuard};
26use std::thread::JoinHandle;
27use std::time::{Duration, Instant};
28
29use crate::event::{Event, EventHandler};
30use crate::rx::process_frame;
31use crate::state::{Device, State};
32use crate::types::*;
33use crate::wire::{
34    build_hello, op, Addressee, BayUid, Conn, DeviceFeature, DeviceUid, FirmwareType, Opcode,
35    SendError, Tx, MULTICAST_IP, MULTICAST_PORT, PROTOCOL_VERSION, VERSION,
36};
37
38pub use control::ControlError;
39pub use info::{BayInfo, DeviceInfo};
40use schedule::Schedule;
41
42/// The name this client advertises when the caller sets none.
43const DEFAULT_NAME: &str = "MXR Rust";
44
45/// The serial this client advertises.
46///
47/// A client is not a unit with a serial number, but the field is fixed-width
48/// and every device fills it, so it carries a constant rather than a blank.
49const CLIENT_SERIAL: &str = "P9SN00000000";
50
51/// The file the client's identifier is kept in, under the user's home
52/// directory. The identifier has to survive a restart, or every peer would see
53/// each run as a new client.
54const UID_FILE: &str = ".mxr-uid";
55
56/// Largest datagram the receive buffer accepts.
57const RECV_BUFFER: usize = 65535;
58
59/// How often the background thread re-examines the network.
60const PROBE_TICK: Duration = Duration::from_secs(1);
61
62/// How often that thread looks up from waiting to see whether it should stop.
63const SHUTDOWN_POLL: Duration = Duration::from_millis(50);
64
65/// Shortest gap between two discovery requests.
66const DISCOVER_INTERVAL: Duration = Duration::from_secs(5);
67
68/// How long a device that has announced itself is given to finish sending its
69/// configuration before discovery is asked for again.
70const CONFIG_GRACE: Duration = Duration::from_secs(15);
71
72/// How a [`Remote`] finds the network.
73///
74/// [`Config::default`] discovers over multicast on the interface the host
75/// picks, which is the right answer on a single-homed machine and arbitrary on
76/// any other.
77#[derive(Clone, Debug, Default)]
78#[non_exhaustive]
79pub struct Config {
80    /// Where to send. Unset means the multicast group, or the interface's
81    /// broadcast address when [`Config::broadcast`] is set.
82    pub target_ip: Option<Ipv4Addr>,
83    /// UDP port. Unset means the default for the selected mode.
84    pub port: Option<u16>,
85    /// Selects the interface by address.
86    ///
87    /// It becomes both the multicast egress interface and the membership
88    /// interface, so it decides which NIC frames leave by and which one they
89    /// are accepted on. Getting it wrong on a multi-homed host is one-sided:
90    /// periodic broadcasts still arrive, so discovery looks healthy while
91    /// every request this client sends leaves by the wrong NIC and is never
92    /// answered.
93    pub local_ip: Option<Ipv4Addr>,
94    /// Selects the interface by name, taking precedence over
95    /// [`Config::local_ip`].
96    ///
97    /// An interface with no address of its own - a tagged VLAN - can only be
98    /// named this way, and only on Linux.
99    pub interface: Option<String>,
100    /// Use broadcast rather than multicast.
101    pub broadcast: bool,
102    /// The name this client advertises. Unset means `MXR Rust`.
103    pub name: Option<String>,
104    /// This client's identifier. Unset loads it from
105    /// [`Config::uid_path`], generating and storing one on first run.
106    pub uid: Option<DeviceUid>,
107    /// Where the identifier is kept. Unset means `.mxr-uid` in the user's home
108    /// directory.
109    pub uid_path: Option<PathBuf>,
110}
111
112/// Everything the threads share.
113struct Shared {
114    uid: DeviceUid,
115    name: String,
116    handler: Arc<dyn EventHandler>,
117    state: Mutex<State>,
118    tx: Mutex<Tx>,
119    schedule: Mutex<Schedule>,
120    network: Mutex<Network>,
121    closing: AtomicBool,
122}
123
124/// The network parameters the socket was opened with, so it can be reopened.
125#[derive(Clone, Debug)]
126struct Network {
127    target_ip: Option<Ipv4Addr>,
128    port: Option<u16>,
129    local_ip: Option<Ipv4Addr>,
130    interface: Option<String>,
131    broadcast: bool,
132}
133
134impl Network {
135    /// The address to send to: what the caller asked for, else the multicast
136    /// group, else - in broadcast mode - the chosen interface's own broadcast
137    /// address.
138    fn target(&self) -> io::Result<Ipv4Addr> {
139        if let Some(ip) = self.target_ip {
140            return Ok(ip);
141        }
142        if !self.broadcast {
143            return Ok(MULTICAST_IP);
144        }
145        Ok(crate::wire::broadcast_address(self.local_ip).unwrap_or(MULTICAST_IP))
146    }
147
148    fn port(&self) -> u16 {
149        self.port.unwrap_or(if self.broadcast {
150            crate::wire::BROADCAST_PORT
151        } else {
152            MULTICAST_PORT
153        })
154    }
155
156    fn open(&self) -> io::Result<Conn> {
157        Conn::open(
158            self.target()?,
159            self.port(),
160            self.local_ip,
161            self.interface.as_deref(),
162        )
163    }
164}
165
166/// A client on the MX Remote network.
167///
168/// Create one with [`Remote::new`], then [`Remote::start`] it. Discovery runs
169/// on threads this client owns until [`Remote::close`], or until the `Remote`
170/// is dropped.
171pub struct Remote {
172    shared: Arc<Shared>,
173    workers: Mutex<Vec<JoinHandle<()>>>,
174}
175
176impl Remote {
177    /// Builds a client, loading or generating its identifier.
178    ///
179    /// Nothing is sent and no socket is opened until [`Remote::start`].
180    pub fn new(config: Config, handler: Arc<dyn EventHandler>) -> io::Result<Self> {
181        let uid = match config.uid {
182            Some(uid) => uid,
183            None => load_uid(config.uid_path.clone())?,
184        };
185        let name = config.name.unwrap_or_else(|| DEFAULT_NAME.to_owned());
186        Ok(Self {
187            shared: Arc::new(Shared {
188                uid,
189                name,
190                handler,
191                state: Mutex::new(State::new(uid)),
192                tx: Mutex::new(Tx::default()),
193                schedule: Mutex::new(Schedule::new()),
194                network: Mutex::new(Network {
195                    target_ip: config.target_ip,
196                    port: config.port,
197                    local_ip: config.local_ip,
198                    interface: config.interface,
199                    broadcast: config.broadcast,
200                }),
201                closing: AtomicBool::new(false),
202            }),
203            workers: Mutex::new(Vec::new()),
204        })
205    }
206
207    /// Opens the socket, announces this client and begins discovery.
208    ///
209    /// Returns once the receive thread is running; discovery continues in the
210    /// background.
211    pub fn start(&self) -> io::Result<()> {
212        let conn = lock(&self.shared.network).open()?;
213        lock(&self.shared.tx).set_conn(Some(conn));
214        self.shared.closing.store(false, Ordering::SeqCst);
215        self.spawn_workers()?;
216        self.shared.announce();
217        let _ = self.shared.discover();
218        Ok(())
219    }
220
221    /// Stops discovery, closes the socket and waits for the threads to finish.
222    ///
223    /// Calling it more than once, or before [`Remote::start`], does nothing.
224    pub fn close(&self) {
225        self.shared.closing.store(true, Ordering::SeqCst);
226        for worker in std::mem::take(&mut *lock(&self.workers)) {
227            let _ = worker.join();
228        }
229        // Only once no thread can still be reading from it: a descriptor
230        // released while another thread is parked on it can be reissued to
231        // something else before that thread returns.
232        lock(&self.shared.tx).set_conn(None);
233    }
234
235    fn spawn_workers(&self) -> io::Result<()> {
236        let mut workers = lock(&self.workers);
237        if !workers.is_empty() {
238            return Ok(());
239        }
240        for (name, body) in [
241            ("mxr-rx", Shared::receive_loop as fn(&Shared)),
242            ("mxr-probe", Shared::probe_loop as fn(&Shared)),
243        ] {
244            let shared = Arc::clone(&self.shared);
245            workers.push(
246                std::thread::Builder::new()
247                    .name(name.to_owned())
248                    .spawn(move || body(&shared))?,
249            );
250        }
251        Ok(())
252    }
253
254    // ---- identity ----
255
256    /// This client's identifier, as peers see it.
257    pub fn uid(&self) -> DeviceUid {
258        self.shared.uid
259    }
260
261    /// The name this client advertises.
262    pub fn name(&self) -> &str {
263        &self.shared.name
264    }
265
266    /// The address frames are being sent to, once started.
267    pub fn target(&self) -> Option<std::net::SocketAddrV4> {
268        lock(&self.shared.tx).conn().map(|conn| conn.target())
269    }
270
271    // ---- reading the registry ----
272
273    /// Every device heard from, in no particular order.
274    pub fn devices(&self) -> Vec<DeviceUid> {
275        self.shared
276            .read(|state| state.devices.keys().copied().collect())
277    }
278
279    /// A snapshot of one device.
280    pub fn device(&self, uid: DeviceUid) -> Option<DeviceInfo> {
281        let now = Instant::now();
282        self.shared
283            .read(|state| state.device(uid).map(|d| DeviceInfo::of(d, now)))
284    }
285
286    /// The device with the given serial number.
287    pub fn device_by_serial(&self, serial: &str) -> Option<DeviceUid> {
288        self.shared
289            .read(|state| state.device_by_serial(serial).map(|d| d.uid))
290    }
291
292    /// Resolves a device from its dotted-hex identifier, falling back to a
293    /// serial-number match.
294    pub fn resolve_device(&self, name: &str) -> Option<DeviceUid> {
295        if let Ok(uid) = name.parse::<DeviceUid>() {
296            if self.shared.read(|state| state.device(uid).is_some()) {
297                return Some(uid);
298            }
299        }
300        self.device_by_serial(name)
301    }
302
303    /// A snapshot of one bay.
304    pub fn bay(&self, uid: BayUid) -> Option<BayInfo> {
305        self.shared
306            .read(|state| state.bay(uid).map(|bay| BayInfo::of(state, bay)))
307    }
308
309    /// The bay on `device` with the given port name, such as `Output 1`.
310    pub fn bay_by_name(&self, device: DeviceUid, port_name: &str) -> Option<BayUid> {
311        self.shared.read(|state| {
312            state
313                .device(device)?
314                .bay_by_name(port_name)
315                .map(crate::state::Bay::uid)
316        })
317    }
318
319    /// The source bay advertising the given multicast group, for the video or
320    /// the audio stream.
321    pub fn bay_by_stream_ip(&self, ip: Ipv4Addr, audio: bool) -> Option<BayUid> {
322        self.shared.read(|state| state.bay_by_stream_ip(ip, audio))
323    }
324
325    /// The V2IP streams a device advertises.
326    pub fn v2ip_sources(&self, uid: DeviceUid) -> Option<Vec<V2ipStreamSources>> {
327        self.shared
328            .read(|state| state.device(uid)?.v2ip_sources.clone())
329    }
330
331    /// A V2IP device's own encoder configuration.
332    pub fn v2ip_details(&self, uid: DeviceUid) -> Option<DeviceV2ipDetails> {
333        self.shared.read(|state| state.device(uid)?.v2ip_details)
334    }
335
336    /// The streams a V2IP sink is subscribed to.
337    pub fn v2ip_sink(&self, uid: DeviceUid) -> Option<DeviceV2ipSink> {
338        self.shared.read(|state| state.device(uid)?.v2ip_sink)
339    }
340
341    /// Transport statistics a V2IP device reports.
342    pub fn v2ip_stats(&self, uid: DeviceUid) -> Option<V2ipDeviceStats> {
343        self.shared.read(|state| state.device(uid)?.v2ip_stats)
344    }
345
346    /// The video-wall tiling a V2IP device is configured for.
347    pub fn v2ip_tiling(&self, uid: DeviceUid) -> Option<V2ipTilingConfig> {
348        self.shared.read(|state| state.device(uid)?.tiling)
349    }
350
351    /// The audio endpoint tree a device exposes.
352    pub fn audio_endpoints(&self, uid: DeviceUid) -> Option<AudioEndpoints> {
353        self.shared.read(|state| state.device(uid)?.audio.clone())
354    }
355
356    /// The multiviewer layout a device is showing.
357    pub fn multiviewer_status(&self, uid: DeviceUid) -> Option<MultiviewerStatus> {
358        self.shared
359            .read(|state| state.device(uid)?.multiviewer.clone())
360    }
361
362    /// An amplifier's Dolby decoder settings.
363    pub fn dolby_settings(&self, uid: DeviceUid) -> Option<AmpDolbySettings> {
364        self.shared.read(|state| state.device(uid)?.dolby_settings)
365    }
366
367    /// A device's power-distribution state.
368    pub fn pdu_state(&self, uid: DeviceUid) -> Option<PduState> {
369        self.shared.read(|state| state.device(uid)?.pdu_state)
370    }
371
372    /// A device's remote-control settings.
373    pub fn rc_settings(&self, uid: DeviceUid) -> Option<RcSettings> {
374        self.shared
375            .read(|state| state.device(uid)?.rc_settings.clone())
376    }
377
378    /// Every network port a device reports, in port order.
379    pub fn network_status(&self, uid: DeviceUid) -> Vec<NetworkPortStatus> {
380        self.shared.read(|state| {
381            state
382                .device(uid)
383                .map(|d| d.network.values().cloned().collect())
384                .unwrap_or_default()
385        })
386    }
387
388    /// The mesh topology a device reports.
389    pub fn topology(&self, uid: DeviceUid) -> Vec<TopologyEntry> {
390        self.shared.read(|state| {
391            state
392                .device(uid)
393                .map(|d| d.topology.clone())
394                .unwrap_or_default()
395        })
396    }
397
398    /// Every firmware image a device reports a version for.
399    pub fn firmware(&self, uid: DeviceUid) -> Vec<(FirmwareType, FirmwareVersion)> {
400        self.shared.read(|state| {
401            state
402                .device(uid)
403                .map(|d| d.firmware.iter().map(|(k, v)| (*k, v.clone())).collect())
404                .unwrap_or_default()
405        })
406    }
407
408    // ---- reconfiguring ----
409
410    /// Changes the interface and the multicast/broadcast mode while running,
411    /// reopening the socket when either differs from what is in use.
412    pub fn update_config(&self, local_ip: Option<Ipv4Addr>, broadcast: bool) -> io::Result<()> {
413        let network = {
414            let mut network = lock(&self.shared.network);
415            if network.local_ip == local_ip && network.broadcast == broadcast {
416                return Ok(());
417            }
418            network.local_ip = local_ip;
419            network.broadcast = broadcast;
420            network.clone()
421        };
422        // Opened before the old one is dropped, so a bind that fails leaves the
423        // client on the socket it had rather than on none.
424        let conn = network.open()?;
425        lock(&self.shared.tx).set_conn(Some(conn));
426        self.shared.announce();
427        let _ = self.shared.discover();
428        Ok(())
429    }
430
431    /// Asks every device on the network to announce itself.
432    pub fn discover(&self) -> Result<(), SendError> {
433        self.shared.discover()
434    }
435}
436
437impl Drop for Remote {
438    fn drop(&mut self) {
439        self.close();
440    }
441}
442
443impl Shared {
444    /// Reads the registry.
445    fn read<R>(&self, f: impl FnOnce(&State) -> R) -> R {
446        f(&lock(&self.state))
447    }
448
449    /// Mutates the registry, then delivers what changed.
450    ///
451    /// The queue is drained after the lock is released, so an event handler
452    /// may call back into the library.
453    fn mutate<R>(&self, f: impl FnOnce(&mut State, &mut Vec<Event>) -> R) -> R {
454        let mut events = Vec::new();
455        let result = f(&mut lock(&self.state), &mut events);
456        self.dispatch(events);
457        result
458    }
459
460    fn dispatch(&self, events: Vec<Event>) {
461        for event in events {
462            event.dispatch(&*self.handler);
463        }
464    }
465
466    /// Decodes one datagram and delivers what it changed.
467    ///
468    /// This is the receive entry point. Keeping it distinct from the decode
469    /// below matters even though the wrapper is thin: announcing hello from
470    /// here, driven by arriving traffic rather than by a clock, is a mistake
471    /// this shape makes visible.
472    fn process_datagram(&self, data: &[u8], from: Ipv4Addr) {
473        let events = process_frame(&mut lock(&self.state), data, Some(from), Instant::now());
474        self.dispatch(events);
475    }
476
477    /// Sends a frame, refusing one the addressee cannot decode.
478    fn send(&self, to: &Addressee, opcode: Opcode, payload: &[u8]) -> Result<usize, SendError> {
479        lock(&self.tx).send(to, self.uid, opcode, payload)
480    }
481
482    fn discover(&self) -> Result<(), SendError> {
483        lock(&self.schedule).discovered(Instant::now());
484        self.send(&Addressee::Broadcast, op::SYS_DISCOVER, &[])?;
485        Ok(())
486    }
487
488    /// Announces this client, and re-arms the announcement timer only once the
489    /// frame is away.
490    ///
491    /// The firmware resets its own hello timeout inside the branch where the
492    /// transmit succeeded. A send that fails is then retried on the next tick
493    /// rather than costing a whole interval of silence, which matters most at
494    /// startup and after a network blip: exactly when being heard is worth the
495    /// most.
496    fn announce(&self) {
497        let payload = build_hello(
498            PROTOCOL_VERSION,
499            &self.name,
500            CLIENT_SERIAL,
501            VERSION,
502            DeviceFeature::MANAGER.bits(),
503        );
504        match self.send(&Addressee::Broadcast, op::SYS_HELLO, &payload) {
505            Ok(n) if n > 0 => lock(&self.schedule).announced(Instant::now()),
506            _ => {}
507        }
508    }
509
510    /// Waits out one tick, reporting whether the client is still running.
511    ///
512    /// Slept in short steps rather than one, so closing does not have to wait
513    /// out a whole tick before the thread can be joined.
514    fn sleep_until_next_tick(&self) -> bool {
515        let deadline = Instant::now() + PROBE_TICK;
516        while Instant::now() < deadline {
517            if self.closing.load(Ordering::SeqCst) {
518                return false;
519            }
520            std::thread::sleep(SHUTDOWN_POLL);
521        }
522        !self.closing.load(Ordering::SeqCst)
523    }
524
525    /// Whether it is time to announce again.
526    ///
527    /// This is a timer, not a response to traffic: a device announces itself on
528    /// a schedule whether or not anything is talking to it, and a client that
529    /// only re-announced when a datagram arrived would go silent on a quiet
530    /// network and stay unknown to every peer that started after it.
531    fn announce_due(&self, now: Instant) -> bool {
532        !self.closing.load(Ordering::SeqCst) && lock(&self.schedule).announce_due(now)
533    }
534
535    /// Reads datagrams until the client is closing.
536    fn receive_loop(&self) {
537        let mut buf = vec![0u8; RECV_BUFFER];
538        while !self.closing.load(Ordering::SeqCst) {
539            let Some(conn) = lock(&self.tx).conn() else {
540                break;
541            };
542            match conn.recv(&mut buf) {
543                Ok(Some((data, from))) => self.process_datagram(data, from),
544                Ok(None) => {}
545                Err(_) => break,
546            }
547        }
548    }
549
550    /// Re-examines the network once a second: liveness, the announcement timer
551    /// and whether anything still owes us its configuration.
552    fn probe_loop(&self) {
553        while self.sleep_until_next_tick() {
554            let now = Instant::now();
555            let want_discover = self.mutate(|state, ev| {
556                let mut incomplete = false;
557                let mut any_complete = false;
558                for device in state.devices.values_mut() {
559                    device.check_online(now, ev);
560                    if device.configuration_complete() {
561                        any_complete = true;
562                    } else if now.saturating_duration_since(device.hello_received) > CONFIG_GRACE {
563                        // Past the grace period a device has said nothing more,
564                        // so ask the network again rather than wait forever.
565                        incomplete = true;
566                    }
567                }
568                // Nothing has finished describing itself, so nothing has been
569                // discovered yet at all.
570                incomplete || !any_complete
571            });
572
573            let discover_due = lock(&self.schedule).discover_due(now);
574            if self.announce_due(now) {
575                self.announce();
576            }
577            if want_discover && discover_due {
578                let _ = self.discover();
579            }
580        }
581    }
582}
583
584/// Takes a lock, continuing through a poisoning.
585///
586/// A poisoned lock here means a panic somewhere that held it. The state behind
587/// it is a cache of what devices have reported and is rebuilt by the next
588/// frame from each, so refusing to touch it again would retire a working
589/// client over one bad datagram.
590fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
591    m.lock().unwrap_or_else(|e| e.into_inner())
592}
593
594/// Loads the client's identifier, generating and storing one on first run.
595///
596/// A generated identifier that cannot be stored is still used: a client with a
597/// new identity each run is worse than one that works today, and the failure is
598/// the caller's home directory, not the network.
599fn load_uid(path: Option<PathBuf>) -> io::Result<DeviceUid> {
600    let path = path.or_else(|| {
601        std::env::var_os("HOME")
602            .or_else(|| std::env::var_os("USERPROFILE"))
603            .map(|home| PathBuf::from(home).join(UID_FILE))
604    });
605    if let Some(path) = &path {
606        if let Ok(bytes) = std::fs::read(path) {
607            if let Ok(array) = <[u8; 16]>::try_from(bytes.get(..16).unwrap_or_default()) {
608                return Ok(DeviceUid::from_array(array));
609            }
610        }
611    }
612    let mut bytes = [0u8; 16];
613    getrandom::getrandom(&mut bytes).map_err(|e| io::Error::other(e.to_string()))?;
614    if let Some(path) = &path {
615        let _ = std::fs::write(path, bytes);
616    }
617    Ok(DeviceUid::from_array(bytes))
618}
619
620/// The protocol floor is checked against what the device says it can decode.
621impl crate::wire::ProtocolTarget for Device {
622    fn serial(&self) -> &str {
623        Device::serial(self)
624    }
625
626    fn supported_protocol(&self) -> u16 {
627        self.hello.supported_protocol
628    }
629}