use std::{
collections::HashMap,
fmt::Debug,
future::Future,
sync::Arc,
time::{Duration, Instant},
};
use futures_concurrency::future::Join as _;
use hidpp::{
channel::HidppChannel,
receiver::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;
use super::{
NodeProbe, PassContext, ProbeTimeouts, ProbeVerdict, RECEIVER_OPERATION_TIMEOUT,
RECEIVER_UID_TIMEOUT, UNIFYING_TRIGGER_ATTEMPT_TIMEOUT, UNIFYING_TRIGGER_RETRY_DELAY,
};
use crate::backend::NodeInfo;
use crate::host_lock::ReceiverRegisterPhase;
use crate::inventory::cache::{CacheKey, CacheOutcome, Cached, is_stale, probe_or_reuse};
use crate::inventory::events::EventSubscriptionHandle;
use crate::inventory::features::ProbedFeatures;
use crate::inventory::mappings::{map_unifying_kind, resolve_device_kind};
pub(super) async fn probe_unifying_receiver(
channel: Arc<HidppChannel>,
info: NodeInfo,
unifying: UnifyingReceiver,
registers: ReceiverRegisterPhase,
pass: PassContext<'_>,
) -> NodeProbe {
let pairing_count = match timeout(RECEIVER_OPERATION_TIMEOUT, unifying.count_pairings()).await {
Ok(Ok(count)) => count,
Ok(Err(error)) => {
debug!(?error, "receiver pairing-count read failed");
return NodeProbe::failed();
}
Err(_) => {
debug!(
budget = ?RECEIVER_OPERATION_TIMEOUT,
"receiver pairing-count read timed out"
);
return NodeProbe::failed();
}
};
debug!(pairing_count, "receiver reports pairing count");
let unique_id = timeout(RECEIVER_UID_TIMEOUT, unifying.get_unique_id())
.await
.ok()
.and_then(Result::ok);
let Some(connections) = drain_device_arrival(
&unifying,
pairing_count,
pass.subscriptions,
pass.timeouts.arrival_drain,
)
.await
else {
return NodeProbe::arrival_replay_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, pass))
.collect::<Vec<_>>()
.join()
.await;
drop(registers);
let (paired, outcomes): (Vec<_>, Vec<_>) = slot_results.into_iter().flatten().unzip();
if paired.len() != usize::from(pairing_count) {
debug!(
expected = pairing_count,
found = paired.len(),
"arrival drain reported fewer slots than the pairing count"
);
}
let complete = paired.len() == usize::from(pairing_count);
NodeProbe {
inventory: Some(DeviceInventory {
receiver: ReceiverInfo {
name: crate::channel::route::receiver_display_name(info.product_id).to_string(),
vendor_id: info.vendor_id,
product_id: info.product_id,
unique_id,
},
paired,
}),
verdict: ProbeVerdict::Healthy { complete },
outcomes,
}
}
async fn drain_device_arrival(
unifying: &UnifyingReceiver,
pairing_count: u8,
subscriptions: Option<&EventSubscriptionHandle>,
idle_timeout: Duration,
) -> Option<Vec<UnifyingDeviceConnection>> {
let rx = unifying.listen();
let _receiver_snapshot = subscriptions.map(EventSubscriptionHandle::begin_receiver_snapshot);
retry_arrival_trigger(
|| unifying.trigger_device_arrival(),
UNIFYING_TRIGGER_ATTEMPT_TIMEOUT,
UNIFYING_TRIGGER_RETRY_DELAY,
)
.await?;
let mut out = Vec::new();
loop {
match timeout(idle_timeout, rx.recv()).await {
Ok(Ok(UnifyingEvent::DeviceConnection(connection))) => out.push(connection),
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => break,
}
}
let notification_result = timeout(
RECEIVER_OPERATION_TIMEOUT,
unifying.set_wireless_notifications(true),
)
.await
.map_err(|_| ())
.and_then(|result| result.map_err(|_| ()));
if !out.is_empty() || pairing_count == 0 {
if notification_result.is_err() {
debug!("enable persistent wireless notifications failed");
}
return Some(out);
}
if notification_result.is_err() {
debug!("enable wireless notifications failed");
return None;
}
retry_arrival_trigger(
|| unifying.trigger_device_arrival(),
UNIFYING_TRIGGER_ATTEMPT_TIMEOUT,
UNIFYING_TRIGGER_RETRY_DELAY,
)
.await?;
out.clear();
loop {
match timeout(idle_timeout, rx.recv()).await {
Ok(Ok(UnifyingEvent::DeviceConnection(connection))) => out.push(connection),
Ok(Ok(_)) => {}
Ok(Err(_)) | Err(_) => return Some(out),
}
}
}
pub(in crate::inventory) async fn retry_arrival_trigger<F, Fut, E>(
mut trigger: F,
attempt_timeout: Duration,
retry_delay: Duration,
) -> Option<()>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<(), E>>,
E: Debug,
{
match timeout(attempt_timeout, trigger()).await {
Ok(Ok(())) => return Some(()),
Ok(Err(error)) => debug!(?error, "trigger_device_arrival failed; retrying once"),
Err(_) => debug!(
?attempt_timeout,
"trigger_device_arrival timed out; retrying once"
),
}
tokio::time::sleep(retry_delay).await;
match timeout(attempt_timeout, trigger()).await {
Ok(Ok(())) => Some(()),
Ok(Err(error)) => {
debug!(
?error,
"trigger_device_arrival retry failed; receiver may report no devices"
);
None
}
Err(_) => {
debug!(
?attempt_timeout,
"trigger_device_arrival retry timed out; receiver may report no devices"
);
None
}
}
}
pub(in crate::inventory) async fn probe_unifying_slot(
channel: &Arc<HidppChannel>,
event: &UnifyingDeviceConnection,
receiver_uid: &str,
pass: PassContext<'_>,
) -> Option<(PairedDevice, CacheOutcome)> {
let slot = event.index;
let id = CacheKey::UnifyingSlot {
receiver_uid: receiver_uid.to_string(),
slot,
};
let cached = pass.cache.get(&id);
let register_kind = map_unifying_kind(event.kind);
let probe_budget = unifying_probe_budget(cached, pass.now, pass.timeouts);
let probe_result = timeout(
probe_budget,
probe_or_reuse(
channel,
slot,
Some(id.clone()),
cached,
event.online,
pass.now,
pass.subscriptions,
),
)
.await;
let (probe, outcome) = if let Ok(result) = probe_result {
result
} else {
debug!(slot, budget = ?probe_budget,
"Unifying slot probe timed out; using cached data if available");
let probe = cached.map_or_else(ProbedFeatures::default, |entry| entry.probe.clone());
(probe, CacheOutcome::Seen(id))
};
let codename = if let Some(name) = probe.marketing_name.clone() {
Some(name)
} else if probe.capabilities.is_some() {
read_codename(channel, slot).await
} else {
None
};
debug!(
slot,
online = event.online,
wpid = format_args!("{:04x}", event.wpid),
kind = ?event.kind,
codename = ?codename,
"unifying paired slot"
);
let device = assemble_unifying_device(
slot,
codename,
event.wpid,
register_kind,
probe,
event.online,
pass.settings_revision(slot),
);
Some((device, outcome))
}
pub(in crate::inventory) fn unifying_probe_budget(
cached: Option<&Cached>,
now: Instant,
timeouts: &ProbeTimeouts,
) -> Duration {
if cached.is_some_and(|entry| !is_stale(entry, now)) {
timeouts.unifying_cached_slot_probe
} else {
timeouts.unifying_slot_probe
}
}
pub(in crate::inventory) fn assemble_unifying_device(
slot: u8,
codename: Option<String>,
wpid: u16,
register_kind: DeviceKind,
probe: ProbedFeatures,
online: bool,
settings_revision: u64,
) -> 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,
settings_revision,
}
}
async fn read_codename(channel: &HidppChannel, slot: u8) -> Option<String> {
let response = channel
.read_long_sub_register(0xFF, 0xB5, 0x40 + slot - 1, [0x00, 0x00])
.await
.ok()?;
parse_codename(&response)
}
pub(in crate::inventory) fn parse_codename(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)
}