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