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 OneIP 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 OneIP 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 output reported a different session description it plays, or
222 /// that it plays none.
223 V2ipSinkDescriptionChanged => on_v2ip_sink_description_changed(description: Option<String>);
224 /// The source unit reported its streams encrypted differently.
225 V2ipPrivacyChanged => on_v2ip_privacy_changed(privacy: V2ipPrivacy);
226 /// The management unit sent a new overlay sync.
227 OsdSyncChanged => on_osd_sync_changed(sync: OsdSync);
228 /// The unit reported something different about the overlay sync it
229 /// takes.
230 OsdSyncReportChanged => on_osd_sync_report_changed(report: OsdSyncReport);
231 /// The unit asked for an overlay sync, or for settings of one.
232 OsdSyncRequested => on_osd_sync_requested(request: OsdSyncRequest);
233 /// The management unit sent one setting of an overlay sync to a unit.
234 OsdSyncSettingReceived => on_osd_sync_setting_received(setting: OsdSyncSetting);
235 /// The unit reported something different about the message on its
236 /// output.
237 OsdMessageChanged => on_osd_message_changed(message: OsdMessage);
238 /// The unit reported a different status.
239 UnitStatusChanged => on_unit_status_changed(status: UnitStatus);
240 /// The device reported a different PTP state, or stopped reporting
241 /// one.
242 V2ipPtpChanged => on_v2ip_ptp_changed(ptp: Option<V2ipPtpState>);
243 /// The device reported a different downmix, or stopped reporting
244 /// one.
245 V2ipDownmixChanged => on_v2ip_downmix_changed(downmix: Option<V2ipDownmix>);
246 /// The sink reported its test card.
247 V2ipTestcardChanged => on_v2ip_testcard_changed(testcard: V2ipTestcard);
248 /// The device announced a different time zone for its mesh.
249 TimeZoneChanged => on_time_zone_changed(time_zone: TimeZone);
250 /// A source bay's remote-control configuration changed.
251 RcSettingsChanged => on_rc_settings_changed(settings: RcSettings);
252 /// A OneIP device was linked to a remote peer.
253 V2ipLinkChanged => on_v2ip_link_changed(target: DeviceUid);
254 /// A multiviewer command arrived.
255 MultiviewerCommand => on_multiviewer_command(command: MultiviewerCommand);
256 /// Management asked the device to route a source endpoint to one of
257 /// its sink endpoints. Its next audio report says what it did.
258 AudioSelectInput => on_audio_select_input(change: AudioChangeSource);
259 /// Management asked the device to mute or unmute one of its audio
260 /// endpoints.
261 AudioEndpointMute => on_audio_endpoint_mute(endpoint: u16, muted: bool);
262 /// Management asked the device to set one of its endpoints' trigger.
263 AudioEndpointTrigger => on_audio_endpoint_trigger(endpoint: u16, active: bool);
264 /// Management asked the device to lock or unlock one of its endpoints'
265 /// audio source.
266 AudioEndpointLock => on_audio_endpoint_lock(endpoint: u16, locked: bool);
267 /// Management asked the device to set one of its endpoints' volume,
268 /// which no unit acts on.
269 AudioEndpointVolume => on_audio_endpoint_volume(endpoint: u16, volume: u32);
270 /// A peer asked this client, or every device, to announce itself.
271 DiscoverRequest => on_discover_request();
272 /// A peer asked a device to switch a sink.
273 SetRouteRequested => on_set_route_requested(request: SetRouteRequest);
274 /// A peer asked a device for its EDID.
275 EdidRequested => on_edid_requested(request: EdidRequest);
276 /// A device answered with its EDID.
277 EdidReceived => on_edid_received(edid: EdidRecord);
278 /// A peer asked a device to rename a bay.
279 BayNameChangeRequested => on_bay_name_change_requested(change: BayNameChange);
280 /// A peer asked a device to switch its EDID profile.
281 EdidProfileChangeRequested => on_edid_profile_change_requested(change: EdidProfileChange);
282 /// A peer asked a device to reboot.
283 RebootRequested => on_reboot_requested(request: RebootRequest);
284 /// A peer sent a command to one unit.
285 UnitCommandRequested => on_unit_command_requested(command: UnitCommand);
286 /// A peer asked devices to factory-reset.
287 FactoryResetRequested => on_factory_reset_requested(request: FactoryResetRequest);
288 /// A device told every unit to check for new firmware now.
289 MonitoringPulse => on_monitoring_pulse();
290 /// A peer asked a device to upgrade its FPGA.
291 UpgradeFpgaRequested => on_upgrade_fpga_requested();
292 /// A peer asked a device to re-detect its bays.
293 DetectBaysRequested => on_detect_bays_requested();
294 /// A peer asked a sink to enter or leave power save.
295 PowerSaveRequested => on_power_save_requested(request: V2ipPowerSaveRequest);
296 /// A peer asked a device to send a remote-control key.
297 KeyTransmitRequested => on_key_transmit_requested(request: KeyTransmitRequest);
298 /// A peer asked a device to perform a remote-control action.
299 ActionTransmitRequested => on_action_transmit_requested(request: ActionTransmitRequest);
300 /// A peer asked a device to blast raw infrared.
301 IrTransmitRequested => on_ir_transmit_requested(request: IrTransmitRequest);
302 /// Multicast groups were added to or removed from the list automatic
303 /// address assignment avoids.
304 BlacklistChanged => on_blacklist_changed(change: V2ipBlacklistChange);
305 /// A video wall command arrived.
306 VideoWallCommand => on_video_wall_command(command: VideoWallCommand);
307 /// An application sent a message under its own vendor id. `device`
308 /// is the sender.
309 ///
310 /// A message from a sender this client has not yet heard announce
311 /// itself is dropped, as every frame is, and this client never
312 /// receives its own.
313 ThirdPartyMessage => on_third_party_message(message: ThirdPartyMessage);
314 }
315 bay {
316 /// A bay was seen for the first time.
317 BayRegistered => on_bay_registered();
318 /// The bay's routed video source changed.
319 VideoSourceChanged => on_video_source_changed(source: Option<BayUid>);
320 /// The bay's routed audio source changed.
321 AudioSourceChanged => on_audio_source_changed(source: Option<BayUid>);
322 /// The bay's volume or mute state changed.
323 VolumeChanged => on_volume_changed(volume: VolumeMuteStatus);
324 /// The attached device's power state changed.
325 PowerChanged => on_power_changed(power: PowerStatus);
326 /// The bay was renamed.
327 NameChanged => on_name_changed(name: String);
328 /// A signal appeared or disappeared.
329 SignalDetectedChanged => on_signal_detected_changed(detected: bool);
330 /// The bay started or stopped reporting a fault.
331 FaultyChanged => on_faulty_changed(faulty: bool);
332 /// The bay was hidden or shown.
333 HiddenChanged => on_hidden_changed(hidden: bool);
334 /// The amplifier zone the bay drives was powered on or off.
335 ZonePoweredChanged => on_zone_powered_changed(powered: bool);
336 /// The HDBaseT link came up or went down.
337 HdbtConnectedChanged => on_hdbt_connected_changed(connected: bool);
338 /// The signal format description changed.
339 SignalTypeChanged => on_signal_type_changed(signal_type: String);
340 /// Hot-plug detect was asserted or released.
341 HpdDetectedChanged => on_hpd_detected_changed(detected: bool);
342 /// A CEC device answered or stopped answering.
343 CecDetectedChanged => on_cec_detected_changed(detected: bool);
344 /// The audio return channel changed.
345 ArcChanged => on_arc_changed(arc: ArcStatus);
346 /// The input's EDID profile changed.
347 EdidProfileChanged => on_edid_profile_changed(profile: EdidProfile);
348 /// The input's remote-control type changed.
349 RcTypeChanged => on_rc_type_changed(rc_type: RcType);
350 /// A remote-control key was pressed on the bay.
351 KeyPressed => on_key_pressed(key: RcKey);
352 /// A remote-control action was received on the bay.
353 ActionReceived => on_action_received(action: RcAction);
354 /// The bay started or stopped mirroring another output.
355 MirrorStatusChanged => on_mirror_status_changed(mirror: BayMirrorStatus);
356 /// A ProAmp8 zone's settings changed.
357 AmpZoneSettingsChanged => on_amp_zone_settings_changed(settings: AmpZoneSettings);
358 /// A volume step was requested on the bay.
359 VolumeStep => on_volume_step(up: bool);
360 /// The bay detected audio clipping.
361 AudioClipped => on_audio_clip(clip: AudioClip);
362 /// Raw infrared was captured on the bay.
363 IrCaptured => on_ir_captured(capture: IrCapture);
364 /// The devices filtered out of this sink's picker changed.
365 FilteredDevicesChanged => on_filtered_devices_changed(filtered: Vec<DeviceUid>);
366 /// The audio endpoint the bay carries changed.
367 AudioEndpointChanged => on_audio_endpoint_changed(endpoint: u8);
368 /// The bay's OneIP encoder was enabled or disabled.
369 EncoderDisabledChanged => on_encoder_disabled_changed(disabled: bool);
370 /// The bay's OneIP decoder was enabled or disabled.
371 DecoderDisabledChanged => on_decoder_disabled_changed(disabled: bool);
372 /// The input's source reported different content, empty when it
373 /// shows none.
374 ContentChanged => on_content_changed(content: Vec<BayContentField>);
375 }
376 bay_and_device {
377 /// The bay was linked to a bay on another device.
378 ///
379 /// `linked_serial` is the serial of the device at the other end of the
380 /// link, and `bay_name` the name of the bay whose link record changed:
381 /// this bay on the device that reported the change, and the far bay on
382 /// its peer. Both ends are told, so both fire.
383 BayLinked => on_bay_linked(linked_serial: String, bay_name: String, features: LinkFeature);
384 /// The bay's link to another device was removed.
385 ///
386 /// The arguments describe the link that was removed, and mean what
387 /// they do on [`Event::BayLinked`].
388 BayUnlinked => on_bay_unlinked(linked_serial: String, bay_name: String);
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use std::sync::Mutex;
396
397 #[derive(Default)]
398 struct Recorder {
399 calls: Mutex<Vec<String>>,
400 }
401
402 impl Recorder {
403 fn record(&self, what: &str) {
404 if let Ok(mut calls) = self.calls.lock() {
405 calls.push(what.to_owned());
406 }
407 }
408
409 fn calls(&self) -> Vec<String> {
410 self.calls.lock().map(|c| c.clone()).unwrap_or_default()
411 }
412 }
413
414 impl EventHandler for Recorder {
415 fn on_power_changed(&self, _bay: BayUid, power: PowerStatus) {
416 self.record(&format!("power={power}"));
417 }
418
419 fn on_setup_status_changed(&self, _device: DeviceUid, completed: bool) {
420 self.record(&format!("setup={completed}"));
421 }
422
423 fn on_bay_update(&self, _bay: BayUid) {
424 self.record("bay_update");
425 }
426
427 fn on_device_update(&self, _device: DeviceUid) {
428 self.record("device_update");
429 }
430 }
431
432 const DEVICE: DeviceUid = DeviceUid::from_array([9; 16]);
433
434 #[test]
435 fn a_bay_event_fires_its_own_method_then_the_generic_bay_update() {
436 let recorder = Recorder::default();
437 Event::PowerChanged {
438 bay: BayUid::new(DEVICE, 3),
439 power: PowerStatus::On,
440 }
441 .dispatch(&recorder);
442 assert_eq!(recorder.calls(), ["power=on", "bay_update"]);
443 }
444
445 #[test]
446 fn a_device_event_fires_its_own_method_then_the_generic_device_update() {
447 let recorder = Recorder::default();
448 Event::SetupStatusChanged {
449 device: DEVICE,
450 completed: true,
451 }
452 .dispatch(&recorder);
453 assert_eq!(recorder.calls(), ["setup=true", "device_update"]);
454 }
455
456 #[test]
457 fn a_link_event_fires_both_generic_updates() {
458 let recorder = Recorder::default();
459 Event::BayUnlinked {
460 bay: BayUid::new(DEVICE, 3),
461 linked_serial: "AB1234".to_owned(),
462 bay_name: "Output 1".to_owned(),
463 }
464 .dispatch(&recorder);
465 assert_eq!(recorder.calls(), ["bay_update", "device_update"]);
466 }
467
468 #[test]
469 fn an_event_a_handler_does_not_name_still_fires_the_generic_update() {
470 let recorder = Recorder::default();
471 Event::MonitoringPulse { device: DEVICE }.dispatch(&recorder);
472 assert_eq!(
473 recorder.calls(),
474 ["device_update"],
475 "the default no-op must not swallow the fan-in"
476 );
477 }
478}