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    /// Stable key for persisted per-physical-device configuration.
168    ///
169    /// Receiver-connected devices are identified by receiver UID + pairing
170    /// slot. Direct and routeless devices require either a non-empty serial
171    /// number or a non-zero unit id. Raw HID devices require a serial-backed
172    /// route identity; OS node identities and all-zero unit ids are transient
173    /// probe results, not physical identities.
174    #[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    /// Parse a key emitted by [`DeviceStableId::physical_key`].
206    ///
207    /// Legacy model-scoped configuration keys intentionally return `None`;
208    /// callers can use that distinction when applying compatibility behavior
209    /// without treating a model identifier as a physical identity.
210    #[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    /// Whether `value` is a structurally valid runtime key whose only device
224    /// identity is the all-zero unit id.
225    #[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    /// Borrow the serialized configuration key.
235    #[must_use]
236    pub fn as_str(&self) -> &str {
237        &self.0
238    }
239
240    /// Consume the wrapper and return its serialized configuration key.
241    #[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        // Unifying and Bolt share the same stable-id variant so the GUI and
327        // agent agree on carousel order regardless of receiver family.
328        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}