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    GestureOwner, Lighting, ScrollResolution, SmartShift, deserialize_gesture_owner,
11};
12use crate::binding::{Action, Binding, ButtonId, GestureDirection};
13use crate::device::{Capabilities, DeviceKind, DeviceModelInfo};
14
15/// Last-known identity of a device, captured while it was online so the UI can
16/// render its card and the *correct* config panels before any live HID++ probe
17/// completes — or while the device is asleep and can't be probed at all.
18///
19/// Every field is a **static property of the model**, not of the current
20/// connection: an MX Master 3S has adjustable DPI whether or not it is awake.
21/// That is what makes this safe to persist — it never goes stale. It is also
22/// free of any per-unit identifier (no serial number, no unit id), so caching
23/// it adds no privacy surface beyond the `config_key` already used as the map
24/// key. Persisting identity is what stops a sleeping/just-booted mouse from
25/// vanishing from the device list (and losing its Pointer/Buttons panels)
26/// until a cold probe happens to win its race — see issue #159.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct DeviceIdentity {
29    /// The name shown in the carousel, as resolved from the asset registry the
30    /// last time the device was online.
31    pub display_name: String,
32    /// HID++ model identity from feature 0x0003, when available. Persisted so
33    /// the GUI can resolve the same curated asset while the device is asleep.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub model_info: Option<DeviceModelInfo>,
36    /// Firmware codename, when available. Used as an asset-resolution hint and
37    /// as a readable fallback for devices without curated model metadata.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub codename: Option<String>,
40    /// The device's resolved [`DeviceKind`] (asset registry preferred, HID++
41    /// classification as fallback).
42    pub kind: DeviceKind,
43    /// Configuration capabilities measured from the device's HID++ feature
44    /// table. This is the field that keeps a sleeping mouse's panels visible.
45    pub capabilities: Capabilities,
46}
47
48/// Settings scoped to a single physical device.
49///
50/// Deserialization goes through `RawDeviceConfig` (`#[serde(from)]`) so
51/// pre-v2 files — which split bindings across `button_bindings` +
52/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
53/// `bindings` is ever serialized, so a migrated file self-heals to the v2 shape
54/// on its next save.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56#[serde(from = "RawDeviceConfig")]
57pub struct DeviceConfig {
58    /// Which button owns the device's single gesture role, once the user has
59    /// chosen explicitly. Absent means "infer" (the dedicated HID++ gesture
60    /// button owns gestures if present) — see
61    /// [`Config::gesture_owner`](crate::config::Config::gesture_owner). Listed
62    /// first so it serializes as a scalar ahead of the `bindings` sub-table.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub gesture_owner: Option<GestureOwner>,
65    /// Last-known identity (name / kind / capabilities), captured while the
66    /// device was online. Lets the UI render this device — with the right
67    /// config panels — on a cold start before any probe, or while it sleeps.
68    /// `None` for configs written before this field existed or by hand.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub identity: Option<DeviceIdentity>,
71    /// Every rebindable button's binding: a single [`Action`], or — for the
72    /// gesture button (and, later, any raw-XY-capable button) — a
73    /// [`Binding::Gesture`] per-direction map.
74    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75    pub bindings: BTreeMap<ButtonId, Binding>,
76    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
77    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
78    /// id matches a key here, those bindings take precedence; anything not
79    /// listed falls through to `bindings`. Deliberately `Action`-valued (not
80    /// `Binding`): a per-app override replaces the whole button with one
81    /// action, never a per-direction gesture overlay.
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
84    /// Ordered list of DPI presets cycled through by
85    /// [`Action::CycleDpiPresets`] and indexed by
86    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
87    /// the cycle action becomes a no-op until the user adds at least one.
88    #[serde(default, skip_serializing_if = "Vec::is_empty")]
89    pub dpi_presets: Vec<u32>,
90    /// The sensor DPI the user committed for this device. Persisted because
91    /// the value lives in device RAM and resets on a power cycle (#189); the
92    /// agent re-applies it when the device reconnects. `None` until the user
93    /// first changes DPI.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub dpi: Option<u32>,
96    /// Per-device RGB lighting (static color + brightness + on/off). `None`
97    /// until the user changes it, so it stays out of `config.toml` otherwise.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub lighting: Option<Lighting>,
100    /// Per-device SmartShift wheel configuration, re-applied on reconnect for
101    /// the same reason as [`Self::dpi`]. `None` until the user changes it.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub smartshift: Option<SmartShift>,
104    /// Invert this device's scroll-wheel direction relative to the OS setting
105    /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
106    /// keeps macOS "natural scrolling" for the trackpad can have a traditional
107    /// "reverse" wheel on the mouse. Vertical only; the agent applies it through
108    /// the device's HID++ native wheel-inversion mode when supported. `false`
109    /// (default) is the native direction, and is omitted from `config.toml`.
110    #[serde(default, skip_serializing_if = "is_false")]
111    pub invert_scroll: bool,
112    /// Persisted HID++ `0x2121` wheel resolution. `None` leaves the device's
113    /// current resolution unmanaged and omits the field from `config.toml`.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub scroll_resolution: Option<ScrollResolution>,
116}
117
118/// `skip_serializing_if` helper for plain `bool` fields whose default is
119/// `false`: keeps an unset toggle out of `config.toml` entirely.
120#[allow(
121    clippy::trivially_copy_pass_by_ref,
122    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
123)]
124fn is_false(b: &bool) -> bool {
125    !*b
126}
127
128/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
129/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
130/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
131/// in the v2 shape.
132#[derive(Deserialize)]
133struct RawDeviceConfig {
134    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
135    /// owner is inferred in
136    /// [`Config::gesture_owner`](crate::config::Config::gesture_owner). A
137    /// present-but-invalid value is tolerated as `None` (infer), not a parse
138    /// error — see [`deserialize_gesture_owner`].
139    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
140    gesture_owner: Option<GestureOwner>,
141    #[serde(default)]
142    identity: Option<DeviceIdentity>,
143    /// v2 shape — present on already-migrated files; wins on any key collision.
144    #[serde(default)]
145    bindings: BTreeMap<ButtonId, Binding>,
146    /// Legacy v1 per-button single bindings.
147    #[serde(default)]
148    button_bindings: BTreeMap<ButtonId, Action>,
149    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
150    #[serde(default)]
151    gesture_bindings: BTreeMap<GestureDirection, Action>,
152    #[serde(default)]
153    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
154    #[serde(default)]
155    dpi_presets: Vec<u32>,
156    #[serde(default)]
157    dpi: Option<u32>,
158    #[serde(default)]
159    lighting: Option<Lighting>,
160    #[serde(default)]
161    smartshift: Option<SmartShift>,
162    #[serde(default)]
163    invert_scroll: bool,
164    #[serde(default)]
165    scroll_resolution: Option<ScrollResolution>,
166}
167
168impl From<RawDeviceConfig> for DeviceConfig {
169    fn from(raw: RawDeviceConfig) -> Self {
170        let mut bindings = raw.bindings; // the v2 map wins on every key.
171
172        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
173        // happen before folding `button_bindings`, so a legacy single
174        // `button_bindings[GestureButton]` entry coexisting with a
175        // `gesture_bindings` map cannot claim the slot first and silently drop
176        // the whole direction map (the pre-v2 rule was "gesture entries win").
177        if !raw.gesture_bindings.is_empty() {
178            bindings
179                .entry(ButtonId::GestureButton)
180                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
181        }
182        for (button, action) in raw.button_bindings {
183            // A legacy `button_bindings[GestureButton]` is vestigial and must not
184            // become a `Binding::Single`: the gesture button never dispatched
185            // through the per-button map (it is not an OS-hook button, and its
186            // plain press routes through the gesture `Click` slot — see
187            // agent-core `bindings_for`). A `Single` here would be unreachable —
188            // the GUI hides it and the runtime ignores it — while folding it into
189            // `Click` would resurrect a dead binding as a behavior change. Drop
190            // it: the gesture map (re-homed above) already owns this button, and
191            // an absent entry falls back to the canonical default, exactly as
192            // pre-v2.
193            if button == ButtonId::GestureButton {
194                continue;
195            }
196            bindings.entry(button).or_insert(Binding::Single(action));
197        }
198
199        DeviceConfig {
200            gesture_owner: raw.gesture_owner,
201            identity: raw.identity,
202            bindings,
203            per_app_bindings: raw.per_app_bindings,
204            dpi_presets: raw.dpi_presets,
205            dpi: raw.dpi,
206            lighting: raw.lighting,
207            smartshift: raw.smartshift,
208            invert_scroll: raw.invert_scroll,
209            scroll_resolution: raw.scroll_resolution,
210        }
211    }
212}