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