Skip to main content

mx_remote/
event.rs

1// Author: Lars Op den Kamp (lars@opdenkamp-it.nl)
2// Copyright (c) 2026 Op den Kamp IT Solutions
3
4//! Events and the handler that receives them.
5
6use crate::types::*;
7use crate::wire::{
8    BayUid, DeviceUid, EdidProfile, LinkFeature, RcAction, RcKey, RcType, V2ipFpgaFeature,
9};
10
11/// Declares the event set.
12///
13/// One declaration produces the [`Event`] enum, the [`EventHandler`] trait and
14/// the dispatch that connects them, so a new event cannot reach the enum
15/// without reaching the trait, and cannot be dispatched without fanning in to
16/// the generic update. The fan-in is written once here rather than repeated at
17/// each call site.
18///
19/// The section an event is declared in decides what it fans in to: `device`
20/// events reach `on_device_update`, `bay` events reach `on_bay_update`, and
21/// `bay_and_device` events reach both. A link change concerns the bay and the
22/// device that owns it, which is why it is neither of the first two.
23macro_rules! events {
24    (
25        device {
26            $( $(#[$dmeta:meta])* $dvariant:ident => $dmethod:ident ( $($darg:ident : $dty:ty),* ); )*
27        }
28        bay {
29            $( $(#[$bmeta:meta])* $bvariant:ident => $bmethod:ident ( $($barg:ident : $bty:ty),* ); )*
30        }
31        bay_and_device {
32            $( $(#[$lmeta:meta])* $lvariant:ident => $lmethod:ident ( $($larg:ident : $lty:ty),* ); )*
33        }
34    ) => {
35        /// Something that changed, or a request that arrived.
36        ///
37        /// Events are collected while the state lock is held and dispatched
38        /// after it is released, so a handler may call back into the library.
39        #[derive(Clone, Debug, PartialEq)]
40        #[non_exhaustive]
41        pub enum Event {
42            $(
43                $(#[$dmeta])*
44                $dvariant {
45                    /// The device the event concerns.
46                    device: DeviceUid,
47                    $( #[allow(missing_docs)] $darg: $dty, )*
48                },
49            )*
50            $(
51                $(#[$bmeta])*
52                $bvariant {
53                    /// The bay the event concerns.
54                    bay: BayUid,
55                    $( #[allow(missing_docs)] $barg: $bty, )*
56                },
57            )*
58            $(
59                $(#[$lmeta])*
60                $lvariant {
61                    /// The bay the event concerns.
62                    bay: BayUid,
63                    $( #[allow(missing_docs)] $larg: $lty, )*
64                },
65            )*
66        }
67
68        /// Receives events.
69        ///
70        /// Every method has a no-op default, so an implementation names only
71        /// the events it cares about. Handlers run one at a time from the
72        /// thread that produced the event, with no lock held: calling back into
73        /// the library from one is safe, but blocking for long stalls the
74        /// receive path.
75        #[allow(unused_variables)]
76        pub trait EventHandler: Send + Sync {
77            $(
78                $(#[$dmeta])*
79                fn $dmethod(&self, device: DeviceUid $(, $darg: $dty)*) {}
80            )*
81            $(
82                $(#[$bmeta])*
83                fn $bmethod(&self, bay: BayUid $(, $barg: $bty)*) {}
84            )*
85            $(
86                $(#[$lmeta])*
87                fn $lmethod(&self, bay: BayUid $(, $larg: $lty)*) {}
88            )*
89
90            /// Fired after every device-level event above.
91            fn on_device_update(&self, device: DeviceUid) {}
92
93            /// Fired after every bay-level event above.
94            fn on_bay_update(&self, bay: BayUid) {}
95        }
96
97        impl Event {
98            /// The bay a bay-level event concerns.
99            fn bay(&self) -> Option<BayUid> {
100                match self {
101                    $( Self::$bvariant { bay, .. } => Some(*bay), )*
102                    _ => None,
103                }
104            }
105
106            /// This bay-level event about another bay.
107            pub(crate) fn for_bay(&self, to: BayUid) -> Self {
108                let mut event = self.clone();
109                match &mut event {
110                    $( Self::$bvariant { bay, .. } => *bay = to, )*
111                    _ => {}
112                }
113                event
114            }
115
116            /// Delivers this event to `handler`, then the generic update it
117            /// fans in to.
118            pub(crate) fn dispatch(self, handler: &dyn EventHandler) {
119                match self {
120                    $(
121                        Self::$dvariant { device $(, $darg)* } => {
122                            handler.$dmethod(device $(, $darg)*);
123                            handler.on_device_update(device);
124                        }
125                    )*
126                    $(
127                        Self::$bvariant { bay $(, $barg)* } => {
128                            handler.$bmethod(bay $(, $barg)*);
129                            handler.on_bay_update(bay);
130                        }
131                    )*
132                    $(
133                        Self::$lvariant { bay $(, $larg)* } => {
134                            handler.$lmethod(bay $(, $larg)*);
135                            handler.on_bay_update(bay);
136                            handler.on_device_update(bay.device);
137                        }
138                    )*
139                }
140            }
141        }
142    };
143}
144
145impl Event {
146    /// The bay whose state this event says changed, for a change the mesh
147    /// bays standing for that bay share. A bay's registration and the
148    /// requests that arrive on it concern that bay alone.
149    pub(crate) fn mirrored_bay(&self) -> Option<BayUid> {
150        match self {
151            Self::BayRegistered { .. }
152            | Self::KeyPressed { .. }
153            | Self::ActionReceived { .. }
154            | Self::VolumeStep { .. }
155            | Self::AudioClipped { .. }
156            | Self::IrCaptured { .. } => None,
157            _ => self.bay(),
158        }
159    }
160}
161
162/// Ignores every event.
163///
164/// The handler a client that only reads state through [`Remote`] needs, since
165/// the trait is not optional and its methods all default to nothing.
166///
167/// [`Remote`]: crate::Remote
168impl EventHandler for () {}
169
170events! {
171    device {
172        /// The device's configuration changed.
173        DeviceConfigChanged => on_device_config_changed();
174        /// The device has reported every part of its configuration.
175        DeviceConfigComplete => on_device_config_complete();
176        /// The device started or stopped answering.
177        DeviceOnlineChanged => on_device_online_changed(online: bool);
178        /// The device reported new temperatures.
179        DeviceTemperatureChanged => on_device_temperature_changed(temperatures: Vec<i8>);
180        /// A firmware component reported its version.
181        FirmwareVersionChanged => on_firmware_version_changed(version: FirmwareVersion);
182        /// The device reported a system status.
183        SystemStatusChanged => on_system_status_changed(status: u16, message: String);
184        /// A network port reported its link state.
185        NetworkStatusChanged => on_network_status_changed(status: NetworkPortStatus);
186        /// The device reported V2IP statistics.
187        V2ipStatsChanged => on_v2ip_stats_changed(stats: V2ipDeviceStats);
188        /// The streams the device's source bays advertise changed.
189        V2ipSourcesChanged => on_v2ip_sources_changed(sources: Vec<V2ipStreamSources>);
190        /// The device's V2IP encoder configuration changed.
191        V2ipDetailsChanged => on_v2ip_details_changed(details: DeviceV2ipDetails);
192        /// The streams the device's sink is subscribed to changed. A request
193        /// addressed to it fires this too - see [`DeviceV2ipSink`].
194        V2ipSinkChanged => on_v2ip_sink_changed(sink: DeviceV2ipSink);
195        /// A multiviewer reported its state.
196        MultiviewerStatusChanged => on_multiviewer_status_changed(status: MultiviewerStatus);
197        /// The device reported a different audio endpoint tree, or an
198        /// endpoint's status changed.
199        AudioEndpointsChanged => on_audio_endpoints_changed(endpoints: AudioEndpoints);
200        /// The device reported its mesh master.
201        MeshMasterChanged => on_mesh_master_changed(master: DeviceUid);
202        /// The device reported its view of the mesh topology.
203        TopologyChanged => on_topology_changed(topology: Vec<TopologyEntry>);
204        /// A ProAmp8 reported its Dolby settings.
205        AmpDolbySettingsChanged => on_amp_dolby_settings_changed(settings: AmpDolbySettings);
206        /// Installer setup was completed or cleared.
207        SetupStatusChanged => on_setup_status_changed(completed: bool);
208        /// The installer id changed.
209        InstallerIdChanged => on_installer_id_changed(installer_id: u16);
210        /// The sink was told to show a window.
211        TilingChanged => on_tiling_changed(tiling: V2ipTilingConfig);
212        /// The device reported what its video processor supports.
213        V2ipFeaturesChanged => on_v2ip_features_changed(features: V2ipFpgaFeature);
214        /// The device's settings changed.
215        V2ipDeviceSettingsChanged => on_v2ip_device_settings_changed(settings: V2ipDeviceSettings);
216        /// The device reported a different VLAN configuration.
217        V2ipVlanChanged => on_v2ip_vlan_changed(vlan: V2ipVlan);
218        /// The output reported something different about what it plays from
219        /// a session description.
220        V2ipSinkSdpChanged => on_v2ip_sink_sdp_changed(sdp: V2ipSinkSdp);
221        /// The source unit reported its streams encrypted differently.
222        V2ipPrivacyChanged => on_v2ip_privacy_changed(privacy: V2ipPrivacy);
223        /// The management unit sent a new overlay sync.
224        OsdSyncChanged => on_osd_sync_changed(sync: OsdSync);
225        /// The unit reported something different about the overlay sync it
226        /// takes.
227        OsdSyncReportChanged => on_osd_sync_report_changed(report: OsdSyncReport);
228        /// The unit asked for an overlay sync, or for settings of one.
229        OsdSyncRequested => on_osd_sync_requested(request: OsdSyncRequest);
230        /// The management unit sent one setting of an overlay sync to a unit.
231        OsdSyncSettingReceived => on_osd_sync_setting_received(setting: OsdSyncSetting);
232        /// The unit reported a different status.
233        UnitStatusChanged => on_unit_status_changed(status: UnitStatus);
234        /// The device reported a different PTP state, or stopped reporting
235        /// one.
236        V2ipPtpChanged => on_v2ip_ptp_changed(ptp: Option<V2ipPtpState>);
237        /// The sink reported its test card.
238        V2ipTestcardChanged => on_v2ip_testcard_changed(testcard: V2ipTestcard);
239        /// The device announced a different time zone for its mesh.
240        TimeZoneChanged => on_time_zone_changed(time_zone: TimeZone);
241        /// A source bay's remote-control configuration changed.
242        RcSettingsChanged => on_rc_settings_changed(settings: RcSettings);
243        /// A V2IP device was linked to a remote peer.
244        V2ipLinkChanged => on_v2ip_link_changed(target: DeviceUid);
245        /// A multiviewer command arrived.
246        MultiviewerCommand => on_multiviewer_command(command: MultiviewerCommand);
247        /// Management asked the device to route a source endpoint to one of
248        /// its sink endpoints. Its next audio report says what it did.
249        AudioSelectInput => on_audio_select_input(change: AudioChangeSource);
250        /// Management asked the device to mute or unmute one of its audio
251        /// endpoints.
252        AudioEndpointMute => on_audio_endpoint_mute(endpoint: u16, muted: bool);
253        /// Management asked the device to set one of its endpoints' trigger.
254        AudioEndpointTrigger => on_audio_endpoint_trigger(endpoint: u16, active: bool);
255        /// Management asked the device to lock or unlock one of its endpoints'
256        /// audio source.
257        AudioEndpointLock => on_audio_endpoint_lock(endpoint: u16, locked: bool);
258        /// Management asked the device to set one of its endpoints' volume,
259        /// which no unit acts on.
260        AudioEndpointVolume => on_audio_endpoint_volume(endpoint: u16, volume: u32);
261        /// A peer asked this client, or every device, to announce itself.
262        DiscoverRequest => on_discover_request();
263        /// A peer asked a device to switch a sink.
264        SetRouteRequested => on_set_route_requested(request: SetRouteRequest);
265        /// A peer asked a device for its EDID.
266        EdidRequested => on_edid_requested(request: EdidRequest);
267        /// A device answered with its EDID.
268        EdidReceived => on_edid_received(edid: EdidRecord);
269        /// A peer asked a device to rename a bay.
270        BayNameChangeRequested => on_bay_name_change_requested(change: BayNameChange);
271        /// A peer asked a device to switch its EDID profile.
272        EdidProfileChangeRequested => on_edid_profile_change_requested(change: EdidProfileChange);
273        /// A peer asked a device to reboot.
274        RebootRequested => on_reboot_requested(request: RebootRequest);
275        /// A peer sent a command to one unit.
276        UnitCommandRequested => on_unit_command_requested(command: UnitCommand);
277        /// A peer asked devices to factory-reset.
278        FactoryResetRequested => on_factory_reset_requested(request: FactoryResetRequest);
279        /// A device told every unit to check for new firmware now.
280        MonitoringPulse => on_monitoring_pulse();
281        /// A peer asked a device to upgrade its FPGA.
282        UpgradeFpgaRequested => on_upgrade_fpga_requested();
283        /// A peer asked a device to re-detect its bays.
284        DetectBaysRequested => on_detect_bays_requested();
285        /// A peer asked a sink to enter or leave power save.
286        PowerSaveRequested => on_power_save_requested(request: V2ipPowerSaveRequest);
287        /// A peer asked a device to send a remote-control key.
288        KeyTransmitRequested => on_key_transmit_requested(request: KeyTransmitRequest);
289        /// A peer asked a device to perform a remote-control action.
290        ActionTransmitRequested => on_action_transmit_requested(request: ActionTransmitRequest);
291        /// A peer asked a device to blast raw infrared.
292        IrTransmitRequested => on_ir_transmit_requested(request: IrTransmitRequest);
293        /// Multicast groups were added to or removed from the list automatic
294        /// address assignment avoids.
295        BlacklistChanged => on_blacklist_changed(change: V2ipBlacklistChange);
296        /// A video wall command arrived.
297        VideoWallCommand => on_video_wall_command(command: VideoWallCommand);
298    }
299    bay {
300        /// A bay was seen for the first time.
301        BayRegistered => on_bay_registered();
302        /// The bay's routed video source changed.
303        VideoSourceChanged => on_video_source_changed(source: Option<BayUid>);
304        /// The bay's routed audio source changed.
305        AudioSourceChanged => on_audio_source_changed(source: Option<BayUid>);
306        /// The bay's volume or mute state changed.
307        VolumeChanged => on_volume_changed(volume: VolumeMuteStatus);
308        /// The attached device's power state changed.
309        PowerChanged => on_power_changed(power: PowerStatus);
310        /// The bay was renamed.
311        NameChanged => on_name_changed(name: String);
312        /// A signal appeared or disappeared.
313        SignalDetectedChanged => on_signal_detected_changed(detected: bool);
314        /// The bay started or stopped reporting a fault.
315        FaultyChanged => on_faulty_changed(faulty: bool);
316        /// The bay was hidden or shown.
317        HiddenChanged => on_hidden_changed(hidden: bool);
318        /// The amplifier zone the bay drives was powered on or off.
319        ZonePoweredChanged => on_zone_powered_changed(powered: bool);
320        /// The HDBaseT link came up or went down.
321        HdbtConnectedChanged => on_hdbt_connected_changed(connected: bool);
322        /// The signal format description changed.
323        SignalTypeChanged => on_signal_type_changed(signal_type: String);
324        /// Hot-plug detect was asserted or released.
325        HpdDetectedChanged => on_hpd_detected_changed(detected: bool);
326        /// A CEC device answered or stopped answering.
327        CecDetectedChanged => on_cec_detected_changed(detected: bool);
328        /// The audio return channel changed.
329        ArcChanged => on_arc_changed(arc: ArcStatus);
330        /// The input's EDID profile changed.
331        EdidProfileChanged => on_edid_profile_changed(profile: EdidProfile);
332        /// The input's remote-control type changed.
333        RcTypeChanged => on_rc_type_changed(rc_type: RcType);
334        /// A remote-control key was pressed on the bay.
335        KeyPressed => on_key_pressed(key: RcKey);
336        /// A remote-control action was received on the bay.
337        ActionReceived => on_action_received(action: RcAction);
338        /// The bay started or stopped mirroring another output.
339        MirrorStatusChanged => on_mirror_status_changed(mirror: BayMirrorStatus);
340        /// A ProAmp8 zone's settings changed.
341        AmpZoneSettingsChanged => on_amp_zone_settings_changed(settings: AmpZoneSettings);
342        /// A volume step was requested on the bay.
343        VolumeStep => on_volume_step(up: bool);
344        /// The bay detected audio clipping.
345        AudioClipped => on_audio_clip(clip: AudioClip);
346        /// Raw infrared was captured on the bay.
347        IrCaptured => on_ir_captured(capture: IrCapture);
348        /// The devices filtered out of this sink's picker changed.
349        FilteredDevicesChanged => on_filtered_devices_changed(filtered: Vec<DeviceUid>);
350        /// The audio endpoint the bay carries changed.
351        AudioEndpointChanged => on_audio_endpoint_changed(endpoint: u8);
352        /// The bay's V2IP encoder was enabled or disabled.
353        EncoderDisabledChanged => on_encoder_disabled_changed(disabled: bool);
354        /// The bay's V2IP decoder was enabled or disabled.
355        DecoderDisabledChanged => on_decoder_disabled_changed(disabled: bool);
356        /// The input's source reported different content, empty when it
357        /// shows none.
358        ContentChanged => on_content_changed(content: Vec<BayContentField>);
359    }
360    bay_and_device {
361        /// The bay was linked to a bay on another device.
362        ///
363        /// `linked_serial` is the serial of the device at the other end of the
364        /// link, and `bay_name` the name of the bay whose link record changed:
365        /// this bay on the device that reported the change, and the far bay on
366        /// its peer. Both ends are told, so both fire.
367        BayLinked => on_bay_linked(linked_serial: String, bay_name: String, features: LinkFeature);
368        /// The bay's link to another device was removed.
369        ///
370        /// The arguments describe the link that was removed, and mean what
371        /// they do on [`Event::BayLinked`].
372        BayUnlinked => on_bay_unlinked(linked_serial: String, bay_name: String);
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::sync::Mutex;
380
381    #[derive(Default)]
382    struct Recorder {
383        calls: Mutex<Vec<String>>,
384    }
385
386    impl Recorder {
387        fn record(&self, what: &str) {
388            if let Ok(mut calls) = self.calls.lock() {
389                calls.push(what.to_owned());
390            }
391        }
392
393        fn calls(&self) -> Vec<String> {
394            self.calls.lock().map(|c| c.clone()).unwrap_or_default()
395        }
396    }
397
398    impl EventHandler for Recorder {
399        fn on_power_changed(&self, _bay: BayUid, power: PowerStatus) {
400            self.record(&format!("power={power}"));
401        }
402
403        fn on_setup_status_changed(&self, _device: DeviceUid, completed: bool) {
404            self.record(&format!("setup={completed}"));
405        }
406
407        fn on_bay_update(&self, _bay: BayUid) {
408            self.record("bay_update");
409        }
410
411        fn on_device_update(&self, _device: DeviceUid) {
412            self.record("device_update");
413        }
414    }
415
416    const DEVICE: DeviceUid = DeviceUid::from_array([9; 16]);
417
418    #[test]
419    fn a_bay_event_fires_its_own_method_then_the_generic_bay_update() {
420        let recorder = Recorder::default();
421        Event::PowerChanged {
422            bay: BayUid::new(DEVICE, 3),
423            power: PowerStatus::On,
424        }
425        .dispatch(&recorder);
426        assert_eq!(recorder.calls(), ["power=on", "bay_update"]);
427    }
428
429    #[test]
430    fn a_device_event_fires_its_own_method_then_the_generic_device_update() {
431        let recorder = Recorder::default();
432        Event::SetupStatusChanged {
433            device: DEVICE,
434            completed: true,
435        }
436        .dispatch(&recorder);
437        assert_eq!(recorder.calls(), ["setup=true", "device_update"]);
438    }
439
440    #[test]
441    fn a_link_event_fires_both_generic_updates() {
442        let recorder = Recorder::default();
443        Event::BayUnlinked {
444            bay: BayUid::new(DEVICE, 3),
445            linked_serial: "AB1234".to_owned(),
446            bay_name: "Output 1".to_owned(),
447        }
448        .dispatch(&recorder);
449        assert_eq!(recorder.calls(), ["bay_update", "device_update"]);
450    }
451
452    #[test]
453    fn an_event_a_handler_does_not_name_still_fires_the_generic_update() {
454        let recorder = Recorder::default();
455        Event::MonitoringPulse { device: DEVICE }.dispatch(&recorder);
456        assert_eq!(
457            recorder.calls(),
458            ["device_update"],
459            "the default no-op must not swallow the fan-in"
460        );
461    }
462}