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]
175 pub fn physical_key(&self) -> Option<PhysicalDeviceKey> {
176 match self {
177 Self::Bolt { .. } => Some(PhysicalDeviceKey(self.runtime_key())),
178 Self::Direct { identity, .. } | Self::Unknown { identity, .. } => identity
179 .is_physical()
180 .then(|| PhysicalDeviceKey(self.runtime_key())),
181 Self::RawHid { identity, .. } => {
182 raw_identity_is_physical(identity).then(|| PhysicalDeviceKey(self.runtime_key()))
183 }
184 }
185 }
186}
187
188impl DeviceIdentity {
189 fn is_physical(&self) -> bool {
190 match self {
191 Self::Serial(serial) => !serial.is_empty(),
192 Self::Unit(unit) => *unit != [0; 4],
193 }
194 }
195
196 fn key(&self) -> String {
197 match self {
198 Self::Serial(serial) => format!("serial:{serial}"),
199 Self::Unit(unit) => format!("unit:{}", hex_unit(*unit)),
200 }
201 }
202}
203
204impl PhysicalDeviceKey {
205 #[must_use]
211 pub fn parse(value: &str) -> Option<Self> {
212 if receiver_key_is_valid(value)
213 || direct_identity_fragment(value).is_some_and(identity_fragment_is_physical)
214 || raw_identity_fragment(value).is_some_and(raw_identity_is_physical)
215 || unknown_identity_fragment(value).is_some_and(identity_fragment_is_physical)
216 {
217 Some(Self(value.to_string()))
218 } else {
219 None
220 }
221 }
222
223 #[must_use]
226 pub fn is_transient(value: &str) -> bool {
227 direct_identity_fragment(value)
228 .or_else(|| unknown_identity_fragment(value))
229 .is_some_and(|identity| identity == "unit:00000000")
230 || raw_identity_fragment(value)
231 .is_some_and(|identity| identity.starts_with("id:") || identity.is_empty())
232 }
233
234 #[must_use]
236 pub fn as_str(&self) -> &str {
237 &self.0
238 }
239
240 #[must_use]
242 pub fn into_string(self) -> String {
243 self.0
244 }
245}
246
247fn receiver_key_is_valid(value: &str) -> bool {
248 value
249 .strip_prefix("receiver:")
250 .and_then(|rest| rest.rsplit_once(":slot:"))
251 .is_some_and(|(receiver_uid, slot)| !receiver_uid.is_empty() && slot.parse::<u8>().is_ok())
252}
253
254fn direct_identity_fragment(value: &str) -> Option<&str> {
255 let mut parts = value.strip_prefix("direct:")?.splitn(3, ':');
256 let vendor_id = parts.next()?;
257 let product_id = parts.next()?;
258 let identity = parts.next()?;
259 (is_hex_word(vendor_id) && is_hex_word(product_id)).then_some(identity)
260}
261
262fn raw_identity_fragment(value: &str) -> Option<&str> {
263 let mut parts = value.strip_prefix("raw:")?.splitn(5, ':');
264 let vendor_id = parts.next()?;
265 let product_id = parts.next()?;
266 let usage_page = parts.next()?;
267 let usage_id = parts.next()?;
268 let identity = parts.next()?;
269 (is_hex_word(vendor_id)
270 && is_hex_word(product_id)
271 && is_hex_word(usage_page)
272 && is_hex_word(usage_id)
273 && !identity.is_empty())
274 .then_some(identity)
275}
276
277fn unknown_identity_fragment(value: &str) -> Option<&str> {
278 let (slot, identity) = value.strip_prefix("unknown:slot:")?.split_once(':')?;
279 slot.parse::<u8>().ok().map(|_| identity)
280}
281
282fn identity_fragment_is_physical(value: &str) -> bool {
283 value
284 .strip_prefix("serial:")
285 .is_some_and(|serial| !serial.is_empty())
286 || value.strip_prefix("unit:").is_some_and(|unit| {
287 unit.len() == 8
288 && unit.bytes().all(|byte| byte.is_ascii_hexdigit())
289 && unit != "00000000"
290 })
291}
292
293fn raw_identity_is_physical(value: &str) -> bool {
294 value
295 .strip_prefix("serial:")
296 .is_some_and(|serial| !serial.is_empty())
297 || value
298 .strip_prefix("stable:")
299 .is_some_and(|identity| !identity.is_empty())
300}
301
302fn is_hex_word(value: &str) -> bool {
303 value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
304}
305
306fn hex_unit(unit: [u8; 4]) -> String {
307 format!(
308 "{:02x}{:02x}{:02x}{:02x}",
309 unit[0], unit[1], unit[2], unit[3]
310 )
311}
312
313#[cfg(test)]
314mod tests {
315 use crate::hid::DeviceRoute;
316
317 use super::{DeviceStableId, PhysicalDeviceKey};
318
319 #[test]
320 fn unifying_route_maps_to_bolt_stable_id() {
321 let route = DeviceRoute::Unifying {
322 receiver_uid: "DA2699E1".into(),
323 slot: 2,
324 };
325 let id = DeviceStableId::from_parts(Some(&route), 2, None, [0; 4]);
326 assert!(
329 matches!(id, DeviceStableId::Bolt { ref receiver_uid, slot: 2 }
330 if receiver_uid == "da2699e1"),
331 "Unifying route should map to DeviceStableId::Bolt with case-folded uid"
332 );
333 }
334
335 #[test]
336 fn bolt_and_unifying_same_uid_slot_produce_identical_stable_id() {
337 let bolt = DeviceRoute::Bolt {
338 receiver_uid: "AABB".into(),
339 slot: 1,
340 };
341 let unifying = DeviceRoute::Unifying {
342 receiver_uid: "AABB".into(),
343 slot: 1,
344 };
345 assert_eq!(
346 DeviceStableId::from_parts(Some(&bolt), 1, None, [0; 4]),
347 DeviceStableId::from_parts(Some(&unifying), 1, None, [0; 4]),
348 );
349 }
350
351 #[test]
352 fn config_key_is_physical_not_model_scoped() {
353 let route = DeviceRoute::Bolt {
354 receiver_uid: "AABB".into(),
355 slot: 2,
356 };
357
358 assert_eq!(
359 DeviceStableId::from_parts(Some(&route), 2, Some("SERIAL"), [1, 2, 3, 4])
360 .physical_key()
361 .map(PhysicalDeviceKey::into_string),
362 Some("receiver:aabb:slot:2".to_string())
363 );
364 }
365
366 #[test]
367 fn zero_unit_direct_identity_is_transient() {
368 let route = DeviceRoute::Direct {
369 vendor_id: 0x046d,
370 product_id: 0xb023,
371 };
372 let id = DeviceStableId::from_parts(Some(&route), 0xff, None, [0; 4]);
373
374 assert!(id.physical_key().is_none());
375 assert_eq!(id.runtime_key(), "direct:046d:b023:unit:00000000");
376 assert!(PhysicalDeviceKey::is_transient(&id.runtime_key()));
377 assert!(PhysicalDeviceKey::parse(&id.runtime_key()).is_none());
378 }
379
380 #[test]
381 fn serial_identity_is_physical_when_unit_is_zero() {
382 let route = DeviceRoute::Direct {
383 vendor_id: 0x046d,
384 product_id: 0xb023,
385 };
386 let key = DeviceStableId::from_parts(Some(&route), 0xff, Some("ABCDEF"), [0; 4])
387 .physical_key()
388 .map(PhysicalDeviceKey::into_string);
389
390 assert_eq!(key, Some("direct:046d:b023:serial:abcdef".to_string()));
391 }
392
393 #[test]
394 fn nonzero_unit_identity_is_physical_without_serial() {
395 let route = DeviceRoute::Direct {
396 vendor_id: 0x046d,
397 product_id: 0xb023,
398 };
399 let key = DeviceStableId::from_parts(Some(&route), 0xff, None, [0xa3, 0x93, 0xca, 0xe0])
400 .physical_key()
401 .map(PhysicalDeviceKey::into_string);
402
403 assert_eq!(key, Some("direct:046d:b023:unit:a393cae0".to_string()));
404 }
405
406 #[test]
407 fn parser_distinguishes_physical_keys_from_legacy_model_keys() {
408 assert!(PhysicalDeviceKey::parse("receiver:d0289db2:slot:1").is_some());
409 assert!(PhysicalDeviceKey::parse("direct:046d:b023:unit:a393cae0").is_some());
410 assert!(PhysicalDeviceKey::parse("2b034").is_none());
411 }
412
413 #[test]
414 fn raw_os_identity_is_transient_across_reconnects() {
415 let old = DeviceRoute::RawHid {
416 vendor_id: 0x046d,
417 product_id: 0xc900,
418 usage_page: 0xff43,
419 usage_id: 0x0202,
420 identity: "id:old-node".into(),
421 };
422 let new = DeviceRoute::RawHid {
423 vendor_id: 0x046d,
424 product_id: 0xc900,
425 usage_page: 0xff43,
426 usage_id: 0x0202,
427 identity: "id:new-node".into(),
428 };
429 let old_id = DeviceStableId::from_parts(Some(&old), 0xff, None, [0; 4]);
430 let new_id = DeviceStableId::from_parts(Some(&new), 0xff, None, [0; 4]);
431 assert_ne!(old_id.runtime_key(), new_id.runtime_key());
432 assert!(old_id.physical_key().is_none());
433 assert!(new_id.physical_key().is_none());
434 }
435
436 #[test]
437 fn raw_serial_identity_survives_a_changed_os_node() {
438 let route = DeviceRoute::RawHid {
439 vendor_id: 0x046d,
440 product_id: 0xc900,
441 usage_page: 0xff43,
442 usage_id: 0x0202,
443 identity: "serial:glow-1".into(),
444 };
445 let key = DeviceStableId::from_parts(Some(&route), 0xff, Some("glow-1"), [0; 4])
446 .physical_key()
447 .map(PhysicalDeviceKey::into_string);
448 assert_eq!(key, Some("raw:046d:c900:ff43:0202:serial:glow-1".into()));
449 }
450}