use std::{
collections::{HashMap, HashSet},
hash::Hash,
sync::Arc,
time::{Duration, Instant},
};
use futures_concurrency::future::Join as _;
use hidpp::channel::HidppChannel;
use openlogi_core::device::DeviceInventory;
use thiserror::Error;
use tracing::{debug, warn};
use crate::ChannelRegistry;
use crate::backend::{BackendError, HidBackend, NodeId, NodeInfo};
use crate::channel::route::{DeviceRoute, find_receiver};
use ledger::{NodeLedger, SettledNode};
mod cache;
mod channel_cache;
pub mod events;
mod features;
pub mod hotplug;
mod ledger;
mod mappings;
pub mod persist;
mod probe;
pub mod standalone;
use cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached};
use channel_cache::ChannelCache;
use events::{ChannelEventSubscriptions, EventNotifier, EventSubscriptionHandle};
use persist::{ProbeCacheSnapshot, ProbeCacheStore};
use probe::{NodeProbe, PassContext, ProbeTimeouts, ProbeVerdict, probe_one};
#[cfg(test)]
use probe::{UNIFYING_CACHED_SLOT_PROBE_TIMEOUT, UNIFYING_SLOT_PROBE_TIMEOUT};
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("HID transport error")]
Hid(#[from] BackendError),
#[error("multiple indistinguishable standalone raw HID devices found")]
AmbiguousRawDevice,
}
pub const PROBE_CACHE_FRESHNESS: Duration = cache::REFRESH_INTERVAL;
#[must_use]
pub fn probe_cache_is_stale(probed_at: Instant, now: Instant) -> bool {
now.saturating_duration_since(probed_at) > PROBE_CACHE_FRESHNESS
}
pub struct Enumerator {
backend: Arc<dyn HidBackend>,
cache: HashMap<CacheKey, Cached>,
misses: HashMap<CacheKey, u8>,
node_cache_keys: HashMap<NodeId, HashSet<CacheKey>>,
channels: ChannelCache<NodeId, CachedChannel>,
ledger: NodeLedger<NodeId>,
registry: Option<ChannelRegistry>,
store: Option<Arc<dyn ProbeCacheStore>>,
cache_dirty: bool,
open_failures_last_tick: bool,
retry_needed_last_tick: bool,
event_notifier: Option<EventNotifier>,
timeouts: ProbeTimeouts,
}
struct CachedChannel {
info: NodeInfo,
channel: Arc<HidppChannel>,
events: Option<ChannelEventSubscriptions>,
}
type ActiveNode = (NodeInfo, Arc<HidppChannel>, Option<EventSubscriptionHandle>);
struct PreparedNodes {
active: Vec<ActiveNode>,
open_failures: Vec<NodeId>,
retiring: Vec<NodeId>,
}
fn routes_for_inventories(inventories: &[DeviceInventory]) -> Vec<DeviceRoute> {
inventories
.iter()
.flat_map(|inventory| {
inventory
.paired
.iter()
.filter_map(|paired| DeviceRoute::for_slot(inventory, paired.slot))
})
.collect()
}
fn settle_probe<Node: Eq + Hash + Clone>(
ledger: &mut NodeLedger<Node>,
node: &Node,
verdict: ProbeVerdict,
inventory: Option<DeviceInventory>,
) -> SettledNode {
match verdict {
ProbeVerdict::Deferred => ledger.defer(node),
ProbeVerdict::AliveButIncomplete => ledger.settle_arrival_replay_failure(node),
verdict => ledger.settle(node, verdict.is_healthy(), inventory),
}
}
fn settle_unhealthy_node<Node: Eq + Hash + Clone>(
ledger: &mut NodeLedger<Node>,
node: &Node,
all_complete: &mut bool,
all_healthy: &mut bool,
) -> Option<DeviceInventory> {
*all_complete = false;
*all_healthy = false;
ledger.settle(node, false, None).inventory
}
pub async fn enumerate(
backend: Arc<dyn HidBackend>,
) -> Result<Vec<DeviceInventory>, InventoryError> {
let mut enumerator = Enumerator::with_backend(backend);
let mut scan = OneShotScan::new();
loop {
let (inventories, all_complete, all_healthy) =
enumerator.enumerate_reporting_completeness().await?;
let pass = ScanPass {
complete: all_complete,
healthy: all_healthy,
};
if scan.is_settled(&inventories, pass) {
return Ok(inventories);
}
debug!(
attempt = scan.attempt,
complete = pass.complete,
healthy = pass.healthy,
"one-shot enumerate inventory incomplete or still changing — retrying"
);
scan.advance(inventories, pass);
tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
}
}
#[derive(Clone, Copy)]
struct ScanPass {
complete: bool,
healthy: bool,
}
struct OneShotScan {
stable_candidate: Option<Vec<DeviceInventory>>,
attempt: u8,
}
impl OneShotScan {
fn new() -> Self {
Self {
stable_candidate: None,
attempt: 1,
}
}
fn is_settled(&self, current: &[DeviceInventory], pass: ScanPass) -> bool {
pass.complete
|| (pass.healthy
&& self
.stable_candidate
.as_deref()
.is_some_and(|previous| previous == current))
|| self.attempt >= ONESHOT_ATTEMPTS
}
fn advance(&mut self, inventories: Vec<DeviceInventory>, pass: ScanPass) {
self.stable_candidate = pass.healthy.then_some(inventories);
self.attempt += 1;
}
}
const ONESHOT_ATTEMPTS: u8 = 4;
const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
fn retained_nodes<K>(
enumerated: &HashSet<K>,
cached_channels: impl IntoIterator<Item = (K, bool)>,
) -> HashSet<K>
where
K: Clone + Eq + Hash,
{
let mut retained = enumerated.clone();
retained.extend(
cached_channels
.into_iter()
.filter_map(|(node, connected)| connected.then_some(node)),
);
retained
}
fn append_live_cached_channels(
nodes: &mut HashSet<NodeId>,
channels: &ChannelCache<NodeId, CachedChannel>,
active: &mut Vec<ActiveNode>,
) {
let retained = retained_nodes(
nodes,
channels
.active_iter()
.map(|(node, open)| (node.clone(), open.channel.is_connected())),
);
for node in retained.difference(nodes) {
if let Some(open) = channels.get(node) {
debug!(
?node,
name = %open.info.name,
"OS enumeration omitted a live HID node; probing cached channel"
);
active.push((
open.info.clone(),
Arc::clone(&open.channel),
open.events.as_ref().map(ChannelEventSubscriptions::handle),
));
}
}
*nodes = retained;
}
impl Enumerator {
#[must_use]
pub fn open_failures_last_tick(&self) -> bool {
self.open_failures_last_tick
}
#[must_use]
pub fn retry_needed_last_tick(&self) -> bool {
self.retry_needed_last_tick
}
#[must_use]
pub fn with_backend(backend: Arc<dyn HidBackend>) -> Self {
Self {
backend,
cache: HashMap::new(),
misses: HashMap::new(),
node_cache_keys: HashMap::new(),
channels: ChannelCache::default(),
ledger: NodeLedger::default(),
registry: None,
store: None,
cache_dirty: false,
open_failures_last_tick: false,
retry_needed_last_tick: false,
event_notifier: None,
timeouts: ProbeTimeouts::DEFAULT,
}
}
#[must_use]
pub fn with_registry(mut self, registry: ChannelRegistry) -> Self {
self.registry = Some(registry);
self
}
#[must_use]
pub fn with_event_notifier(mut self, notifier: EventNotifier) -> Self {
self.event_notifier = Some(notifier);
self
}
#[must_use]
pub fn with_probe_cache(mut self, store: Arc<dyn ProbeCacheStore>) -> Self {
let cache = store.load().into_entries();
if !cache.is_empty() {
debug!(entries = cache.len(), "probe cache warm-started");
}
self.cache.extend(cache);
self.store = Some(store);
self
}
async fn prepare_nodes(
&mut self,
backend: &dyn HidBackend,
candidates: Vec<NodeInfo>,
) -> PreparedNodes {
let mut active = Vec::new();
let mut seen_nodes = HashSet::new();
let mut open_failures = Vec::new();
let mut retiring = Vec::new();
for info in candidates {
let node = info.id.clone();
seen_nodes.insert(node.clone());
if !self
.channels
.prepare_open(&node, |cached| Arc::strong_count(&cached.channel) == 1)
{
debug!("node still retiring — waiting for its channel's remaining users to drop");
retiring.push(node);
continue;
}
if let Some(open) = self.channels.get(&node) {
active.push((
open.info.clone(),
Arc::clone(&open.channel),
open.events.as_ref().map(ChannelEventSubscriptions::handle),
));
continue;
}
match backend.open_hidpp(&info).await {
Ok(Some(channel)) => {
self.ledger.reset_channel_failures_after_open(&node);
let events = self.event_notifier.as_ref().map(|notifier| {
let protocol = find_receiver(info.vendor_id, info.product_id)
.map(|receiver| receiver.protocol);
ChannelEventSubscriptions::attach(&channel, protocol, notifier.clone())
});
let event_handle = events.as_ref().map(ChannelEventSubscriptions::handle);
self.channels.insert(
node,
CachedChannel {
info: info.clone(),
channel: Arc::clone(&channel),
events,
},
);
active.push((info, channel, event_handle));
}
Ok(None) => {}
Err(e) => {
warn!(error = ?e, "failed to open HID++ channel — requesting repair");
open_failures.push(node);
}
}
}
append_live_cached_channels(&mut seen_nodes, &self.channels, &mut active);
if let Some(registry) = &self.registry {
registry.retain_nodes(&seen_nodes);
}
self.channels.retire_absent(&seen_nodes);
self.channels.reap_absent(&seen_nodes, |cached| {
Arc::strong_count(&cached.channel) == 1
});
self.ledger.retain_nodes(&seen_nodes);
self.node_cache_keys
.retain(|node, _| seen_nodes.contains(node));
PreparedNodes {
active,
open_failures,
retiring,
}
}
fn flush_cache(&mut self) {
if !self.cache_dirty {
return;
}
let Some(store) = &self.store else {
return;
};
match store.save(&ProbeCacheSnapshot::of(&self.cache)) {
Ok(()) => self.cache_dirty = false,
Err(e) => warn!(error = %e, "failed to persist probe cache"),
}
}
pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, InventoryError> {
self.enumerate_reporting_completeness()
.await
.map(|(inv, _, _)| inv)
}
async fn enumerate_reporting_completeness(
&mut self,
) -> Result<(Vec<DeviceInventory>, bool, bool), InventoryError> {
let now = Instant::now();
let backend = Arc::clone(&self.backend);
let candidates = backend.enumerate_hidpp().await?;
debug!(count = candidates.len(), "HID++ candidate interfaces");
let PreparedNodes {
active,
open_failures,
retiring: retiring_nodes,
} = self.prepare_nodes(&*backend, candidates).await;
self.open_failures_last_tick = !open_failures.is_empty();
let results = {
let cache = &self.cache;
let timeouts = &self.timeouts;
active
.into_iter()
.map(|(info, channel, events)| async move {
let node = info.id.clone();
let pass = PassContext {
cache,
now,
subscriptions: events.as_ref(),
timeouts,
};
let probe = probe_one(info, Arc::clone(&channel), pass).await;
(node, channel, probe)
})
.collect::<Vec<_>>()
.join()
.await
};
let (mut inventories, mut outcomes) = (Vec::new(), Vec::new());
let (mut all_complete, mut all_healthy) = (true, true);
let mut frozen_keys = HashSet::new();
for (node, channel, probe) in results {
all_complete &= probe.verdict.is_complete();
all_healthy &= probe.verdict.is_healthy();
self.hold_or_note_cache_keys(&node, &probe, &mut frozen_keys);
outcomes.extend(probe.outcomes);
let settled = settle_probe(&mut self.ledger, &node, probe.verdict, probe.inventory);
if settled.evict_channel {
if let Some(registry) = &self.registry {
registry.remove_node(&node);
}
if self.channels.retire_node(&node) {
warn!("node probe keeps failing — retiring its channel before reopen");
}
} else if let Some(registry) = &self.registry {
let routes = settled
.inventory
.as_ref()
.map_or_else(Vec::new, |inventory| {
routes_for_inventories(std::slice::from_ref(inventory))
});
if routes.is_empty() {
registry.remove_node(&node);
} else {
registry.replace_node(node.clone(), routes, channel);
}
}
inventories.extend(settled.inventory);
}
for node in retiring_nodes {
inventories.extend(settle_unhealthy_node(
&mut self.ledger,
&node,
&mut all_complete,
&mut all_healthy,
));
}
for node in open_failures {
inventories.extend(settle_unhealthy_node(
&mut self.ledger,
&node,
&mut all_complete,
&mut all_healthy,
));
}
let seen_keys = self.apply_outcomes(outcomes);
self.evict_unseen(&seen_keys, &frozen_keys);
self.retry_needed_last_tick = !all_healthy || !self.misses.is_empty();
self.flush_cache();
Ok((inventories, all_complete, all_healthy))
}
fn apply_outcomes(&mut self, outcomes: Vec<CacheOutcome>) -> HashSet<CacheKey> {
let mut seen_keys = HashSet::new();
for outcome in outcomes {
match outcome {
CacheOutcome::Fresh(key, cached) => {
seen_keys.insert(key.clone());
self.cache_dirty |= persist::is_persistable(&key);
self.cache.insert(key, cached);
}
CacheOutcome::Update(key, cached) => {
seen_keys.insert(key.clone());
self.cache.insert(key, cached);
}
CacheOutcome::Seen(key) => {
seen_keys.insert(key);
}
CacheOutcome::Unkeyed => {}
}
}
seen_keys
}
fn hold_or_note_cache_keys(
&mut self,
node: &NodeId,
probe: &NodeProbe,
frozen: &mut HashSet<CacheKey>,
) {
if probe.verdict.is_deferred() {
match self.node_cache_keys.get(node) {
Some(keys) => frozen.extend(keys.iter().cloned()),
None => frozen.extend(self.unattributed_cache_keys()),
}
return;
}
if !probe.verdict.is_healthy() && !self.node_cache_keys.contains_key(node) {
return;
}
let keys = probe.outcomes.iter().filter_map(CacheOutcome::key).cloned();
self.node_cache_keys
.entry(node.clone())
.or_default()
.extend(keys);
}
fn unattributed_cache_keys(&self) -> impl Iterator<Item = CacheKey> + '_ {
self.cache
.keys()
.filter(|key| {
!self
.node_cache_keys
.values()
.any(|keys| keys.contains(*key))
})
.cloned()
}
fn evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>, frozen: &HashSet<CacheKey>) {
for key in seen_keys {
self.misses.remove(key);
}
let missing: Vec<CacheKey> = self
.cache
.keys()
.filter(|k| !seen_keys.contains(*k) && !frozen.contains(*k))
.cloned()
.collect();
for key in missing {
let misses = self.misses.entry(key.clone()).or_insert(0);
*misses += 1;
if *misses > CACHE_MISS_GRACE {
self.cache.remove(&key);
self.misses.remove(&key);
for keys in self.node_cache_keys.values_mut() {
keys.remove(&key);
}
self.cache_dirty |= persist::is_persistable(&key);
}
}
}
}
#[cfg(test)]
mod replay_test_support;
#[cfg(test)]
mod replay_tests;
#[cfg(test)]
mod tests;