openlogi_core/device.rs
1//! Serializable device-model types.
2//!
3//! These mirror the HID++ types from the `hidpp` crate but live here so the
4//! CLI and any future GUI can depend on them without dragging in the protocol
5//! crate or its async transport.
6
7use serde::{Deserialize, Serialize};
8
9mod light;
10
11pub use light::{LightCapabilities, LightValueRange, LightValueRangeError, LightValueUnit};
12
13/// What a paired peripheral is. Mirrors `hidpp::receiver::bolt::BoltDeviceKind`
14/// but is owned by us so consumers don't depend on `hidpp`.
15///
16/// Several upstream "device type" vocabularies feed this one enum, and they do
17/// **not** agree on numbers: the Bolt pairing register uses `Unknown=0,
18/// Keyboard=1, Mouse=2, …`, while the HID++ `0x0005` feature uses
19/// `Keyboard=0, …, Mouse=3, …` (no `Unknown` at all). The asset registry adds a
20/// third, free-form *string* type (`"mouse"`, case-inconsistently `"MOUSE"`).
21/// They are converted to this enum at their respective boundaries — never by
22/// reinterpreting one source's raw byte with another's table — so the numeric
23/// mismatch can't leak past those mappers.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum DeviceKind {
27 /// Mice — the family OpenLogi's binding/DPI panels primarily target.
28 Mouse,
29 /// Keyboards, including lighting-capable ones.
30 Keyboard,
31 /// Standalone numeric keypads.
32 Numpad,
33 /// Presentation remotes (slide clickers).
34 Presenter,
35 /// Remote controls; the registry's `"remotecontrol"` string also folds here.
36 Remote,
37 /// Trackballs — treated like mice for presumed capabilities.
38 Trackball,
39 /// External touchpads; the registry's `"trackpad"` string also folds here.
40 Touchpad,
41 /// Pen/graphics tablets.
42 Tablet,
43 /// Game controllers, mirrored from the Bolt pairing vocabulary.
44 Gamepad,
45 /// Joysticks, mirrored from the Bolt pairing vocabulary.
46 Joystick,
47 /// Audio headsets paired through a receiver.
48 Headset,
49 /// Logitech webcam (UVC), configured through `openlogi-camera`.
50 Camera,
51 /// Not classified by any source — also the "no asset opinion" value
52 /// [`DeviceKind::from_registry_type`] returns for unmodelled strings.
53 Unknown,
54 /// Standalone light or other illumination device controlled outside HID++.
55 ///
56 /// This is an identity hint only. UI controls are gated by the dedicated
57 /// light capability descriptor, never by this variant alone.
58 Light,
59}
60
61impl DeviceKind {
62 /// Parse the OpenLogi asset registry's `type` string into a [`DeviceKind`].
63 ///
64 /// The registry field is free-form and case-inconsistent (both `"mouse"`
65 /// and `"MOUSE"` ship), so we case-fold before matching. Values we don't
66 /// model map to [`DeviceKind::Unknown`], which callers treat as "no asset
67 /// opinion" and fall back to the HID++ classification.
68 #[must_use]
69 pub fn from_registry_type(raw: &str) -> Self {
70 match raw.trim().to_ascii_lowercase().as_str() {
71 "mouse" => Self::Mouse,
72 "keyboard" => Self::Keyboard,
73 "numpad" => Self::Numpad,
74 "presenter" => Self::Presenter,
75 "remote" | "remotecontrol" => Self::Remote,
76 "trackball" => Self::Trackball,
77 "touchpad" | "trackpad" => Self::Touchpad,
78 "tablet" => Self::Tablet,
79 "gamepad" => Self::Gamepad,
80 "joystick" => Self::Joystick,
81 "headset" => Self::Headset,
82 "camera" => Self::Camera,
83 "light" | "lighting" | "illumination_light" => Self::Light,
84 _ => Self::Unknown,
85 }
86 }
87}
88
89/// What a device can be *configured* to do, derived from the HID++ feature
90/// table it reports (feature `0x0001`). This is the source of truth for which
91/// configuration panels the UI offers — a panel shows iff the device exposes
92/// the feature that drives it. Gating on capability rather than on
93/// [`DeviceKind`] is what keeps a misclassified device from losing its panels
94/// (issue #127): kind is an identity guess, capability is what the firmware
95/// actually announced.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
97#[allow(
98 clippy::struct_excessive_bools,
99 reason = "capabilities is a serialized feature-bit DTO; independent booleans keep the IPC/config shape explicit"
100)]
101pub struct Capabilities {
102 /// Reprogrammable buttons — HID++ `0x1b00`–`0x1b04` (ReprogControls).
103 pub buttons: bool,
104 /// Adjustable pointer resolution — HID++ `0x2201` / `0x2202` (AdjustableDpi).
105 pub pointer: bool,
106 /// Solid-colour RGB the lighting panel can actually drive — HID++
107 /// `ColorLedEffects` (`0x8070`) or `PerKeyLighting` (`0x8080`), the features
108 /// `set_keyboard_color` writes. Backlight-only families aren't driven by the
109 /// panel, so they don't flip this and don't earn an inert Lighting tab.
110 pub lighting: bool,
111 /// Native vertical wheel inversion — HID++ `0x2121 HiResWheel` with the
112 /// firmware-reported `has_invert` capability.
113 pub scroll_inversion: bool,
114 /// HID++ `0x2121 HiResWheel` is present, so the wheel reporting resolution
115 /// can be read and changed independently of inversion support.
116 #[serde(default)]
117 pub hires_wheel: bool,
118 /// A horizontal thumb wheel is available: either the dedicated HID++
119 /// `0x2150 Thumbwheel` feature or a legacy `0x6501 Gestures2` descriptor
120 /// (gesture id 46, used by MX Master 2S).
121 #[serde(default)]
122 pub thumbwheel: bool,
123}
124
125impl Capabilities {
126 /// Derive capabilities from the set of HID++ feature IDs a device reports.
127 /// Membership of a driving feature ID flips the corresponding flag.
128 #[must_use]
129 pub fn from_feature_ids(ids: &[u16]) -> Self {
130 const BUTTONS: [u16; 5] = [0x1b00, 0x1b01, 0x1b02, 0x1b03, 0x1b04];
131 const POINTER: [u16; 2] = [0x2201, 0x2202];
132 // PerKeyLighting (0x8080) and ColorLedEffects (0x8070) — both now driven
133 // by `set_keyboard_color` (it prefers 0x8070's fixed effect to override a
134 // running onboard profile, falling back to 0x8080 per-key). Other families
135 // (backlight 0x198x) stay out so they don't earn a tab the panel can't drive.
136 const LIGHTING: [u16; 2] = [0x8080, 0x8070];
137 let has = |family: &[u16]| ids.iter().any(|id| family.contains(id));
138 Self {
139 buttons: has(&BUTTONS),
140 pointer: has(&POINTER),
141 lighting: has(&LIGHTING),
142 scroll_inversion: false,
143 hires_wheel: ids.contains(&0x2121),
144 thumbwheel: ids.contains(&0x2150),
145 }
146 }
147
148 /// Best-effort capabilities for a device we could not probe (offline /
149 /// never reached), guessed from its [`DeviceKind`]. Used only as a fallback
150 /// when no measured [`Capabilities`] exist — a sleeping mouse should still
151 /// show its button/pointer panels so its bindings (host-side) stay
152 /// configurable.
153 #[must_use]
154 pub fn presumed_from_kind(kind: DeviceKind) -> Self {
155 match kind {
156 DeviceKind::Mouse | DeviceKind::Trackball => Self {
157 buttons: true,
158 pointer: true,
159 lighting: false,
160 scroll_inversion: false,
161 hires_wheel: false,
162 thumbwheel: false,
163 },
164 DeviceKind::Keyboard => Self {
165 lighting: true,
166 ..Self::default()
167 },
168 _ => Self::default(),
169 }
170 }
171}
172
173/// Coarse battery bucket reported by the device firmware.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175#[serde(rename_all = "lowercase")]
176pub enum BatteryLevel {
177 /// Almost depleted — the firmware's most urgent bucket.
178 Critical,
179 /// Running low; worth surfacing a charge hint.
180 Low,
181 /// Comfortable middle range, no user action needed.
182 Good,
183 /// At or near full charge.
184 Full,
185 /// The firmware did not report a level, or reported one we don't model.
186 Unknown,
187}
188
189/// Charging state. Mirrors `hidpp 0.2`'s `BatteryStatus` plus `Unknown` for
190/// values added in future protocol versions.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum BatteryStatus {
194 /// Running on battery.
195 Discharging,
196 /// Charging at the normal rate.
197 Charging,
198 /// Charging at reduced current (e.g. from a weak power source).
199 ChargingSlow,
200 /// Charge complete while still connected to power.
201 Full,
202 /// The device reported a charging fault.
203 Error,
204 /// A status value this build doesn't model (future protocol additions).
205 Unknown,
206}
207
208/// Battery snapshot for one paired device, as last polled over HID++.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct BatteryInfo {
211 /// Reported charge percentage (`0..=100`).
212 pub percentage: u8,
213 /// Coarse bucket for UI that doesn't want the raw percentage.
214 pub level: BatteryLevel,
215 /// Charging state at poll time.
216 pub status: BatteryStatus,
217}
218
219/// Identity of an enumerated receiver — no paired-device state (that lives
220/// in [`DeviceInventory::paired`]). For a direct (Bluetooth/wired) device,
221/// a synthetic entry mirroring the device's own HID identity fills this role.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct ReceiverInfo {
224 /// Product string from the HID enumeration (e.g. `"Logi Bolt Receiver"`).
225 pub name: String,
226 /// USB vendor ID (`0x046d` for Logitech).
227 pub vendor_id: u16,
228 /// USB product ID distinguishing the receiver model.
229 pub product_id: u16,
230 /// Platform-reported serial, when one is exposed. Deliberately excluded
231 /// from diagnostics (see [`crate::diagnostics::ReceiverDiag`]).
232 pub unique_id: Option<String>,
233}
234
235/// HID++ `DeviceInformation` (feature 0x0003) snapshot used to identify a
236/// device against external registries (e.g. the OpenLogi asset index).
237///
238/// `model_ids` is the per-transport PID array reported by the firmware,
239/// ordered to match the transports flagged in [`Self::transports`] (USB,
240/// eQuad, BTLE, Bluetooth) — slots that aren't enabled stay `0`. The Logi
241/// Options+ asset registry's `modelId` (e.g. `"6b023"`) is the concatenation
242/// of an extended-model byte and one of these PIDs, so callers usually want
243/// to format `extended_model_id` + `model_ids[N]` to match.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct DeviceModelInfo {
246 /// Number of firmware entities (main firmware, bootloader, …) the
247 /// device reports.
248 pub entity_count: u8,
249 /// HID++ DeviceInformation serial number, when the device supports the
250 /// optional serial-number function.
251 pub serial_number: Option<String>,
252 /// Per-unit ID bytes — unique to the physical unit, unlike the
253 /// model-level fields around it.
254 pub unit_id: [u8; 4],
255 /// Which transports the firmware supports; defines the slot order of
256 /// [`Self::model_ids`].
257 pub transports: DeviceTransports,
258 /// Per-transport PIDs ordered to match [`Self::transports`] (USB, eQuad,
259 /// BTLE, Bluetooth); slots for disabled transports stay `0`.
260 pub model_ids: [u16; 3],
261 /// Extra model byte prefixed to a PID to form the asset registry's
262 /// `modelId` — see [`Self::config_key`].
263 pub extended_model_id: u8,
264}
265
266impl DeviceModelInfo {
267 /// Stable identifier used to key per-device configuration (button
268 /// bindings, etc.) and to look up assets in the OpenLogi asset registry.
269 ///
270 /// Format: `{extended_model_id:x}{model_ids[0]:04x}` — the same string
271 /// the depot `manifest.json` uses for its `modelId` field. Example: an
272 /// MX Master 4 with `extended_model_id = 0x02` and `model_ids[0] = 0xb042`
273 /// resolves to `"2b042"`.
274 #[must_use]
275 pub fn config_key(&self) -> String {
276 format!("{:x}{:04x}", self.extended_model_id, self.model_ids[0])
277 }
278}
279
280/// Mirror of hidpp's `DeviceTransport` bitfield — one bool per protocol the
281/// device firmware exposes. The shape is dictated by HID++ feature 0x0003;
282/// a state machine doesn't fit since a single device can announce multiple
283/// transports simultaneously.
284#[allow(
285 clippy::struct_excessive_bools,
286 reason = "bitfield mirroring HID++ DeviceInformation; transports are independent flags"
287)]
288#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct DeviceTransports {
290 /// Wired USB.
291 pub usb: bool,
292 /// Logitech eQuad — the Unifying/Bolt receiver RF protocol.
293 pub equad: bool,
294 /// Bluetooth Low Energy.
295 pub btle: bool,
296 /// Classic Bluetooth.
297 pub bluetooth: bool,
298}
299
300/// One device in the agent's inventory snapshot: a receiver pairing slot,
301/// or a direct (Bluetooth/wired) attachment under its synthetic
302/// [`ReceiverInfo`]. Embedded in [`DeviceInventory`], so its field order is
303/// IPC wire format — see that type's contract.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct PairedDevice {
306 /// Receiver-assigned slot (1..=6 for Bolt).
307 pub slot: u8,
308 /// Firmware codename (e.g. `"MX Master 3S"`), when reported.
309 pub codename: Option<String>,
310 /// Wireless product ID. `None` for offline / unreachable devices on hidpp 0.2.
311 pub wpid: Option<u16>,
312 /// Best-guess classification. Identity only — panel gating uses
313 /// [`Self::capabilities`] instead, so a misread kind can't hide panels
314 /// (issue #127).
315 pub kind: DeviceKind,
316 /// Whether the device was reachable at enumeration time; offline devices
317 /// keep their slot with reduced detail.
318 pub online: bool,
319 /// Last battery reading, `None` when offline or the device doesn't
320 /// report battery.
321 pub battery: Option<BatteryInfo>,
322 /// Output of HID++ feature 0x0003 — populated for online devices that
323 /// expose the feature. Drives asset-registry lookups in the GUI.
324 pub model_info: Option<DeviceModelInfo>,
325 /// Configuration capabilities derived from the device's HID++ feature
326 /// table. `None` for devices we couldn't probe (offline / unreachable);
327 /// the GUI then falls back to [`Capabilities::presumed_from_kind`].
328 pub capabilities: Option<Capabilities>,
329}
330
331/// Address of a standalone raw-HID interface.
332///
333/// The identity is an opaque transport-generated string. It is deliberately
334/// kept separate from the HID++ receiver/slot address so a raw device cannot
335/// accidentally enter the HID++ `Direct` path.
336#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
337pub struct RawDeviceAddress {
338 /// HID vendor ID.
339 pub vendor_id: u16,
340 /// HID product ID.
341 pub product_id: u16,
342 /// HID usage page.
343 pub usage_page: u16,
344 /// HID usage ID.
345 pub usage_id: u16,
346 /// Identity chosen by the transport: a serial when available, otherwise
347 /// an explicitly transient OS-node identity. It is never an enumeration
348 /// index and a transient value is not persisted as a physical key.
349 pub identity: String,
350}
351
352/// A standalone device that is not a HID++ receiver pairing slot.
353///
354/// This is the inventory bridge for Litra and future non-HID++ categories.
355/// Receiver-backed devices continue to use [`PairedDevice`] inside
356/// [`DeviceInventory`].
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct StandaloneDevice {
359 /// Raw HID address used to re-find the interface.
360 pub address: RawDeviceAddress,
361 /// Human-readable name supplied by the OS/HID descriptor.
362 pub display_name: String,
363 /// Human-readable manufacturer, when available.
364 pub manufacturer: Option<String>,
365 /// Device serial, when the HID backend exposes one.
366 pub serial_number: Option<String>,
367 /// Stable four-byte identity when the protocol/driver provides one.
368 /// Raw HID drivers may use zeroes when no such field exists.
369 pub unit_id: [u8; 4],
370 /// Identity classification. Capability fields gate controls.
371 pub kind: DeviceKind,
372 /// Whether this interface was present in the latest completed scan.
373 pub online: bool,
374 /// HID++ capabilities are absent for a non-HID++ device.
375 pub capabilities: Option<Capabilities>,
376 /// Standalone capability descriptor, if the selected driver recognizes it.
377 pub light_capabilities: Option<LightCapabilities>,
378 /// Stable identifier of the driver family that owns this raw interface.
379 /// This is deliberately separate from the product ID so a future family
380 /// can share a protocol driver across several product variants.
381 pub driver_id: String,
382 /// Optional model-level identity in the OpenLogi asset registry.
383 ///
384 /// This is deliberately appended: `StandaloneDevice` crosses the
385 /// append-only GUI↔agent bincode wire format.
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub registry_model_id: Option<String>,
388}
389
390/// One receiver and its paired devices — the unit the agent's inventory
391/// snapshot is made of.
392///
393/// Crosses the agent↔GUI IPC (everything it embeds too: [`ReceiverInfo`],
394/// [`PairedDevice`], battery/model-info/capability types). bincode encodes
395/// field and variant *order*, so reordering, retyping, or wrapping any field
396/// in this tree is a wire-format change and requires a `PROTOCOL_VERSION`
397/// bump (guarded by `openlogi-agent-core/tests/wire_format.rs`).
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
399pub struct DeviceInventory {
400 /// The receiver's identity — synthetic (mirroring the device itself)
401 /// for a direct Bluetooth/wired attachment.
402 pub receiver: ReceiverInfo,
403 /// The devices reached through this receiver; a direct attachment
404 /// carries exactly one entry.
405 pub paired: Vec<PairedDevice>,
406}
407
408#[cfg(test)]
409mod tests {
410 #![allow(
411 clippy::expect_used,
412 reason = "range fixture construction is intentionally asserted in tests"
413 )]
414
415 use super::{
416 BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceInventory, DeviceKind,
417 DeviceModelInfo, DeviceTransports, LightValueRange, LightValueUnit, PairedDevice,
418 ReceiverInfo,
419 };
420
421 fn inventory(slot: u8, wpid: Option<u16>, battery_percentage: u8) -> DeviceInventory {
422 DeviceInventory {
423 receiver: ReceiverInfo {
424 name: "Logi Bolt Receiver".to_string(),
425 vendor_id: 0x046d,
426 product_id: 0xc548,
427 unique_id: Some("receiver-1".to_string()),
428 },
429 paired: vec![PairedDevice {
430 slot,
431 codename: Some("MX Test".to_string()),
432 wpid,
433 kind: DeviceKind::Mouse,
434 online: true,
435 battery: Some(BatteryInfo {
436 percentage: battery_percentage,
437 level: BatteryLevel::Good,
438 status: BatteryStatus::Discharging,
439 }),
440 model_info: Some(DeviceModelInfo {
441 entity_count: 1,
442 serial_number: Some("serial-1".to_string()),
443 unit_id: [1, 2, 3, 4],
444 transports: DeviceTransports {
445 usb: true,
446 equad: true,
447 btle: false,
448 bluetooth: false,
449 },
450 model_ids: [0xb023, 0, 0],
451 extended_model_id: 0x02,
452 }),
453 capabilities: Some(Capabilities {
454 buttons: true,
455 pointer: true,
456 lighting: false,
457 scroll_inversion: false,
458 hires_wheel: false,
459 thumbwheel: false,
460 }),
461 }],
462 }
463 }
464
465 #[test]
466 fn device_inventory_equality_includes_nested_device_fields() {
467 let base = inventory(1, Some(0xb023), 86);
468 assert_eq!(base, base.clone());
469
470 assert_ne!(
471 base,
472 inventory(2, Some(0xb023), 86),
473 "slot changes must affect inventory equality"
474 );
475 assert_ne!(
476 base,
477 inventory(1, Some(0xb024), 86),
478 "wireless product id changes must affect inventory equality"
479 );
480 assert_ne!(
481 base,
482 inventory(1, Some(0xb023), 87),
483 "nested battery changes must affect inventory equality"
484 );
485 }
486
487 #[test]
488 fn registry_type_is_case_folded() {
489 // The registry ships both `"mouse"` and `"MOUSE"`; both must resolve so
490 // the asset cross-check can't silently miss a depot.
491 assert_eq!(DeviceKind::from_registry_type("mouse"), DeviceKind::Mouse);
492 assert_eq!(DeviceKind::from_registry_type("MOUSE"), DeviceKind::Mouse);
493 assert_eq!(
494 DeviceKind::from_registry_type(" Keyboard "),
495 DeviceKind::Keyboard
496 );
497 }
498
499 #[test]
500 fn unknown_registry_type_defers_to_the_caller() {
501 // Unmodelled / empty → Unknown, i.e. "no asset opinion".
502 assert_eq!(
503 DeviceKind::from_registry_type("webcam"),
504 DeviceKind::Unknown
505 );
506 assert_eq!(DeviceKind::from_registry_type(""), DeviceKind::Unknown);
507 }
508
509 #[test]
510 fn capabilities_track_the_driving_feature_ids() {
511 use super::Capabilities;
512 // A typical MX mouse: ReprogControls (0x1b04) + ExtendedAdjustableDpi
513 // (0x2202), no lighting.
514 let mouse =
515 Capabilities::from_feature_ids(&[0x0003, 0x1b04, 0x2121, 0x2150, 0x2202, 0x2110]);
516 assert_eq!(
517 mouse,
518 Capabilities {
519 buttons: true,
520 pointer: true,
521 lighting: false,
522 scroll_inversion: false,
523 hires_wheel: true,
524 thumbwheel: true,
525 }
526 );
527 assert!(!Capabilities::from_feature_ids(&[0x0003, 0x1b04]).thumbwheel);
528 // A wired G-series keyboard: PerKeyLighting (0x8080), no DPI/buttons.
529 let keyboard = Capabilities::from_feature_ids(&[0x0001, 0x8080]);
530 assert_eq!(
531 keyboard,
532 Capabilities {
533 buttons: false,
534 pointer: false,
535 lighting: true,
536 scroll_inversion: false,
537 hires_wheel: false,
538 thumbwheel: false,
539 }
540 );
541 // No driving features → nothing offered.
542 assert_eq!(
543 Capabilities::from_feature_ids(&[0x0000, 0x0003]),
544 Capabilities::default()
545 );
546 }
547
548 #[test]
549 fn persisted_capabilities_without_appended_wheel_fields_load_as_unsupported()
550 -> Result<(), toml::de::Error> {
551 use super::Capabilities;
552
553 let capabilities: Capabilities = toml::from_str(
554 r"
555 buttons = true
556 pointer = true
557 lighting = false
558 scroll_inversion = true
559 ",
560 )?;
561
562 assert!(!capabilities.hires_wheel);
563 assert!(!capabilities.thumbwheel);
564 assert!(capabilities.scroll_inversion);
565 Ok(())
566 }
567
568 #[test]
569 fn presumed_capabilities_keep_an_unprobed_mouse_configurable() {
570 use super::Capabilities;
571 let mouse = Capabilities::presumed_from_kind(DeviceKind::Mouse);
572 assert!(mouse.buttons && mouse.pointer && !mouse.lighting);
573 assert!(!mouse.thumbwheel);
574 assert!(Capabilities::presumed_from_kind(DeviceKind::Keyboard).lighting);
575 // An unidentified device presumes nothing — it must be measured.
576 assert_eq!(
577 Capabilities::presumed_from_kind(DeviceKind::Unknown),
578 Capabilities::default()
579 );
580 }
581
582 #[test]
583 fn light_ranges_reject_invalid_grids_and_units() {
584 assert!(LightValueRange::new(10, 1, 1, LightValueUnit::Lumens).is_err());
585 assert!(LightValueRange::new(0, 10, 0, LightValueUnit::Lumens).is_err());
586 assert!(LightValueRange::new(0, 10, 3, LightValueUnit::Lumens).is_err());
587 assert!(LightValueRange::new(0, 101, 1, LightValueUnit::Percent).is_err());
588 }
589
590 #[test]
591 fn light_ranges_quantize_without_leaving_the_advertised_grid() {
592 let range = LightValueRange::new(20, 250, 10, LightValueUnit::Lumens).expect("valid range");
593 assert_eq!(range.native_for_percent(0), Some(20));
594 assert_eq!(range.native_for_percent(50), Some(140));
595 assert_eq!(range.native_for_percent(100), Some(250));
596 assert_eq!(range.quantize(249), 250);
597 assert!(range.contains(range.native_for_percent(65).expect("mapped value")));
598 }
599
600 #[test]
601 fn invalid_light_ranges_fail_toml_deserialization() {
602 let result = toml::from_str::<LightValueRange>(
603 "min = 2700\nmax = 6500\nstep = 0\nunit = 'kelvin'\n",
604 );
605 assert!(result.is_err());
606 }
607}