use std::{
collections::{HashMap, HashSet},
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::node_ledger::NodeLedger;
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
mod cache;
mod features;
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(6);
const UNIFYING_SLOT_PROBE: Duration = Duration::from_millis(3500);
const BOLT_SLOT_PROBE: Duration = Duration::from_secs(3);
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
}
#[derive(Default)]
pub struct Enumerator {
cache: HashMap<CacheKey, Cached>,
misses: HashMap<CacheKey, u8>,
channels: HashMap<async_hid::DeviceId, CachedChannel>,
ledger: NodeLedger<async_hid::DeviceId>,
tick: u64,
}
struct CachedChannel {
info: async_hid::DeviceInfo,
channel: Arc<HidppChannel>,
}
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);
impl Enumerator {
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 mut active: Vec<(async_hid::DeviceInfo, Arc<HidppChannel>)> = Vec::new();
let mut seen_nodes: HashSet<async_hid::DeviceId> = HashSet::new();
let mut open_failures: Vec<async_hid::DeviceId> = Vec::new();
for dev in candidates {
let node = dev.id.clone();
seen_nodes.insert(node.clone());
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);
}
}
}
self.channels.retain(|node, _| seen_nodes.contains(node));
self.ledger.retain_nodes(&seen_nodes);
let results = {
let cache = &self.cache;
active
.into_iter()
.map(|(info, channel)| async move {
let node = info.id.clone();
let probe = timeout(PROBE_BUDGET, probe_one(info, channel, cache, tick)).await;
(node, probe)
})
.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, result) in results {
let probe = if let Ok(probe) = result {
probe
} else {
warn!(budget = ?PROBE_BUDGET, "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 && self.channels.remove(&node).is_some() {
warn!("node probe keeps failing — dropping its channel to reopen next tick");
}
inventories.extend(settled.inventory);
}
for node in open_failures {
all_complete = false;
all_healthy = false;
let settled = self.ledger.settle(&node, false, None);
inventories.extend(settled.inventory);
}
let mut seen_keys = HashSet::new();
for outcome in outcomes {
match outcome {
CacheOutcome::Fresh(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 => {}
}
}
self.evict_unseen(&seen_keys);
Ok((inventories, all_complete, all_healthy))
}
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);
}
}
}
}
#[cfg(test)]
mod tests;