use std::{
collections::{HashMap, HashSet, hash_map::Entry},
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 tokio::time::timeout;
use tracing::{debug, warn};
use crate::ChannelRegistry;
use crate::backend::{BackendError, HidBackend, NodeId, NodeInfo};
use crate::channel::route::{DeviceRoute, find_receiver, is_receiver_pid};
use ledger::NodeLedger;
mod 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 events::{ChannelEventSubscriptions, EventNotifier, EventSubscriptionHandle};
use persist::{ProbeCacheSnapshot, ProbeCacheStore};
use probe::{NodeProbe, probe_one};
const ARRIVAL_DRAIN: Duration = Duration::from_millis(1500);
const MAX_BOLT_SLOTS: u8 = 6;
const PROBE_BUDGET: Duration = Duration::from_secs(25);
const RECEIVER_PROBE_BUDGET: Duration = Duration::from_secs(13);
const UNIFYING_SLOT_PROBE: Duration = Duration::from_millis(3500);
const UNIFYING_CACHED_SLOT_PROBE: Duration = Duration::from_millis(750);
const BOLT_SLOT_PROBE: Duration = Duration::from_secs(10);
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("HID transport error")]
Hid(#[from] BackendError),
#[error("multiple indistinguishable standalone raw HID devices found")]
AmbiguousRawDevice,
}
pub struct Enumerator {
backend: Arc<dyn HidBackend>,
cache: HashMap<CacheKey, Cached>,
misses: HashMap<CacheKey, u8>,
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>,
}
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>,
}
enum ChannelState<Channel> {
Active(Channel),
Retiring(Channel),
}
struct ChannelCache<Node, Channel> {
channels: HashMap<Node, ChannelState<Channel>>,
}
impl<Node, Channel> Default for ChannelCache<Node, Channel> {
fn default() -> Self {
Self {
channels: HashMap::new(),
}
}
}
impl<Node: Eq + Hash + Clone, Channel> ChannelCache<Node, Channel> {
fn get(&self, node: &Node) -> Option<&Channel> {
match self.channels.get(node)? {
ChannelState::Active(channel) => Some(channel),
ChannelState::Retiring(_) => None,
}
}
fn active_iter(&self) -> impl Iterator<Item = (&Node, &Channel)> {
self.channels
.iter()
.filter_map(|(node, state)| match state {
ChannelState::Active(channel) => Some((node, channel)),
ChannelState::Retiring(_) => None,
})
}
fn insert(&mut self, node: Node, channel: Channel) {
match self.channels.entry(node) {
Entry::Occupied(mut entry) => match entry.get_mut() {
state @ ChannelState::Active(_) => *state = ChannelState::Active(channel),
ChannelState::Retiring(_) => {
warn!("refusing to replace a retiring channel — dropping the fresh open");
drop(channel);
}
},
Entry::Vacant(entry) => {
entry.insert(ChannelState::Active(channel));
}
}
}
fn retire_node(&mut self, node: &Node) -> bool {
let Some(state) = self.channels.get_mut(node) else {
return false;
};
if matches!(state, ChannelState::Retiring(_)) {
return false;
}
let Some(ChannelState::Active(channel) | ChannelState::Retiring(channel)) =
self.channels.remove(node)
else {
return false;
};
self.channels
.insert(node.clone(), ChannelState::Retiring(channel));
true
}
fn prepare_open(&mut self, node: &Node, is_quiescent: impl FnOnce(&Channel) -> bool) -> bool {
let Some(ChannelState::Retiring(channel)) = self.channels.get(node) else {
return true;
};
if is_quiescent(channel) {
self.channels.remove(node);
}
false
}
fn retire_absent(&mut self, seen: &HashSet<Node>) -> usize {
let absent = self
.active_iter()
.map(|(node, _)| node)
.filter(|node| !seen.contains(*node))
.cloned()
.collect::<Vec<_>>();
absent
.into_iter()
.filter(|node| self.retire_node(node))
.count()
}
fn reap_absent(&mut self, seen: &HashSet<Node>, is_quiescent: impl Fn(&Channel) -> bool) {
self.channels.retain(|node, state| match state {
ChannelState::Active(_) => true,
ChannelState::Retiring(channel) => seen.contains(node) || !is_quiescent(channel),
});
}
#[cfg(test)]
fn is_retiring(&self, node: &Node) -> bool {
matches!(self.channels.get(node), Some(ChannelState::Retiring(_)))
}
}
fn routes_for_inventories(inventories: &[DeviceInventory]) -> Vec<DeviceRoute> {
inventories
.iter()
.flat_map(|inventory| {
inventory
.paired
.iter()
.filter_map(|paired| DeviceRoute::device_route_for(inventory, paired.slot))
})
.collect()
}
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(),
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,
}
}
#[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)) => {
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);
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;
active
.into_iter()
.map(|(info, channel, events)| async move {
let node = info.id.clone();
let receiver = is_receiver_pid(info.product_id);
let budget = if receiver {
RECEIVER_PROBE_BUDGET
} else {
PROBE_BUDGET
};
let probe = timeout(
budget,
probe_one(info, Arc::clone(&channel), cache, now, events.as_ref()),
)
.await;
(node, channel, probe, budget, receiver)
})
.collect::<Vec<_>>()
.join()
.await
};
let mut inventories = Vec::new();
let mut outcomes = Vec::new();
let mut all_complete = true;
let mut all_healthy = true;
for (node, channel, result, budget, receiver) in results {
let probe = if let Ok(probe) = result {
probe
} else {
warn!(
?budget,
receiver, "device probe timed out — treating as a failed probe"
);
NodeProbe::failed()
};
all_complete &= probe.verdict.is_complete();
all_healthy &= probe.verdict.is_healthy();
outcomes.extend(probe.outcomes);
let settled = self
.ledger
.settle(&node, probe.verdict.is_healthy(), 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);
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 evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>) {
for key in seen_keys {
self.misses.remove(key);
}
let missing: Vec<CacheKey> = self
.cache
.keys()
.filter(|k| !seen_keys.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);
self.cache_dirty |= persist::is_persistable(&key);
}
}
}
}
#[cfg(test)]
mod tests;