use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use futures_concurrency::future::Join as _;
use hidpp::{
channel::HidppChannel,
device::Device,
feature::{
device_information::DeviceInformationFeature,
device_type_and_name::{DeviceType as HidppDeviceType, DeviceTypeAndNameFeature},
unified_battery::{
BatteryLevel as HidppBatteryLevel, BatteryStatus as HidppBatteryStatus,
UnifiedBatteryFeature,
},
},
receiver::{
self, Receiver,
bolt::{
DeviceConnection as BoltDeviceConnection, DeviceKind as BoltDeviceKind,
Event as BoltEvent, Receiver as BoltReceiver,
},
},
};
use openlogi_core::device::{
BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
DeviceModelInfo, DeviceTransports, PairedDevice, ReceiverInfo,
};
use thiserror::Error;
use tokio::time::timeout;
use tracing::{debug, warn};
use crate::route::DIRECT_DEVICE_INDEX;
use crate::transport::{enumerate_hidpp_devices, open_hidpp_channel};
const ARRIVAL_DRAIN: Duration = Duration::from_millis(1500);
const MAX_BOLT_SLOTS: u8 = 6;
const PROBE_BUDGET: Duration = Duration::from_secs(5);
#[derive(Debug, Error)]
pub enum InventoryError {
#[error("HID transport error")]
Hid(#[from] async_hid::HidError),
}
const REFRESH_TICKS: u64 = 15;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum CacheKey {
Bolt { unit_id: [u8; 4] },
Direct(async_hid::DeviceId),
}
const CACHE_MISS_GRACE: u8 = 3;
#[derive(Clone)]
struct Cached {
probe: ProbedFeatures,
probed_tick: u64,
}
enum CacheOutcome {
Fresh(CacheKey, Cached),
Seen(CacheKey),
Unkeyed,
}
fn seen(id: Option<CacheKey>) -> CacheOutcome {
id.map_or(CacheOutcome::Unkeyed, CacheOutcome::Seen)
}
fn is_stale(cached: &Cached, tick: u64) -> bool {
tick.wrapping_sub(cached.probed_tick) >= REFRESH_TICKS
}
async fn probe_or_reuse(
channel: &Arc<HidppChannel>,
index: u8,
id: Option<CacheKey>,
cached: Option<&Cached>,
online: bool,
tick: u64,
) -> (ProbedFeatures, CacheOutcome) {
if online && cached.is_none_or(|c| is_stale(c, tick)) {
let fresh = probe_features(channel, index).await;
if fresh.capabilities.is_some() {
return match id {
Some(key) => {
let value = Cached {
probe: fresh.clone(),
probed_tick: tick,
};
(fresh, CacheOutcome::Fresh(key, value))
}
None => (fresh, CacheOutcome::Unkeyed),
};
}
return match cached {
Some(c) => (c.probe.clone(), seen(id)),
None => (fresh, seen(id)),
};
}
match cached {
Some(c) => (c.probe.clone(), seen(id)),
None => (ProbedFeatures::default(), seen(id)),
}
}
#[derive(Default)]
pub struct Enumerator {
cache: HashMap<CacheKey, Cached>,
misses: HashMap<CacheKey, u8>,
tick: u64,
}
pub async fn enumerate() -> Result<Vec<DeviceInventory>, InventoryError> {
Enumerator::default().enumerate().await
}
impl Enumerator {
pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, 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 results = {
let cache = &self.cache;
candidates
.into_iter()
.map(|dev| async move { timeout(PROBE_BUDGET, probe_one(dev, cache, tick)).await })
.collect::<Vec<_>>()
.join()
.await
};
let mut inventories = Vec::new();
let mut outcomes = Vec::new();
for result in results {
match result {
Ok(Ok((inv, mut probed))) => {
inventories.extend(inv);
outcomes.append(&mut probed);
}
Ok(Err(e)) => warn!(error = ?e, "skipping device that failed to probe"),
Err(_) => {
warn!(budget = ?PROBE_BUDGET, "device probe timed out — skipping (asleep/unresponsive)");
}
}
}
let mut seen_keys = HashSet::new();
for outcome in outcomes {
match outcome {
CacheOutcome::Fresh(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)
}
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);
}
}
}
}
async fn probe_one(
dev: async_hid::Device,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> Result<(Option<DeviceInventory>, Vec<CacheOutcome>), InventoryError> {
let Some((info, channel)) = open_hidpp_channel(dev).await? else {
return Ok((None, Vec::new()));
};
let Some(Receiver::Bolt(bolt)) = receiver::detect(Arc::clone(&channel)) else {
let (inventory, outcome) = probe_direct(channel, &info, cache, tick).await;
return Ok((inventory, vec![outcome]));
};
let unique_id = bolt.get_unique_id().await.ok();
let pairing_count = bolt.count_pairings().await.ok();
debug!(?pairing_count, "receiver reports pairing count");
let connections = drain_device_arrival(&bolt).await;
debug!(events = connections.len(), "drained device-arrival events");
let by_slot: HashMap<u8, BoltDeviceConnection> =
connections.into_iter().map(|c| (c.index, c)).collect();
let mut paired = Vec::new();
let mut outcomes = Vec::new();
for slot in 1u8..=MAX_BOLT_SLOTS {
if let Some((device, outcome)) =
probe_bolt_slot(&channel, &bolt, by_slot.get(&slot), slot, cache, tick).await
{
paired.push(device);
outcomes.push(outcome);
}
}
if let Some(count) = pairing_count
&& paired.len() != usize::from(count)
{
warn!(
expected = count,
found = paired.len(),
"paired-device count mismatch — some slots may be unreadable"
);
}
Ok((
Some(DeviceInventory {
receiver: ReceiverInfo {
name: "Logi Bolt Receiver".to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
},
paired,
}),
outcomes,
))
}
async fn probe_bolt_slot(
channel: &Arc<HidppChannel>,
bolt: &BoltReceiver,
event: Option<&BoltDeviceConnection>,
slot: u8,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> Option<(PairedDevice, CacheOutcome)> {
let pairing = match bolt.get_device_pairing_information(slot).await {
Ok(p) => p,
Err(e) => {
debug!(slot, error = ?e, "slot empty or unreadable");
return None;
}
};
let codename = read_codename(channel, slot).await;
let online = event.map_or(pairing.online, |c| c.online);
let bolt_kind = event.map_or(pairing.kind, |c| c.kind);
let wpid = event.map(|c| c.wpid);
debug!(
slot,
online,
?wpid,
?bolt_kind,
has_event = event.is_some(),
codename = ?codename,
"paired slot"
);
let id = (pairing.unit_id != [0u8; 4]).then_some(CacheKey::Bolt {
unit_id: pairing.unit_id,
});
let cached = id.as_ref().and_then(|i| cache.get(i));
let register_kind = map_kind(bolt_kind);
let (probe, outcome) = probe_or_reuse(channel, slot, id, cached, online, tick).await;
if matches!(outcome, CacheOutcome::Fresh(..))
&& let Some(probed) = probe.kind
&& probed != DeviceKind::Unknown
&& register_kind != DeviceKind::Unknown
&& probed != register_kind
{
debug!(
slot,
?register_kind,
?probed,
"device-kind sources disagree — trusting 0x0005"
);
}
let device = PairedDevice {
slot,
codename,
wpid,
kind: resolve_device_kind(probe.kind, register_kind),
online,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
};
Some((device, outcome))
}
async fn probe_direct(
channel: Arc<HidppChannel>,
info: &async_hid::DeviceInfo,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> (Option<DeviceInventory>, CacheOutcome) {
let id = CacheKey::Direct(info.id.clone());
let cached = cache.get(&id);
let (probe, outcome) =
probe_or_reuse(&channel, DIRECT_DEVICE_INDEX, Some(id), cached, true, tick).await;
let caps = probe.capabilities.unwrap_or_default();
let is_peripheral = probe.battery.is_some() || caps.buttons || caps.pointer || caps.lighting;
if !is_peripheral {
debug!(
vid = format_args!("{:04x}", info.vendor_id),
pid = format_args!("{:04x}", info.product_id),
has_model = probe.model_info.is_some(),
"slot 0xff exposes no battery or config feature — likely a receiver \
secondary interface; skipping"
);
return (None, CacheOutcome::Unkeyed);
}
debug!(name = %info.name, "BT-direct / wired device recognised");
let inventory = DeviceInventory {
receiver: ReceiverInfo {
name: info.name.clone(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id: None,
},
paired: vec![PairedDevice {
slot: DIRECT_DEVICE_INDEX,
codename: Some(info.name.clone()),
wpid: None,
kind: resolve_device_kind(probe.kind, DeviceKind::Unknown),
online: true,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
}],
};
(Some(inventory), outcome)
}
async fn drain_device_arrival(bolt: &BoltReceiver) -> Vec<BoltDeviceConnection> {
let rx = bolt.listen();
if let Err(e) = bolt.trigger_device_arrival().await {
debug!(error = ?e, "trigger_device_arrival failed; receiver may report no devices");
return Vec::new();
}
let mut out = Vec::new();
loop {
match timeout(ARRIVAL_DRAIN, rx.recv()).await {
Ok(Ok(BoltEvent::DeviceConnection(c))) => out.push(c),
Ok(Ok(_)) => {} Ok(Err(_)) | Err(_) => break,
}
}
out
}
async fn read_codename(channel: &HidppChannel, slot: u8) -> Option<String> {
let response = channel
.read_long_register(0xFF, 0xB5, [0x60 + slot, 0x01, 0x00])
.await
.ok()?;
let len = usize::from(response[2]).min(13);
core::str::from_utf8(&response[3..3 + len])
.ok()
.map(str::to_string)
}
#[derive(Default, Clone)]
struct ProbedFeatures {
battery: Option<BatteryInfo>,
model_info: Option<DeviceModelInfo>,
kind: Option<DeviceKind>,
capabilities: Option<Capabilities>,
}
async fn probe_features(channel: &Arc<HidppChannel>, slot: u8) -> ProbedFeatures {
let mut device = match Device::new(Arc::clone(channel), slot).await {
Ok(d) => d,
Err(e) => {
debug!(slot, error = ?e, "Device::new failed");
return ProbedFeatures::default();
}
};
let capabilities = match device.enumerate_features().await {
Ok(Some(features)) => {
let ids: Vec<u16> = features.iter().map(|f| f.id).collect();
Some(Capabilities::from_feature_ids(&ids))
}
Ok(None) => None,
Err(e) => {
debug!(slot, error = ?e, "enumerate_features failed");
return ProbedFeatures::default();
}
};
let battery = match device.get_feature::<UnifiedBatteryFeature>() {
Some(feature) => feature
.get_battery_info()
.await
.ok()
.map(|info| BatteryInfo {
percentage: info.charging_percentage,
level: map_battery_level(info.level),
status: map_battery_status(info.status),
}),
None => None,
};
let model_info = match device.get_feature::<DeviceInformationFeature>() {
Some(feature) => match feature.get_device_info().await {
Ok(info) => {
let serial_number = if info.capabilities.serial_number {
match feature.get_serial_number().await {
Ok(serial) => normalize_serial_number(&serial),
Err(e) => {
debug!(slot, error = ?e, "DeviceInformation serial read failed");
None
}
}
} else {
None
};
Some(DeviceModelInfo {
entity_count: info.entity_count,
serial_number,
unit_id: info.unit_id,
transports: DeviceTransports {
usb: info.transport.usb,
equad: info.transport.e_quad,
btle: info.transport.btle,
bluetooth: info.transport.bluetooth,
},
model_ids: info.model_id,
extended_model_id: info.extended_model_id,
})
}
Err(e) => {
debug!(slot, error = ?e, "DeviceInformation read failed");
None
}
},
None => None,
};
let kind = match device.get_feature::<DeviceTypeAndNameFeature>() {
Some(feature) => match feature.get_device_type().await {
Ok(ty) => Some(map_device_type(ty)),
Err(e) => {
debug!(slot, error = ?e, "DeviceType read failed");
None
}
},
None => None,
};
ProbedFeatures {
battery,
model_info,
kind,
capabilities,
}
}
fn normalize_serial_number(serial: &str) -> Option<String> {
let serial = serial.trim_matches('\0').trim().to_string();
(!serial.is_empty()).then_some(serial)
}
fn map_kind(k: BoltDeviceKind) -> DeviceKind {
match k {
BoltDeviceKind::Keyboard => DeviceKind::Keyboard,
BoltDeviceKind::Mouse => DeviceKind::Mouse,
BoltDeviceKind::Numpad => DeviceKind::Numpad,
BoltDeviceKind::Presenter => DeviceKind::Presenter,
BoltDeviceKind::Remote => DeviceKind::Remote,
BoltDeviceKind::Trackball => DeviceKind::Trackball,
BoltDeviceKind::Touchpad => DeviceKind::Touchpad,
BoltDeviceKind::Tablet => DeviceKind::Tablet,
BoltDeviceKind::Gamepad => DeviceKind::Gamepad,
BoltDeviceKind::Joystick => DeviceKind::Joystick,
BoltDeviceKind::Headset => DeviceKind::Headset,
_ => DeviceKind::Unknown,
}
}
fn map_device_type(ty: HidppDeviceType) -> DeviceKind {
match ty {
HidppDeviceType::Keyboard => DeviceKind::Keyboard,
HidppDeviceType::Numpad => DeviceKind::Numpad,
HidppDeviceType::Mouse => DeviceKind::Mouse,
HidppDeviceType::Trackpad => DeviceKind::Touchpad,
HidppDeviceType::Trackball => DeviceKind::Trackball,
HidppDeviceType::Presenter => DeviceKind::Presenter,
HidppDeviceType::RemoteControl => DeviceKind::Remote,
HidppDeviceType::Headset => DeviceKind::Headset,
HidppDeviceType::Joystick => DeviceKind::Joystick,
HidppDeviceType::Gamepad => DeviceKind::Gamepad,
_ => DeviceKind::Unknown,
}
}
fn resolve_device_kind(probed: Option<DeviceKind>, register: DeviceKind) -> DeviceKind {
match probed {
Some(kind) if kind != DeviceKind::Unknown => kind,
_ => register,
}
}
fn map_battery_level(level: HidppBatteryLevel) -> BatteryLevel {
match level {
HidppBatteryLevel::Critical => BatteryLevel::Critical,
HidppBatteryLevel::Low => BatteryLevel::Low,
HidppBatteryLevel::Good => BatteryLevel::Good,
HidppBatteryLevel::Full => BatteryLevel::Full,
_ => BatteryLevel::Unknown,
}
}
fn map_battery_status(status: HidppBatteryStatus) -> BatteryStatus {
match status {
HidppBatteryStatus::Discharging => BatteryStatus::Discharging,
HidppBatteryStatus::Charging => BatteryStatus::Charging,
HidppBatteryStatus::ChargingSlow => BatteryStatus::ChargingSlow,
HidppBatteryStatus::Full => BatteryStatus::Full,
HidppBatteryStatus::Error => BatteryStatus::Error,
_ => BatteryStatus::Unknown,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use super::{
CACHE_MISS_GRACE, CacheKey, Cached, DeviceKind, Enumerator, ProbedFeatures, REFRESH_TICKS,
is_stale, resolve_device_kind,
};
fn cache_entry(probed_tick: u64) -> Cached {
Cached {
probe: ProbedFeatures::default(),
probed_tick,
}
}
#[test]
fn cache_entry_survives_grace_then_evicts() {
let mut e = Enumerator::default();
let key = CacheKey::Bolt {
unit_id: [1, 2, 3, 4],
};
e.cache.insert(key.clone(), cache_entry(0));
let nobody = HashSet::new();
for _ in 0..CACHE_MISS_GRACE {
e.evict_unseen(&nobody);
assert!(
e.cache.contains_key(&key),
"evicted inside the grace window"
);
}
e.evict_unseen(&nobody);
assert!(
!e.cache.contains_key(&key),
"should evict past the grace window"
);
}
#[test]
fn being_seen_resets_the_miss_counter() {
let mut e = Enumerator::default();
let key = CacheKey::Bolt { unit_id: [9; 4] };
e.cache.insert(key.clone(), cache_entry(0));
let nobody = HashSet::new();
let seen: HashSet<CacheKey> = std::iter::once(key.clone()).collect();
e.evict_unseen(&nobody); e.evict_unseen(&seen); for _ in 0..CACHE_MISS_GRACE {
e.evict_unseen(&nobody);
}
assert!(
e.cache.contains_key(&key),
"counter reset by a sighting, so still within grace"
);
}
#[test]
fn cached_probe_is_reused_until_refresh_ticks() {
let cached = Cached {
probe: ProbedFeatures::default(),
probed_tick: 10,
};
assert!(!is_stale(&cached, 10), "same tick is fresh");
assert!(
!is_stale(&cached, 10 + REFRESH_TICKS - 1),
"just under the window is still fresh"
);
assert!(
is_stale(&cached, 10 + REFRESH_TICKS),
"at the window the probe is refreshed"
);
}
#[test]
fn probe_overrides_a_misreporting_register() {
assert_eq!(
resolve_device_kind(Some(DeviceKind::Mouse), DeviceKind::Keyboard),
DeviceKind::Mouse
);
}
#[test]
fn probe_supplies_the_kind_on_the_direct_path() {
assert_eq!(
resolve_device_kind(Some(DeviceKind::Mouse), DeviceKind::Unknown),
DeviceKind::Mouse
);
}
#[test]
fn register_is_the_fallback_when_the_probe_is_absent_or_unmodelled() {
assert_eq!(
resolve_device_kind(None, DeviceKind::Mouse),
DeviceKind::Mouse
);
assert_eq!(
resolve_device_kind(Some(DeviceKind::Unknown), DeviceKind::Keyboard),
DeviceKind::Keyboard
);
assert_eq!(
resolve_device_kind(None, DeviceKind::Unknown),
DeviceKind::Unknown
);
}
}