Skip to main content

openlogi_core/config/
device.rs

1//! Per-device config: [`DeviceIdentity`], [`DeviceConfig`], and the
2//! [`RawDeviceConfig`] migration shim that folds pre-v2 files into the
3//! unified `bindings` map.
4
5use std::collections::BTreeMap;
6
7use serde::{Deserialize, Serialize};
8
9use super::button_map::{deserialize_button_map, deserialize_button_maps_by_app};
10use super::settings::{
11    CameraControls, GestureOwner, LightSettings, Lighting, ScrollResolution, SmartShift,
12    ThumbwheelSensitivity, deserialize_gesture_owner,
13};
14use crate::binding::{Action, ActionRingConfig, Binding, ButtonId, GestureDirection};
15use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities};
16use crate::hid::Dpi;
17
18/// Last-known identity of a device, captured while it was online so the UI can
19/// render its card and the *correct* config panels before any live HID++ probe
20/// completes — or while the device is asleep and can't be probed at all.
21///
22/// Every field is a **static property of the model**, not of the current
23/// connection: an MX Master 3S has adjustable DPI whether or not it is awake.
24/// That is what makes this safe to persist — it never goes stale. It is also
25/// free of any per-unit identifier (no serial number, no unit id), so caching
26/// it adds no privacy surface beyond the `config_key` already used as the map
27/// key. Persisting identity is what stops a sleeping/just-booted mouse from
28/// vanishing from the device list (and losing its Pointer/Buttons panels)
29/// until a cold probe happens to win its race — see issue #159.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct DeviceIdentity {
33    /// The name shown in the carousel, as resolved from the asset registry the
34    /// last time the device was online.
35    pub display_name: String,
36    /// HID++ model identity from feature 0x0003, when available. Persisted so
37    /// the GUI can resolve the same curated asset while the device is asleep.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub model_info: Option<DeviceModelInfo>,
40    /// Firmware codename, when available. Used as an asset-resolution hint and
41    /// as a readable fallback for devices without curated model metadata.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub codename: Option<String>,
44    /// The device's resolved [`DeviceKind`] (asset registry preferred, HID++
45    /// classification as fallback).
46    pub kind: DeviceKind,
47    /// Configuration capabilities measured from the device's HID++ feature
48    /// table. This is the field that keeps a sleeping mouse's panels visible.
49    pub capabilities: Capabilities,
50    /// Standalone-light controls measured by its protocol driver, if this is
51    /// a non-HID++ light. Old configs omit this field.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub light_capabilities: Option<LightCapabilities>,
54    /// Standalone driver family that produced this identity, when applicable.
55    /// Old configs and HID++ devices omit it.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub driver_id: Option<String>,
58    /// Optional model-level identity in the OpenLogi asset registry. This is
59    /// not a physical-device key and never contains a serial or OS node id.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub registry_model_id: Option<String>,
62}
63
64impl DeviceIdentity {
65    /// Remove per-unit identifiers before this model snapshot is persisted.
66    #[must_use]
67    pub fn without_unit_identifiers(mut self) -> Self {
68        if let Some(model) = &mut self.model_info {
69            model.serial_number = None;
70            model.unit_id = [0; 4];
71        }
72        self
73    }
74}
75
76/// One route a device has been seen on, plus what differs on it.
77///
78/// The key of the containing map is [`DeviceStableId::route_key`]. The set of
79/// keys doubles as the index that identifies a device while it is asleep and
80/// only its route is known, which is why it is persisted rather than
81/// recomputed.
82///
83/// [`DeviceStableId::route_key`]: crate::device_order::DeviceStableId::route_key
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
85pub struct LinkConfig {
86    /// Capabilities measured on this link. A device may genuinely expose
87    /// different features per transport — a G502 LIGHTSPEED publishes
88    /// `0x2121 HiResWheel` over its receiver and not over USB — so this is
89    /// recorded per link rather than per device.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub capabilities: Option<Capabilities>,
92    /// Settings the user deliberately made different on this link. Empty for
93    /// the overwhelmingly common case where a device behaves the same either
94    /// way.
95    #[serde(default, skip_serializing_if = "LinkOverrides::is_empty")]
96    pub overrides: LinkOverrides,
97}
98
99/// Per-link settings overrides. Each `Some` shadows the device-level value
100/// for as long as the device is reached by that route.
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct LinkOverrides {
103    /// Pointer resolution.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub dpi: Option<Dpi>,
106    /// Native wheel inversion.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub invert_scroll: Option<bool>,
109    /// Wheel resolution.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub scroll_resolution: Option<ScrollResolution>,
112    /// Lighting.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub lighting: Option<Lighting>,
115    /// SmartShift.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub smartshift: Option<SmartShift>,
118}
119
120impl LinkOverrides {
121    /// Whether nothing is overridden on this link.
122    #[must_use]
123    pub fn is_empty(&self) -> bool {
124        *self == Self::default()
125    }
126}
127
128/// Settings scoped to a single physical device.
129///
130/// Deserialization goes through `RawDeviceConfig` (`#[serde(from)]`) so
131/// pre-v2 files — which split bindings across `button_bindings` +
132/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
133/// `bindings` is ever serialized, so a migrated file is rewritten to the v2
134/// shape on its next save.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136#[serde(from = "RawDeviceConfig")]
137pub struct DeviceConfig {
138    /// Whether OpenLogi manages this device at all. `false` leaves the device
139    /// fully native: no capture session (no HID++ diversion of any control)
140    /// and no volatile-settings re-apply on reconnect. Defaults to `true` and
141    /// is only serialized when disabled.
142    #[serde(default = "default_true", skip_serializing_if = "is_true")]
143    pub enabled: bool,
144    /// User-assigned name for this physical device. The persisted
145    /// [`DeviceIdentity::display_name`] remains the hardware model name so an
146    /// inventory refresh can never overwrite this alias or mistake it for
147    /// model metadata.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub custom_name: Option<String>,
150    /// Legacy owner-lock carrier, deserialize-only: the v3-and-older
151    /// `gesture_owner` field, held here just long enough for the version-gated
152    /// load migration (`Config::migrate_owner_locked_gestures`) to consume it.
153    /// Never serialized — since v4 the binding shape is the whole truth
154    /// (gesture mode is per-button; see
155    /// [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
156    #[serde(skip_serializing)]
157    // Consumed only by the `fs` half's load migration. The field stays in
158    // every build: it is part of the shape serde *deserializes*, and dropping
159    // it would turn an old config's key into an unknown field.
160    #[cfg_attr(
161        not(feature = "fs"),
162        expect(clippy::allow_attributes, reason = "see above"),
163        allow(dead_code, reason = "only the `fs` half's load migration reads it")
164    )]
165    pub(super) gesture_owner: Option<GestureOwner>,
166    /// Last-known identity (name / kind / capabilities), captured while the
167    /// device was online. Lets the UI render this device — with the right
168    /// config panels — on a cold start before any probe, or while it sleeps.
169    /// `None` for configs written before this field existed or by hand.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub identity: Option<DeviceIdentity>,
172    /// Routes this device has been seen on. Keys are
173    /// [`DeviceStableId::route_key`]; see [`LinkConfig`].
174    ///
175    /// [`DeviceStableId::route_key`]: crate::device_order::DeviceStableId::route_key
176    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
177    pub links: BTreeMap<String, LinkConfig>,
178    /// Every rebindable button's binding: a single [`Action`], an independent
179    /// short/long action pair, or — for a button in gesture mode — a
180    /// [`Binding::Gesture`] per-direction map.
181    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
182    pub bindings: BTreeMap<ButtonId, Binding>,
183    /// Direction maps of buttons whose gesture mode is currently OFF, keyed by
184    /// button — pure UX memory so re-enabling restores the user's customized
185    /// arms exactly
186    /// (see [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
187    /// Never dispatched: the runtime reads only `bindings`, where a demoted
188    /// button is a [`Binding::Single`] of its former `Click`.
189    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
190    pub disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
191    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
192    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
193    /// id matches a key here, those bindings take precedence; anything not
194    /// listed falls through to `bindings`. Deliberately `Action`-valued (not
195    /// `Binding`): a per-app override replaces the whole button with one
196    /// action, never a per-direction gesture overlay.
197    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
198    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
199    /// Host-rendered Actions Ring settings and complete per-application layouts.
200    #[serde(default, skip_serializing_if = "ActionRingConfig::is_default")]
201    pub action_ring: ActionRingConfig,
202    /// Ordered list of DPI presets cycled through by
203    /// [`Action::CycleDpiPresets`] and indexed by
204    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
205    /// the cycle action becomes a no-op until the user adds at least one.
206    #[serde(
207        default,
208        deserialize_with = "deserialize_dpi_presets",
209        skip_serializing_if = "Vec::is_empty"
210    )]
211    pub dpi_presets: Vec<Dpi>,
212    /// The sensor DPI the user committed for this device. Persisted because
213    /// the value lives in device RAM and resets on a power cycle (#189); the
214    /// agent re-applies it when the device reconnects. `None` until the user
215    /// first changes DPI.
216    #[serde(
217        default,
218        deserialize_with = "deserialize_optional_dpi",
219        skip_serializing_if = "Option::is_none"
220    )]
221    pub dpi: Option<Dpi>,
222    /// Per-device RGB lighting (static color + brightness + on/off). `None`
223    /// until the user changes it, so it stays out of `config.toml` otherwise.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub lighting: Option<Lighting>,
226    /// Per-device standalone-light settings. Separate from [`Self::lighting`],
227    /// which is the existing HID++ keyboard RGB configuration.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub light: Option<LightSettings>,
230    /// Per-device SmartShift wheel configuration, re-applied on reconnect for
231    /// the same reason as [`Self::dpi`]. `None` until the user changes it.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub smartshift: Option<SmartShift>,
234    /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the
235    /// user adjusts one, so it stays out of `config.toml` otherwise.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub camera_controls: Option<CameraControls>,
238    /// User-saved camera profiles (name → control snapshot). Built-in profiles
239    /// (Default / Streaming / Video call) live in the GUI, not here.
240    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241    pub camera_profiles: BTreeMap<String, CameraControls>,
242    /// The camera profile last applied from the GUI, highlighted on reopen.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub camera_profile: Option<String>,
245    /// Per-device thumb-wheel sensitivity override. `None` falls back to the
246    /// app-wide
247    /// [`AppSettings::thumbwheel_sensitivity`](crate::config::AppSettings::thumbwheel_sensitivity).
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
250    /// Invert this device's scroll-wheel direction relative to the OS setting
251    /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
252    /// keeps macOS "natural scrolling" for the trackpad can have a traditional
253    /// "reverse" wheel on the mouse. Vertical only; the agent applies it through
254    /// the device's HID++ native wheel-inversion mode when supported. `false`
255    /// (default) is the native direction, and is omitted from `config.toml`.
256    #[serde(default, skip_serializing_if = "is_false")]
257    pub invert_scroll: bool,
258    /// Persisted HID++ `0x2121` wheel resolution. `None` leaves the device's
259    /// current resolution unmanaged and omits the field from `config.toml`.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub scroll_resolution: Option<ScrollResolution>,
262    /// Physical config keys of pointing devices that follow this keyboard's
263    /// host switch channel. The relationship is keyboard-initiated: pressing
264    /// one of this device's host keys switches every listed target first, then
265    /// lets the keyboard leave the current host.
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub host_switch_targets: Vec<String>,
268    /// Keyboard Fn-lock state (HID++ fn inversion, `0x40a2`/`0x40a3`): `true`
269    /// means the F-row sends F1–F12 without holding Fn. Multi-host keyboards
270    /// store it per Easy-Switch slot and the user can flip it from the
271    /// keyboard (Fn+Esc), so the agent writes it on config reload and
272    /// re-applies it when the keyboard reconnects, like [`Self::dpi`]. `None`
273    /// means "never set — leave the keyboard alone".
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub fn_lock: Option<bool>,
276}
277
278impl DeviceConfig {
279    /// Pointer resolution on `route_key`: the link's override when the user
280    /// set one there, else the device-level value.
281    #[must_use]
282    pub fn effective_dpi(&self, route_key: &str) -> Option<Dpi> {
283        self.link_overrides(route_key)
284            .and_then(|overrides| overrides.dpi)
285            .or(self.dpi)
286    }
287
288    /// Native wheel inversion on `route_key`: the link's override when the
289    /// user set one there, else the device-level value.
290    #[must_use]
291    pub fn effective_invert_scroll(&self, route_key: &str) -> bool {
292        self.link_overrides(route_key)
293            .and_then(|overrides| overrides.invert_scroll)
294            .unwrap_or(self.invert_scroll)
295    }
296
297    /// Wheel resolution on `route_key`: the link's override when the user set
298    /// one there, else the device-level value.
299    #[must_use]
300    pub fn effective_scroll_resolution(&self, route_key: &str) -> Option<ScrollResolution> {
301        self.link_overrides(route_key)
302            .and_then(|overrides| overrides.scroll_resolution)
303            .or(self.scroll_resolution)
304    }
305
306    /// Lighting on `route_key`: the link's override when the user set one
307    /// there, else the device-level value.
308    #[must_use]
309    pub fn effective_lighting(&self, route_key: &str) -> Option<&Lighting> {
310        self.link_overrides(route_key)
311            .and_then(|overrides| overrides.lighting.as_ref())
312            .or(self.lighting.as_ref())
313    }
314
315    /// SmartShift on `route_key`: the link's override when the user set one
316    /// there, else the device-level value.
317    #[must_use]
318    pub fn effective_smartshift(&self, route_key: &str) -> Option<SmartShift> {
319        self.link_overrides(route_key)
320            .and_then(|overrides| overrides.smartshift)
321            .or(self.smartshift)
322    }
323
324    fn link_overrides(&self, route_key: &str) -> Option<&LinkOverrides> {
325        self.links.get(route_key).map(|link| &link.overrides)
326    }
327
328    /// Whether this entry carries anything the *user* configured, as opposed
329    /// to nothing but the metadata OpenLogi writes on its own: the probed
330    /// [`Self::identity`] and the [`Self::links`] route index.
331    ///
332    /// This is what lets [`Config::resolve_device_key`] tell a bare entry
333    /// created by an identity probe from a pre-upgrade entry holding real
334    /// bindings and DPI. Comparing a stripped clone against [`Default`],
335    /// rather than testing fields one by one, keeps the answer honest as the
336    /// struct grows: a setting added tomorrow counts from the day it exists,
337    /// with nothing here to remember to update.
338    ///
339    /// [`Config::resolve_device_key`]: crate::config::Config::resolve_device_key
340    #[must_use]
341    pub fn holds_settings(&self) -> bool {
342        let mut stripped = self.clone();
343        stripped.identity = None;
344        stripped.links = BTreeMap::new();
345        stripped != Self::default()
346    }
347}
348
349impl Default for DeviceConfig {
350    fn default() -> Self {
351        Self {
352            // A fresh entry (e.g. created by a first DPI write) must stay
353            // managed — `enabled: false` is an explicit user choice only.
354            enabled: true,
355            custom_name: None,
356            gesture_owner: None,
357            identity: None,
358            links: BTreeMap::new(),
359            bindings: BTreeMap::new(),
360            disabled_gestures: BTreeMap::new(),
361            per_app_bindings: BTreeMap::new(),
362            action_ring: ActionRingConfig::default(),
363            dpi_presets: Vec::new(),
364            dpi: None,
365            lighting: None,
366            light: None,
367            smartshift: None,
368            camera_controls: None,
369            camera_profiles: BTreeMap::new(),
370            camera_profile: None,
371            thumbwheel_sensitivity: None,
372            invert_scroll: false,
373            scroll_resolution: None,
374            host_switch_targets: Vec::new(),
375            fn_lock: None,
376        }
377    }
378}
379
380/// `serde(default)` helper for `bool` fields that default to `true`.
381fn default_true() -> bool {
382    true
383}
384
385/// `skip_serializing_if` helper for `bool` fields whose default is `true`.
386#[expect(
387    clippy::trivially_copy_pass_by_ref,
388    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
389)]
390fn is_true(b: &bool) -> bool {
391    *b
392}
393
394/// `skip_serializing_if` helper for plain `bool` fields whose default is
395/// `false`: keeps an unset toggle out of `config.toml` entirely.
396#[expect(
397    clippy::trivially_copy_pass_by_ref,
398    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
399)]
400fn is_false(b: &bool) -> bool {
401    !*b
402}
403
404fn deserialize_dpi_presets<'de, D>(deserializer: D) -> Result<Vec<Dpi>, D::Error>
405where
406    D: serde::Deserializer<'de>,
407{
408    Vec::<u32>::deserialize(deserializer)?
409        .into_iter()
410        .map(|value| {
411            Dpi::try_from(value).map_err(|_| {
412                serde::de::Error::custom(format_args!(
413                    "DPI must fit the HID++ 16-bit range, got {value}"
414                ))
415            })
416        })
417        .collect()
418}
419
420fn deserialize_optional_dpi<'de, D>(deserializer: D) -> Result<Option<Dpi>, D::Error>
421where
422    D: serde::Deserializer<'de>,
423{
424    let value = Option::<u32>::deserialize(deserializer)?;
425    value
426        .map(|value| {
427            Dpi::try_from(value).map_err(|_| {
428                serde::de::Error::custom(format_args!(
429                    "DPI must fit the HID++ 16-bit range, got {value}"
430                ))
431            })
432        })
433        .transpose()
434}
435
436/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
437/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
438/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
439/// in the v2 shape.
440#[derive(Deserialize)]
441#[serde(deny_unknown_fields)]
442struct RawDeviceConfig {
443    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
444    /// owner is inferred during the version-gated migration. A
445    /// present-but-invalid legacy value is tolerated as `None` for compatibility
446    /// with v3-and-older behavior; current schemas reject the field first.
447    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
448    gesture_owner: Option<GestureOwner>,
449    #[serde(default)]
450    identity: Option<DeviceIdentity>,
451    /// See [`DeviceConfig::links`].
452    #[serde(default)]
453    links: BTreeMap<String, LinkConfig>,
454    /// v2 shape — present on already-migrated files; wins on any key collision.
455    #[serde(default, deserialize_with = "deserialize_button_map")]
456    bindings: BTreeMap<ButtonId, Binding>,
457    /// v4 stash of turned-off gesture maps (see [`DeviceConfig::disabled_gestures`]).
458    #[serde(default, deserialize_with = "deserialize_button_map")]
459    disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
460    /// Legacy v1 per-button single bindings.
461    #[serde(default, deserialize_with = "deserialize_button_map")]
462    button_bindings: BTreeMap<ButtonId, Action>,
463    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
464    #[serde(default)]
465    gesture_bindings: BTreeMap<GestureDirection, Action>,
466    #[serde(default, deserialize_with = "deserialize_button_maps_by_app")]
467    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
468    #[serde(default)]
469    action_ring: ActionRingConfig,
470    #[serde(default, deserialize_with = "deserialize_dpi_presets")]
471    dpi_presets: Vec<Dpi>,
472    #[serde(default, deserialize_with = "deserialize_optional_dpi")]
473    dpi: Option<Dpi>,
474    #[serde(default)]
475    lighting: Option<Lighting>,
476    #[serde(default)]
477    light: Option<LightSettings>,
478    #[serde(default)]
479    smartshift: Option<SmartShift>,
480    #[serde(default)]
481    camera_controls: Option<CameraControls>,
482    #[serde(default)]
483    camera_profiles: BTreeMap<String, CameraControls>,
484    #[serde(default)]
485    camera_profile: Option<String>,
486    #[serde(default)]
487    thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
488    #[serde(default)]
489    invert_scroll: bool,
490    #[serde(default)]
491    scroll_resolution: Option<ScrollResolution>,
492    #[serde(default)]
493    host_switch_targets: Vec<String>,
494    #[serde(default)]
495    fn_lock: Option<bool>,
496    #[serde(default = "default_true")]
497    enabled: bool,
498    #[serde(default)]
499    custom_name: Option<String>,
500}
501
502impl From<RawDeviceConfig> for DeviceConfig {
503    fn from(raw: RawDeviceConfig) -> Self {
504        let mut bindings = raw.bindings; // the v2 map wins on every key.
505
506        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
507        // happen before folding `button_bindings`, so a legacy single
508        // `button_bindings[GestureButton]` entry coexisting with a
509        // `gesture_bindings` map cannot claim the slot first and silently drop
510        // the whole direction map (the pre-v2 rule was "gesture entries win").
511        if !raw.gesture_bindings.is_empty() {
512            bindings
513                .entry(ButtonId::GestureButton)
514                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
515        }
516        for (button, action) in raw.button_bindings {
517            // A legacy `button_bindings[GestureButton]` is vestigial and must not
518            // become a `Binding::Single`: the gesture button never dispatched
519            // through the per-button map (it is not an OS-hook button, and its
520            // plain press routes through the gesture `Click` slot — see
521            // agent-core `bindings_for`). A `Single` here would be unreachable —
522            // the GUI hides it and the runtime ignores it — while folding it into
523            // `Click` would resurrect a dead binding as a behavior change. Drop
524            // it: the gesture map (re-homed above) already owns this button, and
525            // an absent entry falls back to the canonical default, exactly as
526            // pre-v2.
527            if button == ButtonId::GestureButton {
528                continue;
529            }
530            bindings.entry(button).or_insert(Binding::Single(action));
531        }
532
533        DeviceConfig {
534            enabled: raw.enabled,
535            custom_name: raw.custom_name,
536            gesture_owner: raw.gesture_owner,
537            identity: raw.identity.map(DeviceIdentity::without_unit_identifiers),
538            links: raw.links,
539            bindings,
540            disabled_gestures: raw.disabled_gestures,
541            per_app_bindings: raw.per_app_bindings,
542            action_ring: raw.action_ring,
543            dpi_presets: raw.dpi_presets,
544            dpi: raw.dpi,
545            lighting: raw.lighting,
546            light: raw.light,
547            smartshift: raw.smartshift,
548            camera_controls: raw.camera_controls,
549            camera_profiles: raw.camera_profiles,
550            camera_profile: raw.camera_profile,
551            thumbwheel_sensitivity: raw.thumbwheel_sensitivity,
552            invert_scroll: raw.invert_scroll,
553            scroll_resolution: raw.scroll_resolution,
554            host_switch_targets: raw.host_switch_targets,
555            fn_lock: raw.fn_lock,
556        }
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::DeviceConfig;
563
564    #[test]
565    fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
566        let config: DeviceConfig = toml::from_str(
567            r#"host_switch_targets = [
568  "receiver:keyboard:slot:1",
569  "receiver:mouse:slot:2",
570]"#,
571        )?;
572
573        assert_eq!(
574            config.host_switch_targets,
575            ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
576        );
577        let serialized = toml::to_string(&config)?;
578        assert!(serialized.contains("host_switch_targets"));
579        Ok(())
580    }
581}