use std::{
collections::{HashMap, HashSet},
hash::Hash,
path::PathBuf,
sync::Arc,
time::Duration,
};
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::channel_registry::ChannelRegistry;
use crate::node_ledger::NodeLedger;
use crate::route::{DeviceRoute, is_receiver_pid};
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
mod cache;
mod features;
mod persist;
mod probe;
use cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached};
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 BOLT_SLOT_PROBE: Duration = Duration::from_secs(10);
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
#[error("multiple indistinguishable standalone raw HID devices found")]
AmbiguousRawDevice,
}
#[derive(Default)]
pub struct Enumerator {
cache: HashMap<CacheKey, Cached>,
misses: HashMap<CacheKey, u8>,
channels: ChannelCache<async_hid::DeviceId, CachedChannel>,
ledger: NodeLedger<async_hid::DeviceId>,
registry: Option<ChannelRegistry>,
tick: u64,
persist_path: Option<PathBuf>,
cache_dirty: bool,
}
struct CachedChannel {
info: async_hid::DeviceInfo,
channel: Arc<HidppChannel>,
}
struct PreparedNodes {
active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)>,
open_failures: Vec<async_hid::DeviceId>,
retiring: Vec<async_hid::DeviceId>,
}
struct ChannelCache<Node, Channel> {
active: HashMap<Node, Channel>,
retiring: HashMap<Node, Channel>,
}
impl<Node, Channel> Default for ChannelCache<Node, Channel> {
fn default() -> Self {
Self {
active: HashMap::new(),
retiring: HashMap::new(),
}
}
}
impl<Node: Eq + Hash + Clone, Channel> ChannelCache<Node, Channel> {
fn get(&self, node: &Node) -> Option<&Channel> {
self.active.get(node)
}
fn insert(&mut self, node: Node, channel: Channel) {
debug_assert!(!self.retiring.contains_key(&node));
self.active.insert(node, channel);
}
fn retire_node(&mut self, node: &Node) -> Option<&Channel> {
let channel = self.active.remove(node)?;
self.retiring.insert(node.clone(), channel);
self.retiring.get(node)
}
fn prepare_open(&mut self, node: &Node, is_quiescent: impl FnOnce(&Channel) -> bool) -> bool {
let Some(channel) = self.retiring.get(node) else {
return true;
};
if is_quiescent(channel) {
self.retiring.remove(node);
}
false
}
fn retire_absent(&mut self, seen: &HashSet<Node>, mut on_retire: impl FnMut(&Channel)) {
let absent = self
.active
.keys()
.filter(|node| !seen.contains(*node))
.cloned()
.collect::<Vec<_>>();
for node in absent {
if let Some(channel) = self.retire_node(&node) {
on_retire(channel);
}
}
}
fn reap_absent(&mut self, seen: &HashSet<Node>, is_quiescent: impl Fn(&Channel) -> bool) {
self.retiring
.retain(|node, channel| seen.contains(node) || !is_quiescent(channel));
}
#[cfg(test)]
fn is_retiring(&self, node: &Node) -> bool {
self.retiring.contains_key(node)
}
}
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() -> Result<Vec<DeviceInventory>, InventoryError> {
let mut enumerator = Enumerator::default();
let mut previous_inventories: Option<Vec<DeviceInventory>> = None;
let mut attempt = 1u8;
loop {
let (inventories, all_complete, all_healthy) =
enumerator.enumerate_reporting_completeness().await?;
if one_shot_should_stop(
previous_inventories.as_deref(),
&inventories,
all_complete,
all_healthy,
attempt,
) {
return Ok(inventories);
}
debug!(
attempt,
all_complete,
all_healthy,
"one-shot enumerate inventory incomplete or still changing — retrying"
);
previous_inventories = if all_healthy { Some(inventories) } else { None };
tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
attempt += 1;
}
}
fn one_shot_should_stop(
previous: Option<&[DeviceInventory]>,
current: &[DeviceInventory],
all_complete: bool,
all_healthy: bool,
attempt: u8,
) -> bool {
all_complete
|| (all_healthy && previous.is_some_and(|previous| previous == current))
|| attempt >= ONESHOT_ATTEMPTS
}
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<async_hid::DeviceId>,
channels: &ChannelCache<async_hid::DeviceId, CachedChannel>,
active: &mut Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)>,
) {
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)));
}
}
*nodes = retained;
}
impl Enumerator {
#[must_use]
pub fn with_registry(registry: ChannelRegistry) -> Self {
Self {
registry: Some(registry),
..Self::default()
}
}
#[must_use]
pub fn persisted(mut self) -> Self {
self.persist_path = match openlogi_core::paths::data_dir() {
Ok(dir) => Some(dir.join("probe-cache.json")),
Err(e) => {
warn!(error = %e, "no data dir — probe cache is memory-only");
None
}
};
let cache = self
.persist_path
.as_deref()
.map(persist::load)
.unwrap_or_default();
if !cache.is_empty() {
debug!(entries = cache.len(), "probe cache warm-started from disk");
}
self.cache.extend(cache);
self
}
async fn prepare_nodes(&mut self, candidates: Vec<async_hid::Device>) -> PreparedNodes {
let mut active = Vec::new();
let mut seen_nodes = HashSet::new();
let mut open_failures = Vec::new();
let mut retiring = Vec::new();
for dev in candidates {
let node = dev.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)));
continue;
}
match open_hidpp_channel(dev).await {
Ok(Some((info, channel))) => {
self.channels.insert(
node,
CachedChannel {
info: info.clone(),
channel: Arc::clone(&channel),
},
);
active.push((info, channel));
}
Ok(None) => {}
Err(e) => {
warn!(error = ?e, "failed to open HID++ channel — retrying next tick");
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, |cached| {
crate::write::clear_haptic_feature_cache_for(&cached.channel);
});
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(path) = self.persist_path.as_deref() else {
return;
};
match persist::save(path, &self.cache) {
Ok(()) => self.cache_dirty = false,
Err(e) => warn!(error = %e, ?path, "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> {
self.tick = self.tick.wrapping_add(1);
let tick = self.tick;
let candidates = enumerate_hidpp_devices().await?;
debug!(count = candidates.len(), "HID++ candidate interfaces");
let PreparedNodes {
active,
open_failures,
retiring: retiring_nodes,
} = self.prepare_nodes(candidates).await;
let results = {
let cache = &self.cache;
active
.into_iter()
.map(|(info, channel)| 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, tick)).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.complete;
all_healthy &= probe.healthy;
outcomes.extend(probe.outcomes);
let settled = self.ledger.settle(&node, probe.healthy, probe.inventory);
if settled.evict_channel {
if let Some(registry) = &self.registry {
registry.remove_node(&node);
}
if let Some(cached) = self.channels.retire_node(&node) {
crate::write::clear_haptic_feature_cache_for(&cached.channel);
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.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)]
#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
mod tests;