use std::{collections::HashMap, sync::Arc};
use futures_concurrency::future::Join as _;
use hidpp::{
channel::HidppChannel,
device::Device,
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, seen};
use super::features::ProbedFeatures;
use super::{ARRIVAL_DRAIN, BOLT_SLOT_PROBE, MAX_BOLT_SLOTS, UNIFYING_SLOT_PROBE};
pub(super) struct NodeProbe {
pub(super) inventory: Option<DeviceInventory>,
pub(super) healthy: bool,
pub(super) complete: bool,
pub(super) outcomes: Vec<CacheOutcome>,
}
impl NodeProbe {
pub(super) fn failed() -> Self {
Self {
inventory: None,
healthy: false,
complete: 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 identities = Vec::new();
for slot in 1u8..=MAX_BOLT_SLOTS {
if let Some(identity) =
read_bolt_slot_identity(&bolt, &channel, by_slot.get(&slot), slot).await
{
identities.push(identity);
}
}
let slot_results = identities
.iter()
.map(|identity| walk_bolt_slot(&channel, identity, cache, tick))
.collect::<Vec<_>>()
.join()
.await;
let receiver = ReceiverInfo {
name: "Logi Bolt Receiver".to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
};
assemble_bolt_probe(receiver, pairing_count, slot_results)
}
pub(super) fn assemble_bolt_probe(
receiver: ReceiverInfo,
pairing_count: Option<u8>,
slot_results: Vec<(PairedDevice, CacheOutcome)>,
) -> NodeProbe {
let (paired, outcomes): (Vec<_>, Vec<_>) = slot_results.into_iter().unzip();
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, paired }),
healthy: complete,
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 mut connections: Vec<_> = connections
.into_iter()
.map(|c| (c.index, c))
.collect::<HashMap<_, _>>()
.into_values()
.collect();
connections.sort_by_key(|c| c.index);
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();
let complete = pairing_count.is_some_and(|count| paired.len() == usize::from(count));
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
name: crate::route::receiver_display_name(info.product_id).to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
},
paired,
}),
healthy,
complete,
outcomes,
}
}
struct BoltSlotIdentity {
slot: u8,
codename: Option<String>,
id: Option<CacheKey>,
online: bool,
register_kind: DeviceKind,
wpid: Option<u16>,
}
async fn read_bolt_slot_identity(
bolt: &BoltReceiver,
channel: &Arc<HidppChannel>,
event: Option<&BoltDeviceConnection>,
slot: u8,
) -> Option<BoltSlotIdentity> {
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,
});
Some(BoltSlotIdentity {
slot,
codename,
id,
online,
register_kind: map_kind(bolt_kind),
wpid,
})
}
async fn walk_bolt_slot(
channel: &Arc<HidppChannel>,
identity: &BoltSlotIdentity,
cache: &HashMap<CacheKey, Cached>,
tick: u64,
) -> (PairedDevice, CacheOutcome) {
let &BoltSlotIdentity {
slot,
online,
register_kind,
wpid,
..
} = identity;
let id = identity.id.clone();
let cached = id.as_ref().and_then(|i| cache.get(i));
let probe_result = timeout(
BOLT_SLOT_PROBE,
probe_or_reuse(channel, slot, id.clone(), cached, online, tick),
)
.await;
let (probe, outcome) = if let Ok(r) = probe_result {
r
} else {
debug!(slot, budget = ?BOLT_SLOT_PROBE,
"Bolt slot probe timed out; using cached data if available");
let probe = cached.map_or_else(ProbedFeatures::default, |c| c.probe.clone());
(probe, seen(id))
};
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: identity.codename.clone(),
wpid,
kind: resolve_device_kind(probe.kind, register_kind),
online,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
};
(device, outcome)
}
pub(super) fn preferred_direct_codename(marketing_name: Option<&str>, os_name: &str) -> String {
marketing_name
.filter(|name| !name.trim().is_empty())
.unwrap_or(os_name)
.to_string()
}
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 !walk_succeeded {
debug!(
vid = format_args!("{:04x}", info.vendor_id),
pid = format_args!("{:04x}", info.product_id),
"feature walk did not complete — transient probe failure, keeping last-known identity"
);
return NodeProbe {
inventory: None,
healthy: false,
complete: false,
outcomes: vec![seen(Some(CacheKey::Direct(info.id.clone())))],
};
}
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,
complete: walk_succeeded,
outcomes: vec![CacheOutcome::Unkeyed],
};
}
let codename = preferred_direct_codename(probe.marketing_name.as_deref(), &info.name);
debug!(os_name = %info.name, name = %codename, "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(codename),
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,
complete: 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>> {
if let Err(e) = unifying.set_wireless_notifications(true).await {
debug!(error = ?e, "enable wireless notifications failed");
}
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_unifying(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_unifying_features(channel, slot, &id, cached, tick),
)
.await;
let (probe, outcome, online) = 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), false)
};
let device = assemble_unifying_device(slot, codename, event.wpid, register_kind, probe, online);
Some((device, outcome))
}
pub(super) async fn probe_unifying_features(
channel: &Arc<HidppChannel>,
slot: u8,
id: &CacheKey,
cached: Option<&Cached>,
tick: u64,
) -> (ProbedFeatures, CacheOutcome, bool) {
let (probe, outcome) =
probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick).await;
let online = if matches!(outcome, CacheOutcome::Fresh(..) | CacheOutcome::Update(..)) {
true
} else {
Device::new(Arc::clone(channel), slot).await.is_ok()
};
(probe, outcome, online)
}
pub(super) fn assemble_unifying_device(
slot: u8,
codename: Option<String>,
wpid: u16,
register_kind: DeviceKind,
probe: ProbedFeatures,
online: bool,
) -> PairedDevice {
PairedDevice {
slot,
codename,
wpid: Some(wpid),
kind: resolve_device_kind(probe.kind, register_kind),
online,
battery: probe.battery,
model_info: probe.model_info,
capabilities: probe.capabilities,
}
}
async fn read_codename_unifying(channel: &HidppChannel, slot: u8) -> Option<String> {
let response = channel
.read_long_register(0xFF, 0xB5, [0x40 + slot - 1, 0x00, 0x00])
.await
.ok()?;
parse_codename_unifying(&response)
}
pub(super) fn parse_codename_unifying(response: &[u8]) -> Option<String> {
let len = usize::from(*response.get(1)?).min(response.len().saturating_sub(2));
core::str::from_utf8(response.get(2..2 + len)?)
.ok()
.map(str::to_string)
}
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)
}