Skip to main content

openlogi_core/
device_order.rs

1//! Canonical device ordering shared by the GUI carousel and the agent's
2//! no-selection fallback.
3//!
4//! HID enumeration order shifts as devices wake, sleep, or are reselected, so
5//! both processes order devices by a stable, route-derived identity instead.
6//! Sharing the key keeps device order deterministic. The agent additionally
7//! excludes standalone raw-HID devices when choosing its input-capture target;
8//! they remain ordered here for inventory, display, and settings re-apply.
9
10use crate::hid::DeviceRoute;
11
12/// A configuration key backed by enough information to identify one physical
13/// device across inventory snapshots and process restarts.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub struct PhysicalDeviceKey(String);
16
17/// A route-derived identity used to order devices deterministically.
18///
19/// Receiver UID + slot and serial/non-zero unit identities are stable and
20/// unique. OS-node identities on raw HID routes are deliberately retained here
21/// only so a transient inventory record can still be ordered; they cannot
22/// become a [`PhysicalDeviceKey`].
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub enum DeviceStableId {
25    /// Paired to a receiver — Bolt and Unifying share this variant so order
26    /// agrees regardless of receiver family.
27    Bolt {
28        /// Case-folded receiver unique id.
29        receiver_uid: String,
30        /// Pairing slot on the receiver.
31        slot: u8,
32    },
33    /// Attached straight to the host (USB or Bluetooth), disambiguated by its
34    /// own [`DeviceIdentity`].
35    Direct {
36        /// USB vendor id.
37        vendor_id: u16,
38        /// USB product id.
39        product_id: u16,
40        /// Disambiguates two same-model direct devices.
41        identity: DeviceIdentity,
42    },
43    /// A standalone raw-HID device, addressed by its USB/usage identifiers
44    /// plus an OS-node- or serial-derived identity string.
45    RawHid {
46        /// USB vendor id.
47        vendor_id: u16,
48        /// USB product id.
49        product_id: u16,
50        /// HID usage page.
51        usage_page: u16,
52        /// HID usage id.
53        usage_id: u16,
54        /// Case-folded OS-node- or serial-derived identity.
55        identity: String,
56    },
57    /// No route was reported; ordered by pairing slot plus its own
58    /// [`DeviceIdentity`].
59    Unknown {
60        /// Pairing slot, when known.
61        slot: u8,
62        /// Disambiguates two same-model routeless devices.
63        identity: DeviceIdentity,
64    },
65}
66
67/// A device's own identity, used to disambiguate two same-model direct devices.
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
69pub enum DeviceIdentity {
70    /// Case-folded serial number.
71    Serial(String),
72    /// Raw HID++ unit id, used when no serial is reported.
73    Unit([u8; 4]),
74}
75
76impl DeviceIdentity {
77    /// Prefer the serial number (case-folded) when present, else the unit id.
78    #[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    /// Build the stable id from a device's route plus its identity fields.
90    /// `slot` is only consulted for a routeless device (the Bolt/Direct cases
91    /// carry their own slot / addressing inside the route).
92    #[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    /// Route-derived key used while a device is present in a runtime snapshot.
136    ///
137    /// Unlike [`Self::physical_key`], this also represents a direct or
138    /// routeless device whose only reported identity is an all-zero unit id.
139    /// Such a key is suitable for short-lived UI bookkeeping only and must not
140    /// be written to configuration.
141    ///
142    /// This intentionally keys receiver-connected devices by receiver UID +
143    /// pairing slot rather than model id, so two identical mice paired to the
144    /// same receiver can carry different settings.
145    #[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    /// Key naming the *path* a device was reached by, with the device's own
168    /// identity removed.
169    ///
170    /// Used as the key of a config entry's `links` table, so one device
171    /// reached two ways indexes one entry. Only [`Self::Direct`] carries a
172    /// device identity that moves to the entry key; the others keep
173    /// [`Self::runtime_key`] unchanged — a receiver key already names the
174    /// receiver rather than the paired device, and raw/routeless identities
175    /// are OS node ids that cannot become an entry key.
176    #[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    /// Stable key for persisted per-physical-device configuration.
189    ///
190    /// Receiver-connected devices are identified by receiver UID + pairing
191    /// slot. Direct and routeless devices require either a non-empty serial
192    /// number or a non-zero unit id. Raw HID devices require a serial-backed
193    /// route identity; OS node identities and all-zero unit ids are transient
194    /// probe results, not physical identities.
195    #[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    /// Whether this identity is non-trivial: a non-empty serial, or a unit id
211    /// that is not all-zero.
212    ///
213    /// A device that reports neither has no identity worth keying on, either
214    /// for [`DeviceStableId::physical_key`] or for a route-independent
215    /// [`Self::key`].
216    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    /// This identity formatted as a bare `serial:`/`unit:` fragment, with no
224    /// route information. Callers that only want a key for a physical
225    /// identity should gate this behind [`Self::is_physical`] first.
226    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    /// Parse a key emitted by [`DeviceStableId::physical_key`].
236    ///
237    /// Legacy model-scoped configuration keys intentionally return `None`;
238    /// callers can use that distinction when applying compatibility behavior
239    /// without treating a model identifier as a physical identity.
240    #[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    /// Whether `value` is a structurally valid runtime key whose only device
255    /// identity is the all-zero unit id.
256    #[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    /// Borrow the serialized configuration key.
266    #[must_use]
267    pub fn as_str(&self) -> &str {
268        &self.0
269    }
270
271    /// Consume the wrapper and return its serialized configuration key.
272    #[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        // Unifying and Bolt share the same stable-id variant so the GUI and
358        // agent agree on carousel order regardless of receiver family.
359        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        // The identity moves to the entry key; the route names only the path, so
485        // the same mouse cabled and on its receiver can index the same entry.
486        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        // A receiver key names the receiver and slot, never the paired device, so
498        // there is no identity to strip. Raw and routeless keys keep their
499        // identity because it is an OS node id, not a device identity that can
500        // move to the entry key.
501        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}