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    deserialize_gesture_owner,
12};
13use crate::binding::{Action, Binding, ButtonId, GestureDirection};
14use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities};
15
16/// Last-known identity of a device, captured while it was online so the UI can
17/// render its card and the *correct* config panels before any live HID++ probe
18/// completes — or while the device is asleep and can't be probed at all.
19///
20/// Every field is a **static property of the model**, not of the current
21/// connection: an MX Master 3S has adjustable DPI whether or not it is awake.
22/// That is what makes this safe to persist — it never goes stale. It is also
23/// free of any per-unit identifier (no serial number, no unit id), so caching
24/// it adds no privacy surface beyond the `config_key` already used as the map
25/// key. Persisting identity is what stops a sleeping/just-booted mouse from
26/// vanishing from the device list (and losing its Pointer/Buttons panels)
27/// until a cold probe happens to win its race — see issue #159.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct DeviceIdentity {
30    /// The name shown in the carousel, as resolved from the asset registry the
31    /// last time the device was online.
32    pub display_name: String,
33    /// HID++ model identity from feature 0x0003, when available. Persisted so
34    /// the GUI can resolve the same curated asset while the device is asleep.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub model_info: Option<DeviceModelInfo>,
37    /// Firmware codename, when available. Used as an asset-resolution hint and
38    /// as a readable fallback for devices without curated model metadata.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub codename: Option<String>,
41    /// The device's resolved [`DeviceKind`] (asset registry preferred, HID++
42    /// classification as fallback).
43    pub kind: DeviceKind,
44    /// Configuration capabilities measured from the device's HID++ feature
45    /// table. This is the field that keeps a sleeping mouse's panels visible.
46    pub capabilities: Capabilities,
47    /// Standalone-light controls measured by its protocol driver, if this is
48    /// a non-HID++ light. Old configs omit this field.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub light_capabilities: Option<LightCapabilities>,
51    /// Standalone driver family that produced this identity, when applicable.
52    /// Old configs and HID++ devices omit it.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub driver_id: Option<String>,
55    /// Optional model-level identity in the OpenLogi asset registry. This is
56    /// not a physical-device key and never contains a serial or OS node id.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub registry_model_id: Option<String>,
59}
60
61/// Settings scoped to a single physical device.
62///
63/// Deserialization goes through `RawDeviceConfig` (`#[serde(from)]`) so
64/// pre-v2 files — which split bindings across `button_bindings` +
65/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
66/// `bindings` is ever serialized, so a migrated file self-heals to the v2 shape
67/// on its next save.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(from = "RawDeviceConfig")]
70pub struct DeviceConfig {
71    /// Whether OpenLogi manages this device at all. `false` leaves the device
72    /// fully native: no capture session (no HID++ diversion of any control)
73    /// and no volatile-settings re-apply on reconnect. Defaults to `true` and
74    /// is only serialized when disabled.
75    #[serde(default = "default_true", skip_serializing_if = "is_true")]
76    pub enabled: bool,
77    /// Which button owns the device's single gesture role, once the user has
78    /// chosen explicitly. Absent means "infer" (the dedicated HID++ gesture
79    /// button owns gestures if present) — see
80    /// [`Config::gesture_owner`](crate::config::Config::gesture_owner). Listed
81    /// first so it serializes as a scalar ahead of the `bindings` sub-table.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub gesture_owner: Option<GestureOwner>,
84    /// Last-known identity (name / kind / capabilities), captured while the
85    /// device was online. Lets the UI render this device — with the right
86    /// config panels — on a cold start before any probe, or while it sleeps.
87    /// `None` for configs written before this field existed or by hand.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub identity: Option<DeviceIdentity>,
90    /// Every rebindable button's binding: a single [`Action`], or — for the
91    /// gesture button (and, later, any raw-XY-capable button) — a
92    /// [`Binding::Gesture`] per-direction map.
93    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94    pub bindings: BTreeMap<ButtonId, Binding>,
95    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
96    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
97    /// id matches a key here, those bindings take precedence; anything not
98    /// listed falls through to `bindings`. Deliberately `Action`-valued (not
99    /// `Binding`): a per-app override replaces the whole button with one
100    /// action, never a per-direction gesture overlay.
101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
102    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
103    /// Ordered list of DPI presets cycled through by
104    /// [`Action::CycleDpiPresets`] and indexed by
105    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
106    /// the cycle action becomes a no-op until the user adds at least one.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub dpi_presets: Vec<u32>,
109    /// The sensor DPI the user committed for this device. Persisted because
110    /// the value lives in device RAM and resets on a power cycle (#189); the
111    /// agent re-applies it when the device reconnects. `None` until the user
112    /// first changes DPI.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub dpi: Option<u32>,
115    /// Per-device RGB lighting (static color + brightness + on/off). `None`
116    /// until the user changes it, so it stays out of `config.toml` otherwise.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub lighting: Option<Lighting>,
119    /// Per-device standalone-light settings. Separate from [`Self::lighting`],
120    /// which is the existing HID++ keyboard RGB configuration.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub light: Option<LightSettings>,
123    /// Per-device SmartShift wheel configuration, re-applied on reconnect for
124    /// the same reason as [`Self::dpi`]. `None` until the user changes it.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub smartshift: Option<SmartShift>,
127    /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the
128    /// user adjusts one, so it stays out of `config.toml` otherwise.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub camera_controls: Option<CameraControls>,
131    /// User-saved camera profiles (name → control snapshot). Built-in profiles
132    /// (Default / Streaming / Video call) live in the GUI, not here.
133    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134    pub camera_profiles: BTreeMap<String, CameraControls>,
135    /// The camera profile last applied from the GUI, highlighted on reopen.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub camera_profile: Option<String>,
138    /// Per-device thumb-wheel sensitivity override. `None` falls back to the
139    /// app-wide
140    /// [`AppSettings::thumbwheel_sensitivity`](crate::config::AppSettings::thumbwheel_sensitivity).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub thumbwheel_sensitivity: Option<i32>,
143    /// Invert this device's scroll-wheel direction relative to the OS setting
144    /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
145    /// keeps macOS "natural scrolling" for the trackpad can have a traditional
146    /// "reverse" wheel on the mouse. Vertical only; the agent applies it through
147    /// the device's HID++ native wheel-inversion mode when supported. `false`
148    /// (default) is the native direction, and is omitted from `config.toml`.
149    #[serde(default, skip_serializing_if = "is_false")]
150    pub invert_scroll: bool,
151    /// Persisted HID++ `0x2121` wheel resolution. `None` leaves the device's
152    /// current resolution unmanaged and omits the field from `config.toml`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub scroll_resolution: Option<ScrollResolution>,
155    /// Physical config keys of pointing devices that follow this keyboard's
156    /// host switch channel. The relationship is keyboard-initiated: pressing
157    /// one of this device's host keys switches every listed target first, then
158    /// lets the keyboard leave the current host.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub host_switch_targets: Vec<String>,
161    /// Keyboard Fn-lock state (HID++ fn inversion, `0x40a2`/`0x40a3`): `true`
162    /// means the F-row sends F1–F12 without holding Fn. The state lives in
163    /// device RAM per host, so the agent re-applies it on reconnect like
164    /// [`Self::dpi`]. `None` means "never set — leave the keyboard alone".
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub fn_lock: Option<bool>,
167}
168
169impl Default for DeviceConfig {
170    fn default() -> Self {
171        Self {
172            // A fresh entry (e.g. created by a first DPI write) must stay
173            // managed — `enabled: false` is an explicit user choice only.
174            enabled: true,
175            gesture_owner: None,
176            identity: None,
177            bindings: BTreeMap::new(),
178            per_app_bindings: BTreeMap::new(),
179            dpi_presets: Vec::new(),
180            dpi: None,
181            lighting: None,
182            light: None,
183            smartshift: None,
184            camera_controls: None,
185            camera_profiles: BTreeMap::new(),
186            camera_profile: None,
187            thumbwheel_sensitivity: None,
188            invert_scroll: false,
189            scroll_resolution: None,
190            host_switch_targets: Vec::new(),
191            fn_lock: None,
192        }
193    }
194}
195
196/// `serde(default)` helper for `bool` fields that default to `true`.
197fn default_true() -> bool {
198    true
199}
200
201/// `skip_serializing_if` helper for `bool` fields whose default is `true`.
202#[allow(
203    clippy::trivially_copy_pass_by_ref,
204    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
205)]
206fn is_true(b: &bool) -> bool {
207    *b
208}
209
210/// `skip_serializing_if` helper for plain `bool` fields whose default is
211/// `false`: keeps an unset toggle out of `config.toml` entirely.
212#[allow(
213    clippy::trivially_copy_pass_by_ref,
214    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
215)]
216fn is_false(b: &bool) -> bool {
217    !*b
218}
219
220/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
221/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
222/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
223/// in the v2 shape.
224#[derive(Deserialize)]
225struct RawDeviceConfig {
226    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
227    /// owner is inferred in
228    /// [`Config::gesture_owner`](crate::config::Config::gesture_owner). A
229    /// present-but-invalid value is tolerated as `None` (infer), not a parse
230    /// error — see [`deserialize_gesture_owner`].
231    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
232    gesture_owner: Option<GestureOwner>,
233    #[serde(default)]
234    identity: Option<DeviceIdentity>,
235    /// v2 shape — present on already-migrated files; wins on any key collision.
236    #[serde(default)]
237    bindings: BTreeMap<ButtonId, Binding>,
238    /// Legacy v1 per-button single bindings.
239    #[serde(default)]
240    button_bindings: BTreeMap<ButtonId, Action>,
241    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
242    #[serde(default)]
243    gesture_bindings: BTreeMap<GestureDirection, Action>,
244    #[serde(default)]
245    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
246    #[serde(default)]
247    dpi_presets: Vec<u32>,
248    #[serde(default)]
249    dpi: Option<u32>,
250    #[serde(default)]
251    lighting: Option<Lighting>,
252    #[serde(default)]
253    light: Option<LightSettings>,
254    #[serde(default)]
255    smartshift: Option<SmartShift>,
256    #[serde(default)]
257    camera_controls: Option<CameraControls>,
258    #[serde(default)]
259    camera_profiles: BTreeMap<String, CameraControls>,
260    #[serde(default)]
261    camera_profile: Option<String>,
262    #[serde(default)]
263    thumbwheel_sensitivity: Option<i32>,
264    #[serde(default)]
265    invert_scroll: bool,
266    #[serde(default)]
267    scroll_resolution: Option<ScrollResolution>,
268    #[serde(default)]
269    host_switch_targets: Vec<String>,
270    #[serde(default)]
271    fn_lock: Option<bool>,
272    #[serde(default = "default_true")]
273    enabled: bool,
274}
275
276impl From<RawDeviceConfig> for DeviceConfig {
277    fn from(raw: RawDeviceConfig) -> Self {
278        let mut bindings = raw.bindings; // the v2 map wins on every key.
279
280        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
281        // happen before folding `button_bindings`, so a legacy single
282        // `button_bindings[GestureButton]` entry coexisting with a
283        // `gesture_bindings` map cannot claim the slot first and silently drop
284        // the whole direction map (the pre-v2 rule was "gesture entries win").
285        if !raw.gesture_bindings.is_empty() {
286            bindings
287                .entry(ButtonId::GestureButton)
288                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
289        }
290        for (button, action) in raw.button_bindings {
291            // A legacy `button_bindings[GestureButton]` is vestigial and must not
292            // become a `Binding::Single`: the gesture button never dispatched
293            // through the per-button map (it is not an OS-hook button, and its
294            // plain press routes through the gesture `Click` slot — see
295            // agent-core `bindings_for`). A `Single` here would be unreachable —
296            // the GUI hides it and the runtime ignores it — while folding it into
297            // `Click` would resurrect a dead binding as a behavior change. Drop
298            // it: the gesture map (re-homed above) already owns this button, and
299            // an absent entry falls back to the canonical default, exactly as
300            // pre-v2.
301            if button == ButtonId::GestureButton {
302                continue;
303            }
304            bindings.entry(button).or_insert(Binding::Single(action));
305        }
306
307        DeviceConfig {
308            enabled: raw.enabled,
309            gesture_owner: raw.gesture_owner,
310            identity: raw.identity,
311            bindings,
312            per_app_bindings: raw.per_app_bindings,
313            dpi_presets: raw.dpi_presets,
314            dpi: raw.dpi,
315            lighting: raw.lighting,
316            light: raw.light,
317            smartshift: raw.smartshift,
318            camera_controls: raw.camera_controls,
319            camera_profiles: raw.camera_profiles,
320            camera_profile: raw.camera_profile,
321            thumbwheel_sensitivity: raw.thumbwheel_sensitivity,
322            invert_scroll: raw.invert_scroll,
323            scroll_resolution: raw.scroll_resolution,
324            host_switch_targets: raw.host_switch_targets,
325            fn_lock: raw.fn_lock,
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::DeviceConfig;
333
334    #[test]
335    fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
336        let config: DeviceConfig = toml::from_str(
337            r#"host_switch_targets = [
338  "receiver:keyboard:slot:1",
339  "receiver:mouse:slot:2",
340]"#,
341        )?;
342
343        assert_eq!(
344            config.host_switch_targets,
345            ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
346        );
347        let serialized = toml::to_string(&config)?;
348        assert!(serialized.contains("host_switch_targets"));
349        Ok(())
350    }
351}