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/// Settings scoped to a single physical device.
76///
77/// Deserialization goes through `RawDeviceConfig` (`#[serde(from)]`) so
78/// pre-v2 files — which split bindings across `button_bindings` +
79/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
80/// `bindings` is ever serialized, so a migrated file is rewritten to the v2
81/// shape on its next save.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(from = "RawDeviceConfig")]
84pub struct DeviceConfig {
85    /// Whether OpenLogi manages this device at all. `false` leaves the device
86    /// fully native: no capture session (no HID++ diversion of any control)
87    /// and no volatile-settings re-apply on reconnect. Defaults to `true` and
88    /// is only serialized when disabled.
89    #[serde(default = "default_true", skip_serializing_if = "is_true")]
90    pub enabled: bool,
91    /// Legacy owner-lock carrier, deserialize-only: the v3-and-older
92    /// `gesture_owner` field, held here just long enough for the version-gated
93    /// load migration (`Config::migrate_owner_locked_gestures`) to consume it.
94    /// Never serialized — since v4 the binding shape is the whole truth
95    /// (gesture mode is per-button; see
96    /// [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
97    #[serde(skip_serializing)]
98    // Consumed only by the `fs` half's load migration. The field stays in
99    // every build: it is part of the shape serde *deserializes*, and dropping
100    // it would turn an old config's key into an unknown field.
101    #[cfg_attr(
102        not(feature = "fs"),
103        expect(clippy::allow_attributes, reason = "see above"),
104        allow(dead_code, reason = "only the `fs` half's load migration reads it")
105    )]
106    pub(super) gesture_owner: Option<GestureOwner>,
107    /// Last-known identity (name / kind / capabilities), captured while the
108    /// device was online. Lets the UI render this device — with the right
109    /// config panels — on a cold start before any probe, or while it sleeps.
110    /// `None` for configs written before this field existed or by hand.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub identity: Option<DeviceIdentity>,
113    /// Every rebindable button's binding: a single [`Action`], or — for a
114    /// button in gesture mode — a [`Binding::Gesture`] per-direction map.
115    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
116    pub bindings: BTreeMap<ButtonId, Binding>,
117    /// Direction maps of buttons whose gesture mode is currently OFF, keyed by
118    /// button — pure UX memory so re-enabling restores the user's customized
119    /// arms exactly
120    /// (see [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
121    /// Never dispatched: the runtime reads only `bindings`, where a demoted
122    /// button is a [`Binding::Single`] of its former `Click`.
123    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
124    pub disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
125    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
126    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
127    /// id matches a key here, those bindings take precedence; anything not
128    /// listed falls through to `bindings`. Deliberately `Action`-valued (not
129    /// `Binding`): a per-app override replaces the whole button with one
130    /// action, never a per-direction gesture overlay.
131    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
132    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
133    /// Host-rendered Actions Ring settings and complete per-application layouts.
134    #[serde(default, skip_serializing_if = "ActionRingConfig::is_default")]
135    pub action_ring: ActionRingConfig,
136    /// Ordered list of DPI presets cycled through by
137    /// [`Action::CycleDpiPresets`] and indexed by
138    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
139    /// the cycle action becomes a no-op until the user adds at least one.
140    #[serde(
141        default,
142        deserialize_with = "deserialize_dpi_presets",
143        skip_serializing_if = "Vec::is_empty"
144    )]
145    pub dpi_presets: Vec<Dpi>,
146    /// The sensor DPI the user committed for this device. Persisted because
147    /// the value lives in device RAM and resets on a power cycle (#189); the
148    /// agent re-applies it when the device reconnects. `None` until the user
149    /// first changes DPI.
150    #[serde(
151        default,
152        deserialize_with = "deserialize_optional_dpi",
153        skip_serializing_if = "Option::is_none"
154    )]
155    pub dpi: Option<Dpi>,
156    /// Per-device RGB lighting (static color + brightness + on/off). `None`
157    /// until the user changes it, so it stays out of `config.toml` otherwise.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub lighting: Option<Lighting>,
160    /// Per-device standalone-light settings. Separate from [`Self::lighting`],
161    /// which is the existing HID++ keyboard RGB configuration.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub light: Option<LightSettings>,
164    /// Per-device SmartShift wheel configuration, re-applied on reconnect for
165    /// the same reason as [`Self::dpi`]. `None` until the user changes it.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub smartshift: Option<SmartShift>,
168    /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the
169    /// user adjusts one, so it stays out of `config.toml` otherwise.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub camera_controls: Option<CameraControls>,
172    /// User-saved camera profiles (name → control snapshot). Built-in profiles
173    /// (Default / Streaming / Video call) live in the GUI, not here.
174    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
175    pub camera_profiles: BTreeMap<String, CameraControls>,
176    /// The camera profile last applied from the GUI, highlighted on reopen.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub camera_profile: Option<String>,
179    /// Per-device thumb-wheel sensitivity override. `None` falls back to the
180    /// app-wide
181    /// [`AppSettings::thumbwheel_sensitivity`](crate::config::AppSettings::thumbwheel_sensitivity).
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
184    /// Invert this device's scroll-wheel direction relative to the OS setting
185    /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
186    /// keeps macOS "natural scrolling" for the trackpad can have a traditional
187    /// "reverse" wheel on the mouse. Vertical only; the agent applies it through
188    /// the device's HID++ native wheel-inversion mode when supported. `false`
189    /// (default) is the native direction, and is omitted from `config.toml`.
190    #[serde(default, skip_serializing_if = "is_false")]
191    pub invert_scroll: bool,
192    /// Persisted HID++ `0x2121` wheel resolution. `None` leaves the device's
193    /// current resolution unmanaged and omits the field from `config.toml`.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub scroll_resolution: Option<ScrollResolution>,
196    /// Physical config keys of pointing devices that follow this keyboard's
197    /// host switch channel. The relationship is keyboard-initiated: pressing
198    /// one of this device's host keys switches every listed target first, then
199    /// lets the keyboard leave the current host.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub host_switch_targets: Vec<String>,
202    /// Keyboard Fn-lock state (HID++ fn inversion, `0x40a2`/`0x40a3`): `true`
203    /// means the F-row sends F1–F12 without holding Fn. The state lives in
204    /// device RAM per host, so the agent re-applies it on reconnect like
205    /// [`Self::dpi`]. `None` means "never set — leave the keyboard alone".
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub fn_lock: Option<bool>,
208}
209
210impl Default for DeviceConfig {
211    fn default() -> Self {
212        Self {
213            // A fresh entry (e.g. created by a first DPI write) must stay
214            // managed — `enabled: false` is an explicit user choice only.
215            enabled: true,
216            gesture_owner: None,
217            identity: None,
218            bindings: BTreeMap::new(),
219            disabled_gestures: BTreeMap::new(),
220            per_app_bindings: BTreeMap::new(),
221            action_ring: ActionRingConfig::default(),
222            dpi_presets: Vec::new(),
223            dpi: None,
224            lighting: None,
225            light: None,
226            smartshift: None,
227            camera_controls: None,
228            camera_profiles: BTreeMap::new(),
229            camera_profile: None,
230            thumbwheel_sensitivity: None,
231            invert_scroll: false,
232            scroll_resolution: None,
233            host_switch_targets: Vec::new(),
234            fn_lock: None,
235        }
236    }
237}
238
239/// `serde(default)` helper for `bool` fields that default to `true`.
240fn default_true() -> bool {
241    true
242}
243
244/// `skip_serializing_if` helper for `bool` fields whose default is `true`.
245#[expect(
246    clippy::trivially_copy_pass_by_ref,
247    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
248)]
249fn is_true(b: &bool) -> bool {
250    *b
251}
252
253/// `skip_serializing_if` helper for plain `bool` fields whose default is
254/// `false`: keeps an unset toggle out of `config.toml` entirely.
255#[expect(
256    clippy::trivially_copy_pass_by_ref,
257    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
258)]
259fn is_false(b: &bool) -> bool {
260    !*b
261}
262
263fn deserialize_dpi_presets<'de, D>(deserializer: D) -> Result<Vec<Dpi>, D::Error>
264where
265    D: serde::Deserializer<'de>,
266{
267    Vec::<u32>::deserialize(deserializer)?
268        .into_iter()
269        .map(|value| {
270            Dpi::try_from(value).map_err(|_| {
271                serde::de::Error::custom(format_args!(
272                    "DPI must fit the HID++ 16-bit range, got {value}"
273                ))
274            })
275        })
276        .collect()
277}
278
279fn deserialize_optional_dpi<'de, D>(deserializer: D) -> Result<Option<Dpi>, D::Error>
280where
281    D: serde::Deserializer<'de>,
282{
283    let value = Option::<u32>::deserialize(deserializer)?;
284    value
285        .map(|value| {
286            Dpi::try_from(value).map_err(|_| {
287                serde::de::Error::custom(format_args!(
288                    "DPI must fit the HID++ 16-bit range, got {value}"
289                ))
290            })
291        })
292        .transpose()
293}
294
295/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
296/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
297/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
298/// in the v2 shape.
299#[derive(Deserialize)]
300#[serde(deny_unknown_fields)]
301struct RawDeviceConfig {
302    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
303    /// owner is inferred during the version-gated migration. A
304    /// present-but-invalid legacy value is tolerated as `None` for compatibility
305    /// with v3-and-older behavior; current schemas reject the field first.
306    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
307    gesture_owner: Option<GestureOwner>,
308    #[serde(default)]
309    identity: Option<DeviceIdentity>,
310    /// v2 shape — present on already-migrated files; wins on any key collision.
311    #[serde(default)]
312    bindings: BTreeMap<ButtonId, Binding>,
313    /// v4 stash of turned-off gesture maps (see [`DeviceConfig::disabled_gestures`]).
314    #[serde(default)]
315    disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
316    /// Legacy v1 per-button single bindings.
317    #[serde(default)]
318    button_bindings: BTreeMap<ButtonId, Action>,
319    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
320    #[serde(default)]
321    gesture_bindings: BTreeMap<GestureDirection, Action>,
322    #[serde(default)]
323    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
324    #[serde(default)]
325    action_ring: ActionRingConfig,
326    #[serde(default, deserialize_with = "deserialize_dpi_presets")]
327    dpi_presets: Vec<Dpi>,
328    #[serde(default, deserialize_with = "deserialize_optional_dpi")]
329    dpi: Option<Dpi>,
330    #[serde(default)]
331    lighting: Option<Lighting>,
332    #[serde(default)]
333    light: Option<LightSettings>,
334    #[serde(default)]
335    smartshift: Option<SmartShift>,
336    #[serde(default)]
337    camera_controls: Option<CameraControls>,
338    #[serde(default)]
339    camera_profiles: BTreeMap<String, CameraControls>,
340    #[serde(default)]
341    camera_profile: Option<String>,
342    #[serde(default)]
343    thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
344    #[serde(default)]
345    invert_scroll: bool,
346    #[serde(default)]
347    scroll_resolution: Option<ScrollResolution>,
348    #[serde(default)]
349    host_switch_targets: Vec<String>,
350    #[serde(default)]
351    fn_lock: Option<bool>,
352    #[serde(default = "default_true")]
353    enabled: bool,
354}
355
356impl From<RawDeviceConfig> for DeviceConfig {
357    fn from(raw: RawDeviceConfig) -> Self {
358        let mut bindings = raw.bindings; // the v2 map wins on every key.
359
360        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
361        // happen before folding `button_bindings`, so a legacy single
362        // `button_bindings[GestureButton]` entry coexisting with a
363        // `gesture_bindings` map cannot claim the slot first and silently drop
364        // the whole direction map (the pre-v2 rule was "gesture entries win").
365        if !raw.gesture_bindings.is_empty() {
366            bindings
367                .entry(ButtonId::GestureButton)
368                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
369        }
370        for (button, action) in raw.button_bindings {
371            // A legacy `button_bindings[GestureButton]` is vestigial and must not
372            // become a `Binding::Single`: the gesture button never dispatched
373            // through the per-button map (it is not an OS-hook button, and its
374            // plain press routes through the gesture `Click` slot — see
375            // agent-core `bindings_for`). A `Single` here would be unreachable —
376            // the GUI hides it and the runtime ignores it — while folding it into
377            // `Click` would resurrect a dead binding as a behavior change. Drop
378            // it: the gesture map (re-homed above) already owns this button, and
379            // an absent entry falls back to the canonical default, exactly as
380            // pre-v2.
381            if button == ButtonId::GestureButton {
382                continue;
383            }
384            bindings.entry(button).or_insert(Binding::Single(action));
385        }
386
387        DeviceConfig {
388            enabled: raw.enabled,
389            gesture_owner: raw.gesture_owner,
390            identity: raw.identity.map(DeviceIdentity::without_unit_identifiers),
391            bindings,
392            disabled_gestures: raw.disabled_gestures,
393            per_app_bindings: raw.per_app_bindings,
394            action_ring: raw.action_ring,
395            dpi_presets: raw.dpi_presets,
396            dpi: raw.dpi,
397            lighting: raw.lighting,
398            light: raw.light,
399            smartshift: raw.smartshift,
400            camera_controls: raw.camera_controls,
401            camera_profiles: raw.camera_profiles,
402            camera_profile: raw.camera_profile,
403            thumbwheel_sensitivity: raw.thumbwheel_sensitivity,
404            invert_scroll: raw.invert_scroll,
405            scroll_resolution: raw.scroll_resolution,
406            host_switch_targets: raw.host_switch_targets,
407            fn_lock: raw.fn_lock,
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::DeviceConfig;
415
416    #[test]
417    fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
418        let config: DeviceConfig = toml::from_str(
419            r#"host_switch_targets = [
420  "receiver:keyboard:slot:1",
421  "receiver:mouse:slot:2",
422]"#,
423        )?;
424
425        assert_eq!(
426            config.host_switch_targets,
427            ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
428        );
429        let serialized = toml::to_string(&config)?;
430        assert!(serialized.contains("host_switch_targets"));
431        Ok(())
432    }
433}