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