Skip to main content

openlogi_device/replay/
backend.rs

1use std::collections::{HashMap, HashSet};
2use std::pin::Pin;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex, PoisonError, Weak};
5
6use hidpp::channel::HidppChannel;
7use openlogi_fixture::{HidCassette, ReportSupport, RequestMatch};
8use tokio::sync::mpsc;
9
10use crate::backend::{BackendError, HidBackend, HotplugEvent, HotplugStream, NodeId, NodeInfo};
11
12use super::ReplayError;
13use super::barrier::{ReplayResponseBarrier, ResponseGates};
14use super::channel::{
15    CassetteState, ReplayCompletion, ReplayRawHidChannel, ReplayRawWriter, ReplayRawWriterHandle,
16};
17
18/// Whether an OS HID node appears in enumeration snapshots.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum NodePresence {
21    /// The host currently enumerates the node.
22    Present,
23    /// The node is absent even if a previous channel lifetime still exists.
24    Absent,
25}
26
27/// Outcome of asking the backend to open one present node as HID++.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum OpenOutcome {
30    /// Open a channel over the node's logical cassette.
31    Hidpp,
32    /// The node opens but does not expose HID++ reports.
33    NotHidpp,
34    /// Opening is refused, independently of node presence.
35    Denied,
36    /// Opening reports that the node disappeared.
37    Disconnected,
38}
39
40/// Whether one logical HID channel accepts traffic.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum ChannelConnection {
43    /// New channel lifetimes start connected and accept writes.
44    Connected,
45    /// Current channel lifetimes reject writes and report disconnected.
46    Disconnected,
47}
48
49/// Whether a node can be opened as a bare output-report sink.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum RawWriterAvailability {
52    /// Return a writer that captures successful reports.
53    Capture,
54    /// Reject attempts to open a raw writer.
55    Unavailable,
56}
57
58/// Link liveness for a paired receiver slot.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum ReceiverLinkState {
61    /// The paired device currently answers through the receiver.
62    Online,
63    /// The pairing remains, but the device link is asleep or unavailable.
64    Offline,
65}
66
67/// Pairing and link state of one receiver slot.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum ReceiverSlotState {
70    /// The receiver has no pairing in this slot.
71    Empty,
72    /// The slot is paired; link liveness remains an independent value.
73    Paired(ReceiverLinkState),
74}
75
76/// Mutable state of one receiver pairing slot.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub struct ReceiverSlot {
79    /// Receiver-assigned slot number.
80    pub slot: u8,
81    /// Pairing and link state.
82    pub state: ReceiverSlotState,
83}
84
85/// One logical replay channel and the cassette attached to it.
86#[derive(Clone, Debug)]
87pub struct ReplayChannel {
88    /// Stable channel identifier used by nodes and cassettes.
89    pub id: String,
90    /// Initial connection state for new channel lifetimes.
91    pub connection: ChannelConnection,
92    /// HID++ report widths exposed by the channel.
93    pub report_support: ReportSupport,
94}
95
96/// One virtual OS HID node.
97#[derive(Clone, Debug)]
98pub struct ReplayNode {
99    /// Sanitized host-facing node metadata.
100    pub info: NodeInfo,
101    /// Initial presence in enumeration snapshots.
102    pub presence: NodePresence,
103    /// Initial result of opening the node as HID++.
104    pub open_outcome: OpenOutcome,
105    /// Logical channel used when [`OpenOutcome::Hidpp`] is selected.
106    pub channel: Option<String>,
107    /// Whether raw output-report opens are available.
108    pub raw_writer: RawWriterAvailability,
109    /// Initial explicit receiver-slot contracts, shared by this logical channel.
110    ///
111    /// Unlisted slots are unspecified, not implicitly empty. Nodes sharing a
112    /// channel must agree on any overlapping declarations.
113    pub receiver_slots: Vec<ReceiverSlot>,
114}
115
116/// Initial virtual hardware snapshot used to construct a [`ReplayBackend`].
117#[derive(Clone, Debug)]
118pub struct ReplayTopology {
119    /// Virtual OS nodes.
120    pub nodes: Vec<ReplayNode>,
121    /// Logical channels shared by the routes that traverse them.
122    pub channels: Vec<ReplayChannel>,
123}
124
125struct NodeRuntime {
126    node: ReplayNode,
127    open_count: usize,
128    raw_written: Arc<Mutex<Vec<Vec<u8>>>>,
129    raw_connected: Arc<AtomicBool>,
130}
131
132struct ChannelRuntime {
133    report_support: ReportSupport,
134    connection: ChannelConnection,
135    cassette: Arc<CassetteState>,
136    response_gates: Arc<ResponseGates>,
137    written: Arc<Mutex<Vec<Vec<u8>>>>,
138    lifetimes: Vec<ChannelLifetime>,
139    open_count: usize,
140}
141
142struct ChannelLifetime {
143    connected: Weak<AtomicBool>,
144    incoming: mpsc::UnboundedSender<Vec<u8>>,
145}
146
147struct BackendState {
148    nodes: Vec<NodeRuntime>,
149    channels: HashMap<String, ChannelRuntime>,
150}
151
152/// A mutable virtual [`HidBackend`] backed by strict raw-HID cassettes.
153///
154/// Topology mutation and hotplug delivery are intentionally separate: tests
155/// may change one without the other to reproduce missed or stale host events.
156pub struct ReplayBackend {
157    state: Mutex<BackendState>,
158    watchers: Mutex<Vec<mpsc::UnboundedSender<HotplugEvent>>>,
159}
160
161impl ReplayBackend {
162    /// Validate and build a replay backend from topology plus named cassettes.
163    pub fn new(topology: ReplayTopology, cassettes: Vec<HidCassette>) -> Result<Self, ReplayError> {
164        let channels = build_channels(topology.channels, cassettes)?;
165        let nodes = build_nodes(topology.nodes, &channels)?;
166
167        Ok(Self {
168            state: Mutex::new(BackendState { nodes, channels }),
169            watchers: Mutex::new(Vec::new()),
170        })
171    }
172
173    /// Change whether `node` appears in future enumeration snapshots.
174    pub fn set_node_presence(
175        &self,
176        node: &NodeId,
177        presence: NodePresence,
178    ) -> Result<(), ReplayError> {
179        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
180        let runtime = find_node_mut(&mut state.nodes, node)?;
181        runtime.node.presence = presence;
182        Ok(())
183    }
184
185    /// Change the HID++ open result independently of node presence.
186    pub fn set_open_outcome(&self, node: &NodeId, outcome: OpenOutcome) -> Result<(), ReplayError> {
187        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
188        let runtime = find_node_mut(&mut state.nodes, node)?;
189        if outcome == OpenOutcome::Hidpp && runtime.node.channel.is_none() {
190            return Err(ReplayError::invalid(
191                "replay topology",
192                format!("node {node} has no logical channel"),
193            ));
194        }
195        runtime.node.open_outcome = outcome;
196        Ok(())
197    }
198
199    /// Change a logical channel's state.
200    ///
201    /// Disconnecting also marks all current lifetimes disconnected. Reconnecting
202    /// affects only subsequent opens, so a stale lifetime cannot revive.
203    pub fn set_channel_connection(
204        &self,
205        channel: &str,
206        connection: ChannelConnection,
207    ) -> Result<(), ReplayError> {
208        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
209        let runtime = state
210            .channels
211            .get_mut(channel)
212            .ok_or_else(|| ReplayError::UnknownChannel(channel.to_string()))?;
213        runtime.connection = connection;
214        runtime.lifetimes.retain(|lifetime| {
215            let Some(connection_flag) = lifetime.connected.upgrade() else {
216                return false;
217            };
218            if connection == ChannelConnection::Disconnected {
219                connection_flag.store(false, Ordering::SeqCst);
220            }
221            true
222        });
223        Ok(())
224    }
225
226    /// Hold the next matching cassette response behind an explicit barrier.
227    ///
228    /// The request still counts as written and consumes its cassette exchange.
229    /// Only response delivery waits for [`ReplayResponseBarrier::release`].
230    pub fn hold_next_response(
231        &self,
232        channel: &str,
233        request_match: RequestMatch,
234        request: &[u8],
235    ) -> Result<ReplayResponseBarrier, ReplayError> {
236        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
237        let runtime = state
238            .channels
239            .get(channel)
240            .ok_or_else(|| ReplayError::UnknownChannel(channel.to_string()))?;
241        runtime
242            .report_support
243            .validate_report(request)
244            .map_err(|error| ReplayError::invalid("replay response barrier", error.to_string()))?;
245        Ok(runtime.response_gates.hold(request_match, request))
246    }
247
248    /// Deliver an unsolicited report to every connected lifetime of `channel`.
249    ///
250    /// Returns the number of live channel lifetimes that received the report.
251    pub fn emit_channel_report(&self, channel: &str, report: &[u8]) -> Result<usize, ReplayError> {
252        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
253        let runtime = state
254            .channels
255            .get_mut(channel)
256            .ok_or_else(|| ReplayError::UnknownChannel(channel.to_string()))?;
257        runtime
258            .report_support
259            .validate_report(report)
260            .map_err(|error| ReplayError::invalid("replay channel report", error.to_string()))?;
261        runtime.cassette.validate_report(report)?;
262        let mut delivered = 0;
263        runtime.lifetimes.retain(|lifetime| {
264            let Some(connected) = lifetime.connected.upgrade() else {
265                return false;
266            };
267            if connected.load(Ordering::SeqCst) && lifetime.incoming.send(report.to_vec()).is_ok() {
268                delivered += 1;
269            }
270            true
271        });
272        Ok(delivered)
273    }
274
275    /// Number of current raw-channel lifetimes for one logical channel.
276    pub fn channel_lifetime_count(&self, channel: &str) -> Result<usize, ReplayError> {
277        let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
278        let runtime = state
279            .channels
280            .get_mut(channel)
281            .ok_or_else(|| ReplayError::UnknownChannel(channel.to_string()))?;
282        runtime
283            .lifetimes
284            .retain(|lifetime| lifetime.connected.upgrade().is_some());
285        Ok(runtime.lifetimes.len())
286    }
287
288    /// Change pairing/link state for one declared receiver slot on the node's channel.
289    ///
290    /// Subsequent cassette responses and unsolicited reports must agree with
291    /// this state. Contradictions fail without consuming the exchange and remain
292    /// in completion diagnostics. No firmware replies are synthesized. Responses
293    /// already accepted behind a barrier remain in flight on their original
294    /// lifetime, allowing stale-response/retirement scenarios.
295    pub fn set_receiver_slot_state(
296        &self,
297        node: &NodeId,
298        slot: u8,
299        slot_state: ReceiverSlotState,
300    ) -> Result<(), ReplayError> {
301        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
302        receiver_cassette(&state, node)?.set_slot(slot, slot_state)
303    }
304
305    /// Read the pairing/link state of one declared receiver slot.
306    pub fn receiver_slot_state(
307        &self,
308        node: &NodeId,
309        slot: u8,
310    ) -> Result<ReceiverSlotState, ReplayError> {
311        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
312        receiver_cassette(&state, node)?.slot(slot)
313    }
314
315    /// Deliver a hotplug event without implicitly changing topology.
316    pub fn emit_hotplug(&self, event: HotplugEvent) {
317        self.watchers
318            .lock()
319            .unwrap_or_else(PoisonError::into_inner)
320            .retain(|watcher| watcher.send(event).is_ok());
321    }
322
323    /// Number of HID++ open attempts made for `node`.
324    pub fn open_count(&self, node: &NodeId) -> Result<usize, ReplayError> {
325        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
326        state
327            .nodes
328            .iter()
329            .find(|runtime| runtime.node.info.id == *node)
330            .map(|runtime| runtime.open_count)
331            .ok_or_else(|| ReplayError::UnknownNode(node.to_string()))
332    }
333
334    /// Current completion report for one logical channel.
335    pub fn channel_completion(&self, channel: &str) -> Result<ReplayCompletion, ReplayError> {
336        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
337        let runtime = state
338            .channels
339            .get(channel)
340            .ok_or_else(|| ReplayError::UnknownChannel(channel.to_string()))?;
341        let written = runtime
342            .written
343            .lock()
344            .unwrap_or_else(PoisonError::into_inner)
345            .clone();
346        let mut completion = runtime.cassette.completion(written);
347        completion.channel_open_count = runtime.open_count;
348        Ok(completion)
349    }
350
351    /// Fail unless every logical channel consumed all required exchanges.
352    pub fn require_complete(&self) -> Result<(), ReplayError> {
353        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
354        for runtime in state.channels.values() {
355            let written = runtime
356                .written
357                .lock()
358                .unwrap_or_else(PoisonError::into_inner)
359                .clone();
360            runtime.cassette.completion(written).require_complete()?;
361        }
362        Ok(())
363    }
364
365    /// Inspection and connection handle for a node's raw writer.
366    pub fn raw_writer_handle(&self, node: &NodeId) -> Result<ReplayRawWriterHandle, ReplayError> {
367        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
368        let runtime = state
369            .nodes
370            .iter()
371            .find(|runtime| runtime.node.info.id == *node)
372            .ok_or_else(|| ReplayError::UnknownNode(node.to_string()))?;
373        Ok(ReplayRawWriterHandle::from_parts(
374            Arc::clone(&runtime.raw_written),
375            Arc::clone(&runtime.raw_connected),
376        ))
377    }
378}
379
380fn build_channels(
381    channels: Vec<ReplayChannel>,
382    cassettes: Vec<HidCassette>,
383) -> Result<HashMap<String, ChannelRuntime>, ReplayError> {
384    let mut cassette_by_channel = HashMap::new();
385    for cassette in cassettes {
386        cassette.validate()?;
387        let channel = cassette.channel.clone();
388        if cassette_by_channel
389            .insert(channel.clone(), cassette)
390            .is_some()
391        {
392            return Err(ReplayError::invalid(
393                "replay topology",
394                format!("multiple cassettes name channel {channel}"),
395            ));
396        }
397    }
398
399    let mut runtimes = HashMap::new();
400    for channel in channels {
401        if channel.id.trim().is_empty() {
402            return Err(ReplayError::invalid(
403                "replay topology",
404                "channel id must not be empty",
405            ));
406        }
407        let Some(cassette) = cassette_by_channel.remove(&channel.id) else {
408            return Err(ReplayError::invalid(
409                "replay topology",
410                format!("channel {} has no cassette", channel.id),
411            ));
412        };
413        if cassette.report_support != channel.report_support {
414            return Err(ReplayError::invalid(
415                "replay topology",
416                format!(
417                    "channel {} and its cassette disagree on report support",
418                    channel.id
419                ),
420            ));
421        }
422        if runtimes
423            .insert(
424                channel.id.clone(),
425                ChannelRuntime {
426                    report_support: channel.report_support,
427                    connection: channel.connection,
428                    cassette: CassetteState::new(cassette)?,
429                    response_gates: Arc::new(ResponseGates::default()),
430                    written: Arc::new(Mutex::new(Vec::new())),
431                    lifetimes: Vec::new(),
432                    open_count: 0,
433                },
434            )
435            .is_some()
436        {
437            return Err(ReplayError::invalid(
438                "replay topology",
439                format!("duplicate channel {}", channel.id),
440            ));
441        }
442    }
443    if let Some(extra) = cassette_by_channel.keys().next() {
444        return Err(ReplayError::invalid(
445            "replay topology",
446            format!("cassette references unknown channel {extra}"),
447        ));
448    }
449    Ok(runtimes)
450}
451
452fn build_nodes(
453    nodes: Vec<ReplayNode>,
454    channels: &HashMap<String, ChannelRuntime>,
455) -> Result<Vec<NodeRuntime>, ReplayError> {
456    let mut node_ids = HashSet::new();
457    let mut runtimes = Vec::new();
458    for mut node in nodes {
459        if !node_ids.insert(node.info.id.clone()) {
460            return Err(ReplayError::invalid(
461                "replay topology",
462                format!("duplicate node {}", node.info.id),
463            ));
464        }
465        if node.open_outcome == OpenOutcome::Hidpp && node.channel.is_none() {
466            return Err(ReplayError::invalid(
467                "replay topology",
468                format!("HID++ node {} has no logical channel", node.info.id),
469            ));
470        }
471        if let Some(channel) = &node.channel
472            && !channels.contains_key(channel)
473        {
474            return Err(ReplayError::invalid(
475                "replay topology",
476                format!("node {} references unknown channel {channel}", node.info.id),
477            ));
478        }
479        validate_receiver_slots(&node)?;
480        if !node.receiver_slots.is_empty() {
481            let channel = node
482                .channel
483                .as_ref()
484                .and_then(|id| channels.get(id))
485                .ok_or_else(|| {
486                    ReplayError::invalid(
487                        "replay topology",
488                        format!(
489                            "node {} declares receiver slots without a channel",
490                            node.info.id
491                        ),
492                    )
493                })?;
494            // Move declarations into the cassette lock: matching and topology
495            // transitions must consult one owner across every channel lifetime.
496            channel
497                .cassette
498                .declare_slots(std::mem::take(&mut node.receiver_slots))?;
499        }
500        runtimes.push(NodeRuntime {
501            node,
502            open_count: 0,
503            raw_written: Arc::new(Mutex::new(Vec::new())),
504            raw_connected: Arc::new(AtomicBool::new(true)),
505        });
506    }
507    Ok(runtimes)
508}
509
510fn validate_receiver_slots(node: &ReplayNode) -> Result<(), ReplayError> {
511    let mut slots = HashSet::new();
512    for slot in &node.receiver_slots {
513        if !(1..=6).contains(&slot.slot) {
514            return Err(ReplayError::invalid(
515                "replay topology",
516                format!(
517                    "node {} has invalid receiver slot {}",
518                    node.info.id, slot.slot
519                ),
520            ));
521        }
522        if !slots.insert(slot.slot) {
523            return Err(ReplayError::invalid(
524                "replay topology",
525                format!("node {} repeats receiver slot {}", node.info.id, slot.slot),
526            ));
527        }
528    }
529    Ok(())
530}
531
532fn receiver_cassette<'a>(
533    state: &'a BackendState,
534    node: &NodeId,
535) -> Result<&'a CassetteState, ReplayError> {
536    let runtime = state
537        .nodes
538        .iter()
539        .find(|runtime| runtime.node.info.id == *node)
540        .ok_or_else(|| ReplayError::UnknownNode(node.to_string()))?;
541    runtime
542        .node
543        .channel
544        .as_ref()
545        .and_then(|id| state.channels.get(id))
546        .map(|channel| channel.cassette.as_ref())
547        .ok_or_else(|| {
548            ReplayError::invalid(
549                "replay topology",
550                format!("node {node} has no receiver channel"),
551            )
552        })
553}
554
555fn find_node_mut<'a>(
556    nodes: &'a mut [NodeRuntime],
557    node: &NodeId,
558) -> Result<&'a mut NodeRuntime, ReplayError> {
559    nodes
560        .iter_mut()
561        .find(|runtime| runtime.node.info.id == *node)
562        .ok_or_else(|| ReplayError::UnknownNode(node.to_string()))
563}
564
565#[hidpp::async_trait]
566impl HidBackend for ReplayBackend {
567    async fn enumerate(&self) -> Result<Vec<NodeInfo>, BackendError> {
568        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
569        Ok(state
570            .nodes
571            .iter()
572            .filter(|runtime| runtime.node.presence == NodePresence::Present)
573            .map(|runtime| runtime.node.info.clone())
574            .collect())
575    }
576
577    async fn enumerate_hidpp(&self) -> Result<Vec<NodeInfo>, BackendError> {
578        self.enumerate().await
579    }
580
581    async fn open_hidpp(&self, node: &NodeInfo) -> Result<Option<Arc<HidppChannel>>, BackendError> {
582        let raw = {
583            let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
584            let Some(index) = state
585                .nodes
586                .iter()
587                .position(|runtime| runtime.node.info.id == node.id)
588            else {
589                return Err(BackendError::Disconnected);
590            };
591            if state.nodes[index].node.presence == NodePresence::Absent {
592                return Err(BackendError::Disconnected);
593            }
594            state.nodes[index].open_count += 1;
595            match state.nodes[index].node.open_outcome {
596                OpenOutcome::NotHidpp => return Ok(None),
597                OpenOutcome::Denied => {
598                    return Err(BackendError::Backend("replay HID open denied".to_string()));
599                }
600                OpenOutcome::Disconnected => return Err(BackendError::Disconnected),
601                OpenOutcome::Hidpp => {}
602            }
603            let channel_id =
604                state.nodes[index].node.channel.clone().ok_or_else(|| {
605                    BackendError::Backend("replay node has no channel".to_string())
606                })?;
607            let vendor_id = state.nodes[index].node.info.vendor_id;
608            let product_id = state.nodes[index].node.info.product_id;
609            let channel = state.channels.get_mut(&channel_id).ok_or_else(|| {
610                BackendError::Backend(format!("replay channel {channel_id} is missing"))
611            })?;
612            channel.open_count += 1;
613            let connected = Arc::new(AtomicBool::new(
614                channel.connection == ChannelConnection::Connected,
615            ));
616            let raw = ReplayRawHidChannel::from_parts(
617                vendor_id,
618                product_id,
619                channel.report_support,
620                Arc::clone(&channel.cassette),
621                Arc::clone(&channel.written),
622                Arc::clone(&connected),
623                Arc::clone(&channel.response_gates),
624            );
625            channel.lifetimes.push(ChannelLifetime {
626                connected: Arc::downgrade(&connected),
627                incoming: raw.incoming_sender(),
628            });
629            raw
630        };
631        HidppChannel::from_raw_channel(raw)
632            .await
633            .map(Arc::new)
634            .map(Some)
635            .map_err(|error| BackendError::Backend(error.to_string()))
636    }
637
638    async fn open_raw_writer(
639        &self,
640        node: &NodeInfo,
641    ) -> Result<Box<dyn crate::backend::RawWriter>, BackendError> {
642        let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
643        let runtime = state
644            .nodes
645            .iter()
646            .find(|runtime| runtime.node.info.id == node.id)
647            .ok_or(BackendError::Disconnected)?;
648        if runtime.node.presence == NodePresence::Absent {
649            return Err(BackendError::Disconnected);
650        }
651        if runtime.node.raw_writer == RawWriterAvailability::Unavailable {
652            return Err(BackendError::Backend(
653                "replay node has no raw writer".to_string(),
654            ));
655        }
656        Ok(Box::new(ReplayRawWriter::from_parts(
657            Arc::clone(&runtime.raw_written),
658            Arc::clone(&runtime.raw_connected),
659        )))
660    }
661
662    fn watch(&self) -> Result<HotplugStream, BackendError> {
663        let (sender, mut receiver) = mpsc::unbounded_channel();
664        self.watchers
665            .lock()
666            .unwrap_or_else(PoisonError::into_inner)
667            .push(sender);
668        Ok(Box::new(futures_lite::stream::poll_fn(move |context| {
669            Pin::new(&mut receiver).poll_recv(context)
670        })))
671    }
672}