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::button_map::{deserialize_button_map, deserialize_button_maps_by_app};
10use super::settings::{
11 CameraControls, GestureOwner, LightSettings, Lighting, ScrollResolution, SmartShift,
12 ThumbwheelSensitivity, deserialize_gesture_owner,
13};
14use crate::binding::{Action, ActionRingConfig, Binding, ButtonId, GestureDirection};
15use crate::device::{Capabilities, DeviceKind, DeviceModelInfo, LightCapabilities};
16use crate::hid::{Dpi, ReportRate};
17
18/// Last-known identity of a device, captured while it was online so the UI can
19/// render its card and the *correct* config panels before any live HID++ probe
20/// completes — or while the device is asleep and can't be probed at all.
21///
22/// Every field is a **static property of the model**, not of the current
23/// connection: an MX Master 3S has adjustable DPI whether or not it is awake.
24/// That is what makes this safe to persist — it never goes stale. It is also
25/// free of any per-unit identifier (no serial number, no unit id), so caching
26/// it adds no privacy surface beyond the `config_key` already used as the map
27/// key. Persisting identity is what stops a sleeping/just-booted mouse from
28/// vanishing from the device list (and losing its Pointer/Buttons panels)
29/// until a cold probe happens to win its race — see issue #159.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct DeviceIdentity {
33 /// The name shown in the carousel, as resolved from the asset registry the
34 /// last time the device was online.
35 pub display_name: String,
36 /// HID++ model identity from feature 0x0003, when available. Persisted so
37 /// the GUI can resolve the same curated asset while the device is asleep.
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub model_info: Option<DeviceModelInfo>,
40 /// Firmware codename, when available. Used as an asset-resolution hint and
41 /// as a readable fallback for devices without curated model metadata.
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub codename: Option<String>,
44 /// The device's resolved [`DeviceKind`] (asset registry preferred, HID++
45 /// classification as fallback).
46 pub kind: DeviceKind,
47 /// Configuration capabilities measured from the device's HID++ feature
48 /// table. This is the field that keeps a sleeping mouse's panels visible.
49 pub capabilities: Capabilities,
50 /// Standalone-light controls measured by its protocol driver, if this is
51 /// a non-HID++ light. Old configs omit this field.
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub light_capabilities: Option<LightCapabilities>,
54 /// Standalone driver family that produced this identity, when applicable.
55 /// Old configs and HID++ devices omit it.
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub driver_id: Option<String>,
58 /// Optional model-level identity in the OpenLogi asset registry. This is
59 /// not a physical-device key and never contains a serial or OS node id.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub registry_model_id: Option<String>,
62}
63
64impl DeviceIdentity {
65 /// Drop the serial number and unit id from this model snapshot before it
66 /// is persisted, leaving the identity body with model-level fields only.
67 ///
68 /// This does **not** make the saved file free of per-unit identifiers.
69 /// Settings are keyed by what a device *is*, so [`canonical_device_key`]
70 /// makes the entry key itself a `serial:` or `unit:` fragment whenever the
71 /// device reported one, whatever transport it was reached by. Only a
72 /// device with no usable identity falls back to a route-derived key such
73 /// as `receiver:{uid}:slot:{n}`. The entry's `links` table, keyed by
74 /// [`route_key`], also persists receiver UIDs. `raw:` routes retain an
75 /// OS-node- or serial-derived identity; `unknown:` routes retain the
76 /// device's serial number or unit id.
77 ///
78 /// [`canonical_device_key`]: crate::config::canonical_device_key
79 /// [`route_key`]: crate::device_order::DeviceStableId::route_key
80 #[must_use]
81 pub fn without_unit_identifiers(mut self) -> Self {
82 if let Some(model) = &mut self.model_info {
83 model.serial_number = None;
84 model.unit_id = [0; 4];
85 }
86 self
87 }
88}
89
90/// One route a device has been seen on, plus what differs on it.
91///
92/// The key of the containing map is [`DeviceStableId::route_key`]. The set of
93/// keys doubles as the index that identifies a device while it is asleep and
94/// only its route is known, which is why it is persisted rather than
95/// recomputed.
96///
97/// [`DeviceStableId::route_key`]: crate::device_order::DeviceStableId::route_key
98#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
99pub struct LinkConfig {
100 /// Capabilities measured on this link. A device may genuinely expose
101 /// different features per transport — a G502 LIGHTSPEED publishes
102 /// `0x2121 HiResWheel` over its receiver and not over USB — so this is
103 /// recorded per link rather than per device.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub capabilities: Option<Capabilities>,
106 /// Settings the user deliberately made different on this link. Empty for
107 /// the overwhelmingly common case where a device behaves the same either
108 /// way.
109 #[serde(default, skip_serializing_if = "LinkOverrides::is_empty")]
110 pub overrides: LinkOverrides,
111}
112
113/// Per-link settings overrides. Each `Some` shadows the device-level value
114/// for as long as the device is reached by that route.
115#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
116pub struct LinkOverrides {
117 /// Pointer resolution.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub dpi: Option<Dpi>,
120 /// Native wheel inversion.
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub invert_scroll: Option<bool>,
123 /// Wheel resolution.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub scroll_resolution: Option<ScrollResolution>,
126 /// Lighting.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub lighting: Option<Lighting>,
129 /// SmartShift.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub smartshift: Option<SmartShift>,
132}
133
134impl LinkOverrides {
135 /// Whether nothing is overridden on this link.
136 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 *self == Self::default()
139 }
140}
141
142/// Settings scoped to a single physical device.
143///
144/// Deserialization goes through `RawDeviceConfig` (`#[serde(from)]`) so
145/// pre-v2 files — which split bindings across `button_bindings` +
146/// `gesture_bindings` — fold into the unified [`Self::bindings`] map. Only
147/// `bindings` is ever serialized, so a migrated file is rewritten to the v2
148/// shape on its next save.
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(from = "RawDeviceConfig")]
151pub struct DeviceConfig {
152 /// Whether OpenLogi manages this device at all. `false` leaves the device
153 /// fully native: no capture session (no HID++ diversion of any control)
154 /// and no volatile-settings re-apply on reconnect. Defaults to `true` and
155 /// is only serialized when disabled.
156 #[serde(default = "default_true", skip_serializing_if = "is_true")]
157 pub enabled: bool,
158 /// User-assigned name for this physical device. The persisted
159 /// [`DeviceIdentity::display_name`] remains the hardware model name so an
160 /// inventory refresh can never overwrite this alias or mistake it for
161 /// model metadata.
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub custom_name: Option<String>,
164 /// Legacy owner-lock carrier, deserialize-only: the v3-and-older
165 /// `gesture_owner` field, held here just long enough for the version-gated
166 /// load migration (`Config::migrate_owner_locked_gestures`) to consume it.
167 /// Never serialized — since v4 the binding shape is the whole truth
168 /// (gesture mode is per-button; see
169 /// [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
170 #[serde(skip_serializing)]
171 // Consumed only by the `fs` half's load migration. The field stays in
172 // every build: it is part of the shape serde *deserializes*, and dropping
173 // it would turn an old config's key into an unknown field.
174 #[cfg_attr(
175 not(feature = "fs"),
176 expect(clippy::allow_attributes, reason = "see above"),
177 allow(dead_code, reason = "only the `fs` half's load migration reads it")
178 )]
179 pub(super) gesture_owner: Option<GestureOwner>,
180 /// Last-known identity (name / kind / capabilities), captured while the
181 /// device was online. Lets the UI render this device — with the right
182 /// config panels — on a cold start before any probe, or while it sleeps.
183 /// `None` for configs written before this field existed or by hand.
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub identity: Option<DeviceIdentity>,
186 /// Routes this device has been seen on. Keys are
187 /// [`DeviceStableId::route_key`]; see [`LinkConfig`].
188 ///
189 /// [`DeviceStableId::route_key`]: crate::device_order::DeviceStableId::route_key
190 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191 pub links: BTreeMap<String, LinkConfig>,
192 /// Every rebindable button's binding: a single [`Action`], an independent
193 /// short/long action pair, or — for a button in gesture mode — a
194 /// [`Binding::Gesture`] per-direction map.
195 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196 pub bindings: BTreeMap<ButtonId, Binding>,
197 /// Direction maps of buttons whose gesture mode is currently OFF, keyed by
198 /// button — pure UX memory so re-enabling restores the user's customized
199 /// arms exactly
200 /// (see [`Config::set_gesture_mode`](crate::config::Config::set_gesture_mode)).
201 /// Never dispatched: the runtime reads only `bindings`, where a demoted
202 /// button is a [`Binding::Single`] of its former `Click`.
203 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
204 pub disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
205 /// Per-application binding overlays (P1.4). Keyed by the application
206 /// identifier defined by [`ForegroundApp::id`](crate::app::ForegroundApp::id).
207 /// When the foreground app's id matches a key here, these bindings take
208 /// precedence. Other buttons use `bindings`. Deliberately `Action`-valued
209 /// (not `Binding`): a per-app override replaces the whole button with one
210 /// action, never a per-direction gesture overlay.
211 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
212 pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
213 /// Host-rendered Actions Ring settings and complete per-application layouts.
214 #[serde(default, skip_serializing_if = "ActionRingConfig::is_default")]
215 pub action_ring: ActionRingConfig,
216 /// Ordered list of DPI presets cycled through by
217 /// [`Action::CycleDpiPresets`] and indexed by
218 /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
219 /// the cycle action becomes a no-op until the user adds at least one.
220 #[serde(
221 default,
222 deserialize_with = "deserialize_dpi_presets",
223 skip_serializing_if = "Vec::is_empty"
224 )]
225 pub dpi_presets: Vec<Dpi>,
226 /// The sensor DPI the user committed for this device. Persisted because
227 /// the value lives in device RAM and resets on a power cycle (#189); the
228 /// agent re-applies it when the device reconnects. `None` until the user
229 /// first changes DPI.
230 #[serde(
231 default,
232 deserialize_with = "deserialize_optional_dpi",
233 skip_serializing_if = "Option::is_none"
234 )]
235 pub dpi: Option<Dpi>,
236 /// Per-device RGB lighting (static color + brightness + on/off). `None`
237 /// until the user changes it, so it stays out of `config.toml` otherwise.
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub lighting: Option<Lighting>,
240 /// Per-device standalone-light settings. Separate from [`Self::lighting`],
241 /// which is the existing HID++ keyboard RGB configuration.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub light: Option<LightSettings>,
244 /// Per-device SmartShift wheel configuration, re-applied on reconnect for
245 /// the same reason as [`Self::dpi`]. `None` until the user changes it.
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub smartshift: Option<SmartShift>,
248 /// Per-webcam UVC image controls (brightness/contrast/…). `None` until the
249 /// user adjusts one, so it stays out of `config.toml` otherwise.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub camera_controls: Option<CameraControls>,
252 /// User-saved camera profiles (name → control snapshot). Built-in profiles
253 /// (Default / Streaming / Video call) live in the GUI, not here.
254 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255 pub camera_profiles: BTreeMap<String, CameraControls>,
256 /// The camera profile last applied from the GUI, highlighted on reopen.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub camera_profile: Option<String>,
259 /// Per-device thumb-wheel sensitivity override. `None` falls back to the
260 /// app-wide
261 /// [`AppSettings::thumbwheel_sensitivity`](crate::config::AppSettings::thumbwheel_sensitivity).
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
264 /// Invert this device's scroll-wheel direction relative to the OS setting
265 /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
266 /// keeps macOS "natural scrolling" for the trackpad can have a traditional
267 /// "reverse" wheel on the mouse. Vertical only; the agent applies it through
268 /// the device's HID++ native wheel-inversion mode when supported. `false`
269 /// (default) is the native direction, and is omitted from `config.toml`.
270 #[serde(default, skip_serializing_if = "is_false")]
271 pub invert_scroll: bool,
272 /// Persisted HID++ `0x2121` wheel resolution. `None` leaves the device's
273 /// current resolution unmanaged and omits the field from `config.toml`.
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub scroll_resolution: Option<ScrollResolution>,
276 /// Physical config keys of pointing devices that follow this keyboard's
277 /// host switch channel. The relationship is keyboard-initiated: pressing
278 /// one of this device's host keys switches every listed target first, then
279 /// lets the keyboard leave the current host.
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub host_switch_targets: Vec<String>,
282 /// Keyboard Fn-lock state (HID++ fn inversion, `0x40a2`/`0x40a3`): `true`
283 /// means the F-row sends F1–F12 without holding Fn. Multi-host keyboards
284 /// store it per Easy-Switch slot and the user can flip it from the
285 /// keyboard (Fn+Esc), so the agent writes it on config reload and
286 /// re-applies it when the keyboard reconnects, like [`Self::dpi`]. `None`
287 /// means "never set — leave the keyboard alone".
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub fn_lock: Option<bool>,
290 /// Keep a multi-platform keyboard's key layout (HID++ `0x4531`) on the
291 /// host OS, the "keep keyboard in OS layout" setting of Logi Options+. The
292 /// agent writes the host's platform when the keyboard connects and again
293 /// whenever the keyboard reports a platform change (a platform key
294 /// combination). `None` means "never set", which is on, as in Options+.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub keep_os_layout: Option<bool>,
297 /// Report rate (HID++ `0x8060` / `0x8061`), in hertz. The agent re-applies
298 /// it when the device reconnects, like [`Self::dpi`], but only where the
299 /// device lists it for the connection it is on — a wired-only rate is
300 /// left alone while the mouse is wireless. `None` means "never set —
301 /// leave the device's rate alone".
302 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub report_rate: Option<ReportRate>,
304}
305
306impl DeviceConfig {
307 /// Pointer resolution on `route_key`: the link's override when the user
308 /// set one there, else the device-level value.
309 #[must_use]
310 pub fn effective_dpi(&self, route_key: &str) -> Option<Dpi> {
311 self.link_overrides(route_key)
312 .and_then(|overrides| overrides.dpi)
313 .or(self.dpi)
314 }
315
316 /// Native wheel inversion on `route_key`: the link's override when the
317 /// user set one there, else the device-level value.
318 #[must_use]
319 pub fn effective_invert_scroll(&self, route_key: &str) -> bool {
320 self.link_overrides(route_key)
321 .and_then(|overrides| overrides.invert_scroll)
322 .unwrap_or(self.invert_scroll)
323 }
324
325 /// Wheel resolution on `route_key`: the link's override when the user set
326 /// one there, else the device-level value.
327 #[must_use]
328 pub fn effective_scroll_resolution(&self, route_key: &str) -> Option<ScrollResolution> {
329 self.link_overrides(route_key)
330 .and_then(|overrides| overrides.scroll_resolution)
331 .or(self.scroll_resolution)
332 }
333
334 /// Lighting on `route_key`: the link's override when the user set one
335 /// there, else the device-level value.
336 #[must_use]
337 pub fn effective_lighting(&self, route_key: &str) -> Option<&Lighting> {
338 self.link_overrides(route_key)
339 .and_then(|overrides| overrides.lighting.as_ref())
340 .or(self.lighting.as_ref())
341 }
342
343 /// SmartShift on `route_key`: the link's override when the user set one
344 /// there, else the device-level value.
345 #[must_use]
346 pub fn effective_smartshift(&self, route_key: &str) -> Option<SmartShift> {
347 self.link_overrides(route_key)
348 .and_then(|overrides| overrides.smartshift)
349 .or(self.smartshift)
350 }
351
352 fn link_overrides(&self, route_key: &str) -> Option<&LinkOverrides> {
353 self.links.get(route_key).map(|link| &link.overrides)
354 }
355
356 /// Whether this entry carries anything the *user* configured, as opposed
357 /// to nothing but the metadata OpenLogi writes on its own: the probed
358 /// [`Self::identity`] and the [`Self::links`] route index.
359 ///
360 /// This is what lets [`Config::resolve_device_key`] tell a bare entry
361 /// created by an identity probe from a pre-upgrade entry holding real
362 /// bindings and DPI. Comparing a stripped clone against [`Default`],
363 /// rather than testing fields one by one, keeps the answer honest as the
364 /// struct grows: a setting added tomorrow counts from the day it exists,
365 /// with nothing here to remember to update.
366 ///
367 /// [`Config::resolve_device_key`]: crate::config::Config::resolve_device_key
368 #[must_use]
369 pub fn holds_settings(&self) -> bool {
370 let mut stripped = self.clone();
371 stripped.identity = None;
372 stripped.links = BTreeMap::new();
373 stripped != Self::default()
374 }
375}
376
377impl Default for DeviceConfig {
378 fn default() -> Self {
379 Self {
380 // A fresh entry (e.g. created by a first DPI write) must stay
381 // managed — `enabled: false` is an explicit user choice only.
382 enabled: true,
383 custom_name: None,
384 gesture_owner: None,
385 identity: None,
386 links: BTreeMap::new(),
387 bindings: BTreeMap::new(),
388 disabled_gestures: BTreeMap::new(),
389 per_app_bindings: BTreeMap::new(),
390 action_ring: ActionRingConfig::default(),
391 dpi_presets: Vec::new(),
392 dpi: None,
393 lighting: None,
394 light: None,
395 smartshift: None,
396 camera_controls: None,
397 camera_profiles: BTreeMap::new(),
398 camera_profile: None,
399 thumbwheel_sensitivity: None,
400 invert_scroll: false,
401 scroll_resolution: None,
402 host_switch_targets: Vec::new(),
403 fn_lock: None,
404 keep_os_layout: None,
405 report_rate: None,
406 }
407 }
408}
409
410/// `serde(default)` helper for `bool` fields that default to `true`.
411fn default_true() -> bool {
412 true
413}
414
415/// `skip_serializing_if` helper for `bool` fields whose default is `true`.
416#[expect(
417 clippy::trivially_copy_pass_by_ref,
418 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
419)]
420fn is_true(b: &bool) -> bool {
421 *b
422}
423
424/// `skip_serializing_if` helper for plain `bool` fields whose default is
425/// `false`: keeps an unset toggle out of `config.toml` entirely.
426#[expect(
427 clippy::trivially_copy_pass_by_ref,
428 reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
429)]
430fn is_false(b: &bool) -> bool {
431 !*b
432}
433
434fn deserialize_dpi_presets<'de, D>(deserializer: D) -> Result<Vec<Dpi>, D::Error>
435where
436 D: serde::Deserializer<'de>,
437{
438 Vec::<u32>::deserialize(deserializer)?
439 .into_iter()
440 .map(|value| {
441 Dpi::try_from(value).map_err(|_| {
442 serde::de::Error::custom(format_args!(
443 "DPI must fit the HID++ 16-bit range, got {value}"
444 ))
445 })
446 })
447 .collect()
448}
449
450fn deserialize_optional_dpi<'de, D>(deserializer: D) -> Result<Option<Dpi>, D::Error>
451where
452 D: serde::Deserializer<'de>,
453{
454 let value = Option::<u32>::deserialize(deserializer)?;
455 value
456 .map(|value| {
457 Dpi::try_from(value).map_err(|_| {
458 serde::de::Error::custom(format_args!(
459 "DPI must fit the HID++ 16-bit range, got {value}"
460 ))
461 })
462 })
463 .transpose()
464}
465
466/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
467/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
468/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
469/// in the v2 shape.
470#[derive(Deserialize)]
471#[serde(deny_unknown_fields)]
472struct RawDeviceConfig {
473 /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
474 /// owner is inferred during the version-gated migration. A
475 /// present-but-invalid legacy value is tolerated as `None` for compatibility
476 /// with v3-and-older behavior; current schemas reject the field first.
477 #[serde(default, deserialize_with = "deserialize_gesture_owner")]
478 gesture_owner: Option<GestureOwner>,
479 #[serde(default)]
480 identity: Option<DeviceIdentity>,
481 /// See [`DeviceConfig::links`].
482 #[serde(default)]
483 links: BTreeMap<String, LinkConfig>,
484 /// v2 shape — present on already-migrated files; wins on any key collision.
485 #[serde(default, deserialize_with = "deserialize_button_map")]
486 bindings: BTreeMap<ButtonId, Binding>,
487 /// v4 stash of turned-off gesture maps (see [`DeviceConfig::disabled_gestures`]).
488 #[serde(default, deserialize_with = "deserialize_button_map")]
489 disabled_gestures: BTreeMap<ButtonId, BTreeMap<GestureDirection, Action>>,
490 /// Legacy v1 per-button single bindings.
491 #[serde(default, deserialize_with = "deserialize_button_map")]
492 button_bindings: BTreeMap<ButtonId, Action>,
493 /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
494 #[serde(default)]
495 gesture_bindings: BTreeMap<GestureDirection, Action>,
496 #[serde(default, deserialize_with = "deserialize_button_maps_by_app")]
497 per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
498 #[serde(default)]
499 action_ring: ActionRingConfig,
500 #[serde(default, deserialize_with = "deserialize_dpi_presets")]
501 dpi_presets: Vec<Dpi>,
502 #[serde(default, deserialize_with = "deserialize_optional_dpi")]
503 dpi: Option<Dpi>,
504 #[serde(default)]
505 lighting: Option<Lighting>,
506 #[serde(default)]
507 light: Option<LightSettings>,
508 #[serde(default)]
509 smartshift: Option<SmartShift>,
510 #[serde(default)]
511 camera_controls: Option<CameraControls>,
512 #[serde(default)]
513 camera_profiles: BTreeMap<String, CameraControls>,
514 #[serde(default)]
515 camera_profile: Option<String>,
516 #[serde(default)]
517 thumbwheel_sensitivity: Option<ThumbwheelSensitivity>,
518 #[serde(default)]
519 invert_scroll: bool,
520 #[serde(default)]
521 scroll_resolution: Option<ScrollResolution>,
522 #[serde(default)]
523 host_switch_targets: Vec<String>,
524 #[serde(default)]
525 fn_lock: Option<bool>,
526 #[serde(default)]
527 keep_os_layout: Option<bool>,
528 #[serde(default)]
529 report_rate: Option<ReportRate>,
530 #[serde(default = "default_true")]
531 enabled: bool,
532 #[serde(default)]
533 custom_name: Option<String>,
534}
535
536impl From<RawDeviceConfig> for DeviceConfig {
537 fn from(raw: RawDeviceConfig) -> Self {
538 let mut bindings = raw.bindings; // the v2 map wins on every key.
539
540 // Re-home the legacy flat gesture map under `GestureButton`. This MUST
541 // happen before folding `button_bindings`, so a legacy single
542 // `button_bindings[GestureButton]` entry coexisting with a
543 // `gesture_bindings` map cannot claim the slot first and silently drop
544 // the whole direction map (the pre-v2 rule was "gesture entries win").
545 if !raw.gesture_bindings.is_empty() {
546 bindings
547 .entry(ButtonId::GestureButton)
548 .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
549 }
550 for (button, action) in raw.button_bindings {
551 // A legacy `button_bindings[GestureButton]` is vestigial and must not
552 // become a `Binding::Single`: the gesture button never dispatched
553 // through the per-button map (it is not an OS-hook button, and its
554 // plain press routes through the gesture `Click` slot — see
555 // agent-core `bindings_for`). A `Single` here would be unreachable —
556 // the GUI hides it and the runtime ignores it — while folding it into
557 // `Click` would resurrect a dead binding as a behavior change. Drop
558 // it: the gesture map (re-homed above) already owns this button, and
559 // an absent entry falls back to the canonical default, exactly as
560 // pre-v2.
561 if button == ButtonId::GestureButton {
562 continue;
563 }
564 bindings.entry(button).or_insert(Binding::Single(action));
565 }
566
567 DeviceConfig {
568 enabled: raw.enabled,
569 custom_name: raw.custom_name,
570 gesture_owner: raw.gesture_owner,
571 identity: raw.identity.map(DeviceIdentity::without_unit_identifiers),
572 links: raw.links,
573 bindings,
574 disabled_gestures: raw.disabled_gestures,
575 per_app_bindings: raw.per_app_bindings,
576 action_ring: raw.action_ring,
577 dpi_presets: raw.dpi_presets,
578 dpi: raw.dpi,
579 lighting: raw.lighting,
580 light: raw.light,
581 smartshift: raw.smartshift,
582 camera_controls: raw.camera_controls,
583 camera_profiles: raw.camera_profiles,
584 camera_profile: raw.camera_profile,
585 thumbwheel_sensitivity: raw.thumbwheel_sensitivity,
586 invert_scroll: raw.invert_scroll,
587 scroll_resolution: raw.scroll_resolution,
588 host_switch_targets: raw.host_switch_targets,
589 fn_lock: raw.fn_lock,
590 keep_os_layout: raw.keep_os_layout,
591 report_rate: raw.report_rate,
592 }
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::DeviceConfig;
599
600 #[test]
601 fn host_switch_targets_round_trip_as_physical_keys() -> Result<(), Box<dyn std::error::Error>> {
602 let config: DeviceConfig = toml::from_str(
603 r#"host_switch_targets = [
604 "receiver:keyboard:slot:1",
605 "receiver:mouse:slot:2",
606]"#,
607 )?;
608
609 assert_eq!(
610 config.host_switch_targets,
611 ["receiver:keyboard:slot:1", "receiver:mouse:slot:2"]
612 );
613 let serialized = toml::to_string(&config)?;
614 assert!(serialized.contains("host_switch_targets"));
615 Ok(())
616 }
617}