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    /// The EDID a device last reported: the display on its output, or the one
399    /// it presents to the source on its input.
400    ///
401    /// Filled in by a device's answer to [`Remote::request_edid`], and by any
402    /// answer to a peer's request that this client happened to hear.
403    pub fn edid(&self, uid: DeviceUid, output: bool) -> Option<Vec<u8>> {
404        self.shared
405            .read(|state| state.device(uid)?.edid(output).map(<[u8]>::to_vec))
406    }
407
408    /// How many frames from other senders have parsed since this client
409    /// started.
410    ///
411    /// It separates a mesh with nothing on it from an interface nothing is on:
412    /// a client that has discovered no device but is counting frames is
413    /// hearing traffic it cannot get answers from, which on a multi-homed host
414    /// is what a wrong [`Config::local_ip`] looks like. Frames this client
415    /// sent are not counted, because the host loops its own multicast back
416    /// whichever interface was selected.
417    pub fn frames_received(&self) -> u64 {
418        self.shared.read(|state| state.frames_received)
419    }
420
421    /// Every firmware image a device reports a version for.
422    pub fn firmware(&self, uid: DeviceUid) -> Vec<(FirmwareType, FirmwareVersion)> {
423        self.shared.read(|state| {
424            state
425                .device(uid)
426                .map(|d| d.firmware.iter().map(|(k, v)| (*k, v.clone())).collect())
427                .unwrap_or_default()
428        })
429    }
430
431    // ---- reconfiguring ----
432
433    /// Changes the interface and the multicast/broadcast mode while running,
434    /// reopening the socket when either differs from what is in use.
435    pub fn update_config(&self, local_ip: Option<Ipv4Addr>, broadcast: bool) -> io::Result<()> {
436        let network = {
437            let mut network = lock(&self.shared.network);
438            if network.local_ip == local_ip && network.broadcast == broadcast {
439                return Ok(());
440            }
441            network.local_ip = local_ip;
442            network.broadcast = broadcast;
443            network.clone()
444        };
445        // Opened before the old one is dropped, so a bind that fails leaves the
446        // client on the socket it had rather than on none.
447        let conn = network.open()?;
448        lock(&self.shared.tx).set_conn(Some(conn));
449        self.shared.announce();
450        let _ = self.shared.discover();
451        Ok(())
452    }
453
454    /// Asks every device on the network to announce itself.
455    pub fn discover(&self) -> Result<(), SendError> {
456        self.shared.discover()
457    }
458}
459
460impl Drop for Remote {
461    fn drop(&mut self) {
462        self.close();
463    }
464}
465
466impl Shared {
467    /// Reads the registry.
468    fn read<R>(&self, f: impl FnOnce(&State) -> R) -> R {
469        f(&lock(&self.state))
470    }
471
472    /// Mutates the registry, then delivers what changed.
473    ///
474    /// The queue is drained after the lock is released, so an event handler
475    /// may call back into the library.
476    fn mutate<R>(&self, f: impl FnOnce(&mut State, &mut Vec<Event>) -> R) -> R {
477        let mut events = Vec::new();
478        let result = f(&mut lock(&self.state), &mut events);
479        self.dispatch(events);
480        result
481    }
482
483    fn dispatch(&self, events: Vec<Event>) {
484        for event in events {
485            event.dispatch(&*self.handler);
486        }
487    }
488
489    /// Decodes one datagram and delivers what it changed.
490    ///
491    /// This is the receive entry point. Keeping it distinct from the decode
492    /// below matters even though the wrapper is thin: announcing hello from
493    /// here, driven by arriving traffic rather than by a clock, is a mistake
494    /// this shape makes visible.
495    fn process_datagram(&self, data: &[u8], from: Ipv4Addr) {
496        let events = process_frame(&mut lock(&self.state), data, Some(from), Instant::now());
497        self.dispatch(events);
498    }
499
500    /// Sends a frame, refusing one the addressee cannot decode.
501    fn send(&self, to: &Addressee, opcode: Opcode, payload: &[u8]) -> Result<usize, SendError> {
502        lock(&self.tx).send(to, self.uid, opcode, payload)
503    }
504
505    fn discover(&self) -> Result<(), SendError> {
506        lock(&self.schedule).discovered(Instant::now());
507        self.send(&Addressee::Broadcast, op::SYS_DISCOVER, &[])?;
508        Ok(())
509    }
510
511    /// Announces this client, and re-arms the announcement timer only once the
512    /// frame is away.
513    ///
514    /// The firmware resets its own hello timeout inside the branch where the
515    /// transmit succeeded. A send that fails is then retried on the next tick
516    /// rather than costing a whole interval of silence, which matters most at
517    /// startup and after a network blip: exactly when being heard is worth the
518    /// most.
519    fn announce(&self) {
520        let payload = build_hello(
521            PROTOCOL_VERSION,
522            &self.name,
523            CLIENT_SERIAL,
524            VERSION,
525            DeviceFeature::MANAGER.bits(),
526        );
527        match self.send(&Addressee::Broadcast, op::SYS_HELLO, &payload) {
528            Ok(n) if n > 0 => lock(&self.schedule).announced(Instant::now()),
529            _ => {}
530        }
531    }
532
533    /// Waits out one tick, reporting whether the client is still running.
534    ///
535    /// Slept in short steps rather than one, so closing does not have to wait
536    /// out a whole tick before the thread can be joined.
537    fn sleep_until_next_tick(&self) -> bool {
538        let deadline = Instant::now() + PROBE_TICK;
539        while Instant::now() < deadline {
540            if self.closing.load(Ordering::SeqCst) {
541                return false;
542            }
543            std::thread::sleep(SHUTDOWN_POLL);
544        }
545        !self.closing.load(Ordering::SeqCst)
546    }
547
548    /// Whether it is time to announce again.
549    ///
550    /// This is a timer, not a response to traffic: a device announces itself on
551    /// a schedule whether or not anything is talking to it, and a client that
552    /// only re-announced when a datagram arrived would go silent on a quiet
553    /// network and stay unknown to every peer that started after it.
554    fn announce_due(&self, now: Instant) -> bool {
555        !self.closing.load(Ordering::SeqCst) && lock(&self.schedule).announce_due(now)
556    }
557
558    /// Reads datagrams until the client is closing.
559    fn receive_loop(&self) {
560        let mut buf = vec![0u8; RECV_BUFFER];
561        while !self.closing.load(Ordering::SeqCst) {
562            let Some(conn) = lock(&self.tx).conn() else {
563                break;
564            };
565            match conn.recv(&mut buf) {
566                Ok(Some((data, from))) => self.process_datagram(data, from),
567                Ok(None) => {}
568                Err(_) => break,
569            }
570        }
571    }
572
573    /// Re-examines the network once a second: liveness, the announcement timer
574    /// and whether anything still owes us its configuration.
575    fn probe_loop(&self) {
576        while self.sleep_until_next_tick() {
577            let now = Instant::now();
578            let want_discover = self.mutate(|state, ev| {
579                let mut incomplete = false;
580                let mut any_complete = false;
581                for device in state.devices.values_mut() {
582                    device.check_online(now, ev);
583                    if device.configuration_complete() {
584                        any_complete = true;
585                    } else if now.saturating_duration_since(device.hello_received) > CONFIG_GRACE {
586                        // Past the grace period a device has said nothing more,
587                        // so ask the network again rather than wait forever.
588                        incomplete = true;
589                    }
590                }
591                // Nothing has finished describing itself, so nothing has been
592                // discovered yet at all.
593                incomplete || !any_complete
594            });
595
596            let discover_due = lock(&self.schedule).discover_due(now);
597            if self.announce_due(now) {
598                self.announce();
599            }
600            if want_discover && discover_due {
601                let _ = self.discover();
602            }
603        }
604    }
605}
606
607/// Takes a lock, continuing through a poisoning.
608///
609/// A poisoned lock here means a panic somewhere that held it. The state behind
610/// it is a cache of what devices have reported and is rebuilt by the next
611/// frame from each, so refusing to touch it again would retire a working
612/// client over one bad datagram.
613fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
614    m.lock().unwrap_or_else(|e| e.into_inner())
615}
616
617/// Loads the client's identifier, generating and storing one on first run.
618///
619/// A generated identifier that cannot be stored is still used: a client with a
620/// new identity each run is worse than one that works today, and the failure is
621/// the caller's home directory, not the network.
622fn load_uid(path: Option<PathBuf>) -> io::Result<DeviceUid> {
623    let path = path.or_else(|| {
624        std::env::var_os("HOME")
625            .or_else(|| std::env::var_os("USERPROFILE"))
626            .map(|home| PathBuf::from(home).join(UID_FILE))
627    });
628    if let Some(path) = &path {
629        if let Ok(bytes) = std::fs::read(path) {
630            if let Ok(array) = <[u8; 16]>::try_from(bytes.get(..16).unwrap_or_default()) {
631                return Ok(DeviceUid::from_array(array));
632            }
633        }
634    }
635    let mut bytes = [0u8; 16];
636    getrandom::getrandom(&mut bytes).map_err(|e| io::Error::other(e.to_string()))?;
637    if let Some(path) = &path {
638        let _ = std::fs::write(path, bytes);
639    }
640    Ok(DeviceUid::from_array(bytes))
641}
642
643/// The protocol floor is checked against what the device says it can decode.
644impl crate::wire::ProtocolTarget for Device {
645    fn serial(&self) -> &str {
646        Device::serial(self)
647    }
648
649    fn supported_protocol(&self) -> u16 {
650        self.hello.supported_protocol
651    }
652}