use std::{collections::HashMap, sync::Arc};
use futures_concurrency::future::Join as _;
use hidpp::{
channel::HidppChannel,
receiver::{
self, Receiver,
bolt::{
DeviceConnection as BoltDeviceConnection, Event as BoltEvent, Receiver as BoltReceiver,
},
unifying::{
DeviceConnection as UnifyingDeviceConnection, Event as UnifyingEvent,
Receiver as UnifyingReceiver,
},
},
};
use openlogi_core::device::{DeviceInventory, DeviceKind, PairedDevice, ReceiverInfo};
use tokio::time::timeout;
use tracing::{debug, warn};
use crate::mappings::{map_kind, map_unifying_kind, resolve_device_kind};
use crate::route::DIRECT_DEVICE_INDEX;
use super::cache::{CacheKey, CacheOutcome, Cached, probe_or_reuse};
use super::features::ProbedFeatures;
use super::{ARRIVAL_DRAIN, MAX_BOLT_SLOTS, UNIFYING_SLOT_PROBE};
pub(super) struct NodeProbe {
pub(super) inventory: Option<DeviceInventory>,
pub(super) healthy: bool,
pub(super) outcomes: Vec<CacheOutcome>,
}
impl NodeProbe {
pub(super) fn failed() -> Self {
Self {
inventory: None,
healthy: false,
outcomes: Vec::new(),
}
}
}
pub(super) async fn probe_one(
info: async_hid::DeviceInfo,
channel: Arc<HidppChannel>,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> NodeProbe {
match receiver::detect(Arc::clone(&channel)) {
Some(Receiver::Bolt(bolt)) => probe_bolt_receiver(channel, info, bolt, cache, tick).await,
Some(Receiver::Unifying(unifying)) => {
probe_unifying_receiver(channel, info, unifying, cache, tick).await
}
None | Some(_) => {
probe_direct(channel, &info, cache, tick).await
}
}
}
async fn probe_bolt_receiver(
channel: Arc<HidppChannel>,
info: async_hid::DeviceInfo,
bolt: BoltReceiver,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> NodeProbe {
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"
);
}
let complete = pairing_count.is_some_and(|count| paired.len() == usize::from(count));
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
name: "Logi Bolt Receiver".to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
},
paired,
}),
healthy: complete,
outcomes,
}
}
async fn probe_unifying_receiver(
channel: Arc<HidppChannel>,
info: async_hid::DeviceInfo,
unifying: UnifyingReceiver,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> NodeProbe {
let unique_id = unifying.get_unique_id().await.ok();
let pairing_count = unifying.count_pairings().await.ok();
debug!(?pairing_count, "receiver reports pairing count");
let Some(connections) = drain_device_arrival_unifying(&unifying).await else {
return NodeProbe::failed();
};
debug!(events = connections.len(), "drained device-arrival events");
let receiver_uid_fallback;
let receiver_uid = if let Some(uid) = unique_id.as_deref() {
uid
} else {
tracing::warn!("Unifying receiver UID unavailable; cache isolation may be degraded");
receiver_uid_fallback = format!("pid:{:04x}", info.product_id);
&receiver_uid_fallback
};
let slot_results = connections
.iter()
.map(|conn| probe_unifying_slot(&channel, conn, receiver_uid, cache, tick))
.collect::<Vec<_>>()
.join()
.await;
let (paired, outcomes): (Vec<_>, Vec<_>) = slot_results.into_iter().flatten().unzip();
if let Some(count) = pairing_count
&& paired.len() != usize::from(count)
{
debug!(
expected = count,
found = paired.len(),
"online devices differ from pairing count; offline devices not yet surfaced for Unifying"
);
}
let healthy = pairing_count.is_some();
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
name: "Unifying Receiver".to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
},
paired,
}),
healthy,
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,
) -> NodeProbe {
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 capabilities = probe.capabilities;
let walk_succeeded = capabilities.is_some();
let caps = 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 NodeProbe {
inventory: None,
healthy: walk_succeeded,
outcomes: vec![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,
}],
};
NodeProbe {
inventory: Some(inventory),
healthy: true,
outcomes: vec![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 drain_device_arrival_unifying(
unifying: &UnifyingReceiver,
) -> Option<Vec<UnifyingDeviceConnection>> {
let rx = unifying.listen();
if let Err(e) = unifying.trigger_device_arrival().await {
debug!(error = ?e, "trigger_device_arrival failed; receiver may report no devices");
return None;
}
let mut out = Vec::new();
loop {
match timeout(ARRIVAL_DRAIN, rx.recv()).await {
Ok(Ok(UnifyingEvent::DeviceConnection(c))) => out.push(c),
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => break,
}
}
Some(out)
}
async fn probe_unifying_slot(
channel: &Arc<HidppChannel>,
event: &UnifyingDeviceConnection,
receiver_uid: &str,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> Option<(PairedDevice, CacheOutcome)> {
let slot = event.index;
let codename = read_codename(channel, slot).await;
debug!(
slot,
online = event.online,
wpid = format_args!("{:04x}", event.wpid),
kind = ?event.kind,
codename = ?codename,
"unifying paired slot"
);
let id = CacheKey::UnifyingSlot {
receiver_uid: receiver_uid.to_string(),
slot,
};
let cached = cache.get(&id);
let register_kind = map_unifying_kind(event.kind);
let probe_result = timeout(
UNIFYING_SLOT_PROBE,
probe_or_reuse(channel, slot, Some(id.clone()), cached, event.online, tick),
)
.await;
let (probe, outcome) = if let Ok(r) = probe_result {
r
} else {
debug!(slot, budget = ?UNIFYING_SLOT_PROBE,
"Unifying slot probe timed out; using cached data if available");
let probe = cached.map_or_else(ProbedFeatures::default, |c| c.probe.clone());
(probe, CacheOutcome::Seen(id))
};
let device = PairedDevice {
slot,
codename,
wpid: Some(event.wpid),
kind: resolve_device_kind(probe.kind, register_kind),
online: event.online,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
};
Some((device, outcome))
}
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)
}