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