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