1use crate::hid::DeviceRoute;
11
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct PhysicalDeviceKey(String);
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub enum DeviceStableId {
25 Bolt {
28 receiver_uid: String,
30 slot: u8,
32 },
33 Direct {
36 vendor_id: u16,
38 product_id: u16,
40 identity: DeviceIdentity,
42 },
43 RawHid {
46 vendor_id: u16,
48 product_id: u16,
50 usage_page: u16,
52 usage_id: u16,
54 identity: String,
56 },
57 Unknown {
60 slot: u8,
62 identity: DeviceIdentity,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
69pub enum DeviceIdentity {
70 Serial(String),
72 Unit([u8; 4]),
74}
75
76impl DeviceIdentity {
77 #[must_use]
79 pub fn from_parts(serial: Option<&str>, unit_id: [u8; 4]) -> Self {
80 serial
81 .filter(|serial| !serial.is_empty())
82 .map_or(Self::Unit(unit_id), |serial| {
83 Self::Serial(serial.to_ascii_lowercase())
84 })
85 }
86}
87
88impl DeviceStableId {
89 #[must_use]
93 pub fn from_parts(
94 route: Option<&DeviceRoute>,
95 slot: u8,
96 serial: Option<&str>,
97 unit_id: [u8; 4],
98 ) -> Self {
99 match route {
100 Some(
101 DeviceRoute::Bolt { receiver_uid, slot }
102 | DeviceRoute::Unifying { receiver_uid, slot },
103 ) => Self::Bolt {
104 receiver_uid: receiver_uid.to_ascii_lowercase(),
105 slot: *slot,
106 },
107 Some(DeviceRoute::Direct {
108 vendor_id,
109 product_id,
110 }) => Self::Direct {
111 vendor_id: *vendor_id,
112 product_id: *product_id,
113 identity: DeviceIdentity::from_parts(serial, unit_id),
114 },
115 Some(DeviceRoute::RawHid {
116 vendor_id,
117 product_id,
118 usage_page,
119 usage_id,
120 identity,
121 }) => Self::RawHid {
122 vendor_id: *vendor_id,
123 product_id: *product_id,
124 usage_page: *usage_page,
125 usage_id: *usage_id,
126 identity: identity.to_ascii_lowercase(),
127 },
128 None => Self::Unknown {
129 slot,
130 identity: DeviceIdentity::from_parts(serial, unit_id),
131 },
132 }
133 }
134
135 #[must_use]
146 pub fn runtime_key(&self) -> String {
147 match self {
148 Self::Bolt { receiver_uid, slot } => format!("receiver:{receiver_uid}:slot:{slot}"),
149 Self::Direct {
150 vendor_id,
151 product_id,
152 identity,
153 } => format!("direct:{vendor_id:04x}:{product_id:04x}:{}", identity.key()),
154 Self::RawHid {
155 vendor_id,
156 product_id,
157 usage_page,
158 usage_id,
159 identity,
160 } => format!(
161 "raw:{vendor_id:04x}:{product_id:04x}:{usage_page:04x}:{usage_id:04x}:{identity}"
162 ),
163 Self::Unknown { slot, identity } => format!("unknown:slot:{slot}:{}", identity.key()),
164 }
165 }
166
167 #[must_use]
177 pub fn route_key(&self) -> String {
178 match self {
179 Self::Direct {
180 vendor_id,
181 product_id,
182 ..
183 } => format!("direct:{vendor_id:04x}:{product_id:04x}"),
184 Self::Bolt { .. } | Self::RawHid { .. } | Self::Unknown { .. } => self.runtime_key(),
185 }
186 }
187
188 #[must_use]
196 pub fn physical_key(&self) -> Option<PhysicalDeviceKey> {
197 match self {
198 Self::Bolt { .. } => Some(PhysicalDeviceKey(self.runtime_key())),
199 Self::Direct { identity, .. } | Self::Unknown { identity, .. } => identity
200 .is_physical()
201 .then(|| PhysicalDeviceKey(self.runtime_key())),
202 Self::RawHid { identity, .. } => {
203 raw_identity_is_physical(identity).then(|| PhysicalDeviceKey(self.runtime_key()))
204 }
205 }
206 }
207}
208
209impl DeviceIdentity {
210 pub(crate) fn is_physical(&self) -> bool {
217 match self {
218 Self::Serial(serial) => !serial.is_empty(),
219 Self::Unit(unit) => *unit != [0; 4],
220 }
221 }
222
223 pub(crate) fn key(&self) -> String {
227 match self {
228 Self::Serial(serial) => format!("serial:{serial}"),
229 Self::Unit(unit) => format!("unit:{}", hex_unit(*unit)),
230 }
231 }
232}
233
234impl PhysicalDeviceKey {
235 #[must_use]
241 pub fn parse(value: &str) -> Option<Self> {
242 if receiver_key_is_valid(value)
243 || direct_identity_fragment(value).is_some_and(identity_fragment_is_physical)
244 || raw_identity_fragment(value).is_some_and(raw_identity_is_physical)
245 || unknown_identity_fragment(value).is_some_and(identity_fragment_is_physical)
246 || identity_fragment_is_physical(value)
247 {
248 Some(Self(value.to_string()))
249 } else {
250 None
251 }
252 }
253
254 #[must_use]
257 pub fn is_transient(value: &str) -> bool {
258 direct_identity_fragment(value)
259 .or_else(|| unknown_identity_fragment(value))
260 .is_some_and(|identity| identity == "unit:00000000")
261 || raw_identity_fragment(value)
262 .is_some_and(|identity| identity.starts_with("id:") || identity.is_empty())
263 }
264
265 #[must_use]
267 pub fn as_str(&self) -> &str {
268 &self.0
269 }
270
271 #[must_use]
273 pub fn into_string(self) -> String {
274 self.0
275 }
276}
277
278fn receiver_key_is_valid(value: &str) -> bool {
279 value
280 .strip_prefix("receiver:")
281 .and_then(|rest| rest.rsplit_once(":slot:"))
282 .is_some_and(|(receiver_uid, slot)| !receiver_uid.is_empty() && slot.parse::<u8>().is_ok())
283}
284
285fn direct_identity_fragment(value: &str) -> Option<&str> {
286 let mut parts = value.strip_prefix("direct:")?.splitn(3, ':');
287 let vendor_id = parts.next()?;
288 let product_id = parts.next()?;
289 let identity = parts.next()?;
290 (is_hex_word(vendor_id) && is_hex_word(product_id)).then_some(identity)
291}
292
293fn raw_identity_fragment(value: &str) -> Option<&str> {
294 let mut parts = value.strip_prefix("raw:")?.splitn(5, ':');
295 let vendor_id = parts.next()?;
296 let product_id = parts.next()?;
297 let usage_page = parts.next()?;
298 let usage_id = parts.next()?;
299 let identity = parts.next()?;
300 (is_hex_word(vendor_id)
301 && is_hex_word(product_id)
302 && is_hex_word(usage_page)
303 && is_hex_word(usage_id)
304 && !identity.is_empty())
305 .then_some(identity)
306}
307
308fn unknown_identity_fragment(value: &str) -> Option<&str> {
309 let (slot, identity) = value.strip_prefix("unknown:slot:")?.split_once(':')?;
310 slot.parse::<u8>().ok().map(|_| identity)
311}
312
313fn identity_fragment_is_physical(value: &str) -> bool {
314 value
315 .strip_prefix("serial:")
316 .is_some_and(|serial| !serial.is_empty())
317 || value.strip_prefix("unit:").is_some_and(|unit| {
318 unit.len() == 8
319 && unit.bytes().all(|byte| byte.is_ascii_hexdigit())
320 && unit != "00000000"
321 })
322}
323
324fn raw_identity_is_physical(value: &str) -> bool {
325 value
326 .strip_prefix("serial:")
327 .is_some_and(|serial| !serial.is_empty())
328 || value
329 .strip_prefix("stable:")
330 .is_some_and(|identity| !identity.is_empty())
331}
332
333fn is_hex_word(value: &str) -> bool {
334 value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
335}
336
337fn hex_unit(unit: [u8; 4]) -> String {
338 format!(
339 "{:02x}{:02x}{:02x}{:02x}",
340 unit[0], unit[1], unit[2], unit[3]
341 )
342}
343
344#[cfg(test)]
345mod tests {
346 use crate::hid::DeviceRoute;
347
348 use super::{DeviceIdentity, DeviceStableId, PhysicalDeviceKey};
349
350 #[test]
351 fn unifying_route_maps_to_bolt_stable_id() {
352 let route = DeviceRoute::Unifying {
353 receiver_uid: "DA2699E1".into(),
354 slot: 2,
355 };
356 let id = DeviceStableId::from_parts(Some(&route), 2, None, [0; 4]);
357 assert!(
360 matches!(id, DeviceStableId::Bolt { ref receiver_uid, slot: 2 }
361 if receiver_uid == "da2699e1"),
362 "Unifying route should map to DeviceStableId::Bolt with case-folded uid"
363 );
364 }
365
366 #[test]
367 fn bolt_and_unifying_same_uid_slot_produce_identical_stable_id() {
368 let bolt = DeviceRoute::Bolt {
369 receiver_uid: "AABB".into(),
370 slot: 1,
371 };
372 let unifying = DeviceRoute::Unifying {
373 receiver_uid: "AABB".into(),
374 slot: 1,
375 };
376 assert_eq!(
377 DeviceStableId::from_parts(Some(&bolt), 1, None, [0; 4]),
378 DeviceStableId::from_parts(Some(&unifying), 1, None, [0; 4]),
379 );
380 }
381
382 #[test]
383 fn config_key_is_physical_not_model_scoped() {
384 let route = DeviceRoute::Bolt {
385 receiver_uid: "AABB".into(),
386 slot: 2,
387 };
388
389 assert_eq!(
390 DeviceStableId::from_parts(Some(&route), 2, Some("SERIAL"), [1, 2, 3, 4])
391 .physical_key()
392 .map(PhysicalDeviceKey::into_string),
393 Some("receiver:aabb:slot:2".to_string())
394 );
395 }
396
397 #[test]
398 fn zero_unit_direct_identity_is_transient() {
399 let route = DeviceRoute::Direct {
400 vendor_id: 0x046d,
401 product_id: 0xb023,
402 };
403 let id = DeviceStableId::from_parts(Some(&route), 0xff, None, [0; 4]);
404
405 assert!(id.physical_key().is_none());
406 assert_eq!(id.runtime_key(), "direct:046d:b023:unit:00000000");
407 assert!(PhysicalDeviceKey::is_transient(&id.runtime_key()));
408 assert!(PhysicalDeviceKey::parse(&id.runtime_key()).is_none());
409 }
410
411 #[test]
412 fn serial_identity_is_physical_when_unit_is_zero() {
413 let route = DeviceRoute::Direct {
414 vendor_id: 0x046d,
415 product_id: 0xb023,
416 };
417 let key = DeviceStableId::from_parts(Some(&route), 0xff, Some("ABCDEF"), [0; 4])
418 .physical_key()
419 .map(PhysicalDeviceKey::into_string);
420
421 assert_eq!(key, Some("direct:046d:b023:serial:abcdef".to_string()));
422 }
423
424 #[test]
425 fn nonzero_unit_identity_is_physical_without_serial() {
426 let route = DeviceRoute::Direct {
427 vendor_id: 0x046d,
428 product_id: 0xb023,
429 };
430 let key = DeviceStableId::from_parts(Some(&route), 0xff, None, [0xa3, 0x93, 0xca, 0xe0])
431 .physical_key()
432 .map(PhysicalDeviceKey::into_string);
433
434 assert_eq!(key, Some("direct:046d:b023:unit:a393cae0".to_string()));
435 }
436
437 #[test]
438 fn parser_distinguishes_physical_keys_from_legacy_model_keys() {
439 assert!(PhysicalDeviceKey::parse("receiver:d0289db2:slot:1").is_some());
440 assert!(PhysicalDeviceKey::parse("direct:046d:b023:unit:a393cae0").is_some());
441 assert!(PhysicalDeviceKey::parse("2b034").is_none());
442 }
443
444 #[test]
445 fn raw_os_identity_is_transient_across_reconnects() {
446 let old = DeviceRoute::RawHid {
447 vendor_id: 0x046d,
448 product_id: 0xc900,
449 usage_page: 0xff43,
450 usage_id: 0x0202,
451 identity: "id:old-node".into(),
452 };
453 let new = DeviceRoute::RawHid {
454 vendor_id: 0x046d,
455 product_id: 0xc900,
456 usage_page: 0xff43,
457 usage_id: 0x0202,
458 identity: "id:new-node".into(),
459 };
460 let old_id = DeviceStableId::from_parts(Some(&old), 0xff, None, [0; 4]);
461 let new_id = DeviceStableId::from_parts(Some(&new), 0xff, None, [0; 4]);
462 assert_ne!(old_id.runtime_key(), new_id.runtime_key());
463 assert!(old_id.physical_key().is_none());
464 assert!(new_id.physical_key().is_none());
465 }
466
467 #[test]
468 fn raw_serial_identity_survives_a_changed_os_node() {
469 let route = DeviceRoute::RawHid {
470 vendor_id: 0x046d,
471 product_id: 0xc900,
472 usage_page: 0xff43,
473 usage_id: 0x0202,
474 identity: "serial:glow-1".into(),
475 };
476 let key = DeviceStableId::from_parts(Some(&route), 0xff, Some("glow-1"), [0; 4])
477 .physical_key()
478 .map(PhysicalDeviceKey::into_string);
479 assert_eq!(key, Some("raw:046d:c900:ff43:0202:serial:glow-1".into()));
480 }
481
482 #[test]
483 fn a_direct_route_key_drops_the_device_identity() {
484 let id = DeviceStableId::Direct {
487 vendor_id: 0x046d,
488 product_id: 0xc08d,
489 identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
490 };
491 assert_eq!(id.runtime_key(), "direct:046d:c08d:unit:6be9d300");
492 assert_eq!(id.route_key(), "direct:046d:c08d");
493 }
494
495 #[test]
496 fn other_routes_keep_their_runtime_key() {
497 let bolt = DeviceStableId::Bolt {
502 receiver_uid: "82839805".to_string(),
503 slot: 1,
504 };
505 assert_eq!(bolt.route_key(), bolt.runtime_key());
506
507 let unknown = DeviceStableId::Unknown {
508 slot: 255,
509 identity: DeviceIdentity::Unit([1, 2, 3, 4]),
510 };
511 assert_eq!(unknown.route_key(), unknown.runtime_key());
512 }
513}