Skip to content

Commit d3f66e3

Browse files
committed
fix(hid): confirm Unifying reachability after battery errors
1 parent b2de5b3 commit d3f66e3

2 files changed

Lines changed: 108 additions & 13 deletions

File tree

crates/openlogi-hid/src/inventory/probe.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use tracing::{debug, warn};
2222
use crate::mappings::{map_kind, map_unifying_kind, resolve_device_kind};
2323
use crate::route::DIRECT_DEVICE_INDEX;
2424

25-
use super::cache::{CacheKey, CacheOutcome, Cached, is_stale, probe_or_reuse, seen};
25+
use super::cache::{CacheKey, CacheOutcome, Cached, probe_or_reuse, seen};
2626
use super::features::ProbedFeatures;
2727
use super::{ARRIVAL_DRAIN, BOLT_SLOT_PROBE, MAX_BOLT_SLOTS, UNIFYING_SLOT_PROBE};
2828

@@ -602,25 +602,22 @@ async fn probe_unifying_slot(
602602
/// A successful full probe ([`CacheOutcome::Fresh`]) confirms liveness on a
603603
/// cache miss/stale entry. A fresh cached entry normally refreshes its battery,
604604
/// whose successful response ([`CacheOutcome::Update`]) is the liveness check.
605-
/// Devices without that feature get a root ping instead.
606-
async fn probe_unifying_features(
605+
/// A failed battery refresh, or a device without that feature, gets a root ping
606+
/// before being treated as offline.
607+
pub(super) async fn probe_unifying_features(
607608
channel: &Arc<HidppChannel>,
608609
slot: u8,
609610
id: &CacheKey,
610611
cached: Option<&Cached>,
611612
tick: u64,
612613
) -> (ProbedFeatures, CacheOutcome, bool) {
613-
if let Some(cached) = cached
614-
&& !is_stale(cached, tick)
615-
&& cached.battery_index.is_none()
616-
{
617-
let online = Device::new(Arc::clone(channel), slot).await.is_ok();
618-
return (cached.probe.clone(), CacheOutcome::Seen(id.clone()), online);
619-
}
620-
621614
let (probe, outcome) =
622615
probe_or_reuse(channel, slot, Some(id.clone()), cached, true, tick).await;
623-
let online = matches!(outcome, CacheOutcome::Fresh(..) | CacheOutcome::Update(..));
616+
let online = if matches!(outcome, CacheOutcome::Fresh(..) | CacheOutcome::Update(..)) {
617+
true
618+
} else {
619+
Device::new(Arc::clone(channel), slot).await.is_ok()
620+
};
624621
(probe, outcome, online)
625622
}
626623

crates/openlogi-hid/src/inventory/tests.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
use std::collections::HashSet;
1+
use std::{collections::HashSet, error::Error, io, sync::Arc};
22

3+
use hidpp::channel::{HidppChannel, RawHidChannel};
34
use openlogi_core::device::{
45
Capabilities, DeviceInventory, DeviceKind, PairedDevice, ReceiverInfo,
56
};
7+
use tokio::sync::{Mutex, mpsc};
68

79
use super::cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached, REFRESH_TICKS, is_stale};
810
use super::probe::{
911
NodeProbe, assemble_bolt_probe, assemble_unifying_device, parse_codename_unifying,
12+
probe_unifying_features,
1013
};
1114
use super::{Enumerator, ONESHOT_ATTEMPTS, one_shot_should_stop};
1215
use crate::inventory::features::ProbedFeatures;
@@ -105,6 +108,101 @@ fn unifying_cached_features_do_not_override_current_liveness() {
105108
);
106109
}
107110

111+
struct BatteryErrorPingChannel {
112+
incoming_tx: mpsc::UnboundedSender<Vec<u8>>,
113+
incoming_rx: Mutex<mpsc::UnboundedReceiver<Vec<u8>>>,
114+
}
115+
116+
impl BatteryErrorPingChannel {
117+
fn new() -> Self {
118+
let (incoming_tx, incoming_rx) = mpsc::unbounded_channel();
119+
Self {
120+
incoming_tx,
121+
incoming_rx: Mutex::new(incoming_rx),
122+
}
123+
}
124+
}
125+
126+
#[hidpp::async_trait]
127+
impl RawHidChannel for BatteryErrorPingChannel {
128+
fn vendor_id(&self) -> u16 {
129+
0x046d
130+
}
131+
132+
fn product_id(&self) -> u16 {
133+
0xc52b
134+
}
135+
136+
async fn write_report(&self, src: &[u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
137+
let mut response = src.to_vec();
138+
if src.get(2).copied() != Some(0) {
139+
if response.len() < 7 {
140+
return Err(Box::new(io::Error::other("short mock HID++ report")));
141+
}
142+
response[2] = 0xff;
143+
response[3] = src[2];
144+
response[4] = src[3];
145+
response[5] = 0x08;
146+
response[6] = 0;
147+
}
148+
self.incoming_tx.send(response).map_err(|_| {
149+
Box::new(io::Error::other("mock HID++ response receiver closed"))
150+
as Box<dyn Error + Send + Sync>
151+
})?;
152+
Ok(src.len())
153+
}
154+
155+
async fn read_report(&self, buf: &mut [u8]) -> Result<usize, Box<dyn Error + Send + Sync>> {
156+
let Some(report) = self.incoming_rx.lock().await.recv().await else {
157+
return Err(Box::new(io::Error::other(
158+
"mock HID++ response sender closed",
159+
)));
160+
};
161+
let len = report.len().min(buf.len());
162+
buf[..len].copy_from_slice(&report[..len]);
163+
Ok(len)
164+
}
165+
166+
fn supports_short_long_hidpp(&self) -> Option<(bool, bool)> {
167+
Some((true, true))
168+
}
169+
170+
async fn get_report_descriptor(
171+
&self,
172+
_buf: &mut [u8],
173+
) -> Result<usize, Box<dyn Error + Send + Sync>> {
174+
unreachable!("mock declares HID++ support")
175+
}
176+
}
177+
178+
#[tokio::test]
179+
async fn unifying_battery_failure_uses_root_ping_before_marking_offline() {
180+
let channel = Arc::new(
181+
HidppChannel::from_raw_channel(BatteryErrorPingChannel::new())
182+
.await
183+
.unwrap_or_else(|e| panic!("mock HID++ channel should open: {e}")),
184+
);
185+
let id = CacheKey::UnifyingSlot {
186+
receiver_uid: "receiver".to_string(),
187+
slot: 1,
188+
};
189+
let cached = Cached {
190+
probe: ProbedFeatures {
191+
capabilities: Some(Capabilities::default()),
192+
..ProbedFeatures::default()
193+
},
194+
battery_index: Some(4),
195+
probed_tick: 10,
196+
};
197+
198+
let (_, _, online) = probe_unifying_features(&channel, 1, &id, Some(&cached), 11).await;
199+
200+
assert!(
201+
online,
202+
"a failed battery refresh is inconclusive when the root ping still answers"
203+
);
204+
}
205+
108206
fn inventory(slots: &[u8]) -> Vec<DeviceInventory> {
109207
vec![DeviceInventory {
110208
receiver: ReceiverInfo {

0 commit comments

Comments
 (0)