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    /// 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}
291
292impl DeviceConfig {
293    /// Pointer resolution on `route_key`: the link's override when the user
294    /// set one there, else the device-level value.
295    #[must_use]
296    pub fn effective_dpi(&self, route_key: &str) -> Option<Dpi> {
297        self.link_overrides(route_key)
298            .and_then(|overrides| overrides.dpi)
299            .or(self.dpi)
300    }
301
302    /// Native wheel inversion on `route_key`: the link's override when the
303    /// user set one there, else the device-level value.
304    #[must_use]
305    pub fn effective_invert_scroll(&self, route_key: &str) -> bool {
306        self.link_overrides(route_key)
307            .and_then(|overrides| overrides.invert_scroll)
308            .unwrap_or(self.invert_scroll)
309    }
310
311    /// Wheel resolution on `route_key`: the link's override when the user set
312    /// one there, else the device-level value.
313    #[must_use]
314    pub fn effective_scroll_resolution(&self, route_key: &str) -> Option<ScrollResolution> {
315        self.link_overrides(route_key)
316            .and_then(|overrides| overrides.scroll_resolution)
317            .or(self.scroll_resolution)
318    }
319
320    /// Lighting on `route_key`: the link's override when the user set one
321    /// there, else the device-level value.
322    #[must_use]
323    pub fn effective_lighting(&self, route_key: &str) -> Option<&Lighting> {
324        self.link_overrides(route_key)
325            .and_then(|overrides| overrides.lighting.as_ref())
326            .or(self.lighting.as_ref())
327    }
328
329    /// SmartShift on `route_key`: the link's override when the user set one
330    /// there, else the device-level value.
331    #[must_use]
332    pub fn effective_smartshift(&self, route_key: &str) -> Option<SmartShift> {
333        self.link_overrides(route_key)
334            .and_then(|overrides| overrides.smartshift)
335            .or(self.smartshift)
336    }
337
338    fn link_overrides(&self, route_key: &str) -> Option<&LinkOverrides> {
339        self.links.get(route_key).map(|link| &link.overrides)
340    }
341
342    /// Whether this entry carries anything the *user* configured, as opposed
343    /// to nothing but the metadata OpenLogi writes on its own: the probed
344    /// [`Self::identity`] and the [`Self::links`] route index.
345    ///
346    /// This is what lets [`Config::resolve_device_key`] tell a bare entry
347    /// created by an identity probe from a pre-upgrade entry holding real
348    /// bindings and DPI. Comparing a stripped clone against [`Default`],
349    /// rather than testing fields one by one, keeps the answer honest as the
350    /// struct grows: a setting added tomorrow counts from the day it exists,
351    /// with nothing here to remember to update.
352    ///
353    /// [`Config::resolve_device_key`]: crate::config::Config::resolve_device_key
354    #[must_use]
355    pub fn holds_settings(&self) -> bool {
356        let mut stripped = self.clone();
357        stripped.identity = None;
358        stripped.links = BTreeMap::new();
359        stripped != Self::default()
360    }
361}
362
363impl Default for DeviceConfig {
364    fn default() -> Self {
365        Self {
366            // A fresh entry (e.g. created by a first DPI write) must stay
367            // managed — `enabled: false` is an explicit user choice only.
368            enabled: true,
369            custom_name: None,
370            gesture_owner: None,
371            identity: None,
372            links: BTreeMap::new(),
373            bindings: BTreeMap::new(),
374            disabled_gestures: BTreeMap::new(),
375            per_app_bindings: BTreeMap::new(),
376            action_ring: ActionRingConfig::default(),
377            dpi_presets: Vec::new(),
378            dpi: None,
379            lighting: None,
380            light: None,
381            smartshift: None,
382            camera_controls: None,
383            camera_profiles: BTreeMap::new(),
384            camera_profile: None,
385            thumbwheel_sensitivity: None,
386            invert_scroll: false,
387            scroll_resolution: None,
388            host_switch_targets: Vec::new(),
389            fn_lock: None,
390        }
391    }
392}
393
394/// `serde(default)` helper for `bool` fields that default to `true`.
395fn default_true() -> bool {
396    true
397}
398
399/// `skip_serializing_if` helper for `bool` fields whose default is `true`.
400#[expect(
401    clippy::trivially_copy_pass_by_ref,
402    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
403)]
404fn is_true(b: &bool) -> bool {
405    *b
406}
407
408/// `skip_serializing_if` helper for plain `bool` fields whose default is
409/// `false`: keeps an unset toggle out of `config.toml` entirely.
410#[expect(
411    clippy::trivially_copy_pass_by_ref,
412    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
413)]
414fn is_false(b: &bool) -> bool {
415    !*b
416}
417
418fn deserialize_dpi_presets<'de, D>(deserializer: D) -> Result<Vec<Dpi>, D::Error>
419where
420    D: serde::Deserializer<'de>,
421{
422    Vec::<u32>::deserialize(deserializer)?
423        .into_iter()
424        .map(|value| {
425            Dpi::try_from(value).map_err(|_| {
426                serde::de::Error::custom(format_args!(
427                    "DPI must fit the HID++ 16-bit range, got {value}"
428                ))
429            })
430        })
431        .collect()
432}
433
434fn deserialize_optional_dpi<'de, D>(deserializer: D) -> Result<Option<Dpi>, D::Error>
435where
436    D: serde::Deserializer<'de>,
437{
438    let value = Option::<u32>::deserialize(deserializer)?;
439    value
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        .transpose()
448}
449
450/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
451/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
452/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
453/// in the v2 shape.
454#[derive(Deserialize)]
455#[serde(deny_unknown_fields)]
456struct RawDeviceConfig {
457    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
458    /// owner is inferred during the version-gated migration. A
459    /// present-but-invalid legacy value is tolerated as `None` for compatibility
460    /// with v3-and-older behavior; current schemas reject the field first.
461    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
462    gesture_owner: Option<GestureOwner>,
463    #[serde(default)]
464    identity: Option<DeviceIdentity>,
465    /// See [`DeviceConfig::links`].
466    #[serde(default)]
467    links: BTreeMap<String, LinkConfig>,
468    /// v2 shape — present on already-migrated files; wins on any key collision.
469    #[serde(default, deserialize_with = "deserialize_button_map")]
470    bindings: BTreeMap<ButtonId, Binding>,
471    /// v4 stash of turned-off gesture maps (see [`DeviceConfig::disabled_gestures`]).
472    #[serde(default, deserialize_with = "deserialize_button_map")]
473    disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
474    /// Legacy v1 per-button single bindings.
475    #[serde(default, deserialize_with = "deserialize_button_map")]
476    button_bindings: BTreeMap<ButtonId, Action>,
477    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
478    #[serde(default)]
479    gesture_bindings: BTreeMap<GestureDirection, Action>,
480    #[serde(default, deserialize_with = "deserialize_button_maps_by_app")]
481    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
482    #[serde(default)]
483    action_ring: ActionRingConfig,
484    #[serde(default, deserialize_with = "deserialize_dpi_presets")]
485    dpi_presets: Vec<Dpi>,
486    #[serde(default, deserialize_with = "deserialize_optional_dpi")]
487    dpi: Option<Dpi>,
488    #[serde(default)]
489    lighting: Option<Lighting>,
490    #[serde(default)]
491    light: Option<LightSettings>,
492    #[serde(default)]
493    smartshift: Option<SmartShift>,
494    #[serde(default)]
495    camera_controls: Option<CameraControls>,
496    #[serde(default)]
497    camera_profiles: BTreeMap<String, CameraControls>,
498    #[serde(default)]
499    camera_profile: Option<String>,
500    #[serde(default)]
501    thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
502    #[serde(default)]
503    invert_scroll: bool,
504    #[serde(default)]
505    scroll_resolution: Option<ScrollResolution>,
506    #[serde(default)]
507    host_switch_targets: Vec<String>,
508    #[serde(default)]
509    fn_lock: Option<bool>,
510    #[serde(default = "default_true")]
511    enabled: bool,
512    #[serde(default)]
513    custom_name: Option<String>,
514}
515
516impl From<RawDeviceConfig> for DeviceConfig {
517    fn from(raw: RawDeviceConfig) -> Self {
518        let mut bindings = raw.bindings; // the v2 map wins on every key.
519
520        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
521        // happen before folding `button_bindings`, so a legacy single
522        // `button_bindings[GestureButton]` entry coexisting with a
523        // `gesture_bindings` map cannot claim the slot first and silently drop
524        // the whole direction map (the pre-v2 rule was "gesture entries win").
525        if !raw.gesture_bindings.is_empty() {
526            bindings
527                .entry(ButtonId::GestureButton)
528                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
529        }
530        for (button, action) in raw.button_bindings {
531            // A legacy `button_bindings[GestureButton]` is vestigial and must not
532            // become a `Binding::Single`: the gesture button never dispatched
533            // through the per-button map (it is not an OS-hook button, and its
534            // plain press routes through the gesture `Click` slot — see
535            // agent-core `bindings_for`). A `Single` here would be unreachable —
536            // the GUI hides it and the runtime ignores it — while folding it into
537            // `Click` would resurrect a dead binding as a behavior change. Drop
538            // it: the gesture map (re-homed above) already owns this button, and
539            // an absent entry falls back to the canonical default, exactly as
540            // pre-v2.
541            if button == ButtonId::GestureButton {
542                continue;
543            }
544            bindings.entry(button).or_insert(Binding::Single(action));
545        }
546
547        DeviceConfig {
548            enabled: raw.enabled,
549            custom_name: raw.custom_name,
550            gesture_owner: raw.gesture_owner,
551            identity: raw.identity.map(DeviceIdentity::without_unit_identifiers),
552            links: raw.links,
553            bindings,
554            disabled_gestures: raw.disabled_gestures,
555            per_app_bindings: raw.per_app_bindings,
556            action_ring: raw.action_ring,
557            dpi_presets: raw.dpi_presets,
558            dpi: raw.dpi,
559            lighting: raw.lighting,
560            light: raw.light,
561            smartshift: raw.smartshift,
562            camera_controls: raw.camera_controls,
563            camera_profiles: raw.camera_profiles,
564            camera_profile: raw.camera_profile,
565            thumbwheel_sensitivity: raw.thumbwheel_sensitivity,
566            invert_scroll: raw.invert_scroll,
567            scroll_resolution: raw.scroll_resolution,
568            host_switch_targets: raw.host_switch_targets,
569            fn_lock: raw.fn_lock,
570        }
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::DeviceConfig;
577
578    #[test]
579    fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
580        let config: DeviceConfig = toml::from_str(
581            r#"host_switch_targets = [
582  "receiver:keyboard:slot:1",
583  "receiver:mouse:slot:2",
584]"#,
585        )?;
586
587        assert_eq!(
588            config.host_switch_targets,
589            ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
590        );
591        let serialized = toml::to_string(&config)?;
592        assert!(serialized.contains("host_switch_targets"));
593        Ok(())
594    }
595}