Skip to main content

hid_rgb_ctl/
device.rs

1//! HID RGB device control.
2//!
3//! Provides two device types:
4//! - [`LampArrayDevice`]: HID LampArray (Usage Page 0x59) per HUT v1.4 Section 26
5//! - [`LedRgbDevice`]: LED Page RGB LED (Usage Page 0x08) per HUT v1.4 Section 11.7
6
7use std::collections::HashSet;
8use std::fs::OpenOptions;
9use std::os::unix::io::AsRawFd;
10
11use crate::descriptor::{
12    DeviceInfo, DeviceKind, LampArrayReports, LedRgbChannelInfo, ReportInfo, ReportType,
13};
14use crate::error::{Error, Result};
15
16// Linux HIDRAW ioctl numbers
17// HIDIOCGFEATURE = _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x07, len)
18// HIDIOCSFEATURE = _IOC(_IOC_READ|_IOC_WRITE, 'H', 0x06, len)
19
20fn hidiocgfeature(size: usize) -> libc::c_ulong {
21    0xC000_4807 | ((size as libc::c_ulong) << 16)
22}
23
24fn hidiocsfeature(size: usize) -> libc::c_ulong {
25    0xC000_4806 | ((size as libc::c_ulong) << 16)
26}
27
28/// Scale a u8 value from 0-255 range to 0-logical_max range.
29///
30/// When `logical_max` is 255, returns the input unchanged.
31/// When `logical_max` is 100 (e.g. LED Intensity per Section 11.7),
32/// scales proportionally: 255 -> 100, 128 -> 50, etc.
33fn scale_u8(value: u8, logical_max: u32) -> u8 {
34    if logical_max == 0 {
35        return 0;
36    }
37    if logical_max >= 255 {
38        return value;
39    }
40    ((value as u32 * logical_max + 127) / 255) as u8
41}
42
43/// LampArrayKind values (Section 26.2.1).
44pub fn lamp_array_kind_name(kind: u32) -> &'static str {
45    match kind {
46        0 => "Undefined",
47        1 => "Keyboard",
48        2 => "Mouse",
49        3 => "GameController",
50        4 => "Peripheral",
51        5 => "Scene",
52        6 => "Notification",
53        7 => "Chassis",
54        8 => "Wearable",
55        9 => "Furniture",
56        10 => "Art",
57        11 => "Headset",
58        0x100.. => "Vendor-defined",
59        _ => "Reserved",
60    }
61}
62
63/// Attributes of a single lamp (Section 26.3).
64#[derive(Debug, Clone)]
65pub struct LampAttributes {
66    pub lamp_id: u16,
67    /// Position in micrometers.
68    pub position_x_um: u32,
69    pub position_y_um: u32,
70    pub position_z_um: u32,
71    /// Update latency in microseconds.
72    pub update_latency_us: u32,
73    pub lamp_purposes: u32,
74    pub red_level_count: u8,
75    pub green_level_count: u8,
76    pub blue_level_count: u8,
77    pub intensity_level_count: u8,
78    pub is_programmable: bool,
79    pub input_binding: u8,
80}
81
82/// Per-lamp color specification for [`LampArrayDevice::set_lamp_colors`].
83#[derive(Debug, Clone, Copy)]
84pub struct LampColor {
85    pub lamp_id: u16,
86    pub red: u8,
87    pub green: u8,
88    pub blue: u8,
89    pub intensity: u8,
90}
91
92/// LampArray device attributes (Section 26.2).
93#[derive(Debug, Clone)]
94pub struct LampArrayAttributes {
95    pub lamp_count: u16,
96    /// Bounding box in micrometers.
97    pub width_um: u32,
98    pub height_um: u32,
99    pub depth_um: u32,
100    pub kind: u32,
101    pub kind_name: &'static str,
102    pub min_update_interval_us: u32,
103}
104
105/// LED Page RGB device attributes (Section 11.7).
106#[derive(Debug, Clone)]
107pub struct LedRgbAttributes<'a> {
108    pub name: &'a str,
109    pub path: &'a str,
110    pub protocol: &'static str,
111    pub report_id: u8,
112    pub channel_size: u32,
113    pub has_intensity: bool,
114}
115
116// --- Low-level ioctl helpers ---
117
118/// File descriptor wrapper for batched ioctl operations.
119struct HidrawFd {
120    fd: std::fs::File,
121}
122
123impl HidrawFd {
124    fn open(path: &str) -> Result<Self> {
125        let fd = OpenOptions::new()
126            .read(true)
127            .write(true)
128            .open(path)
129            .map_err(|e| match e.kind() {
130                std::io::ErrorKind::PermissionDenied => Error::PermissionDenied {
131                    path: path.to_string(),
132                },
133                std::io::ErrorKind::NotFound => Error::DeviceNotFound {
134                    path: path.to_string(),
135                },
136                _ => Error::Io(e),
137            })?;
138        Ok(Self { fd })
139    }
140
141    /// Read a HID Feature report (HIDIOCGFEATURE).
142    fn feat_get(&self, report_id: u8, size: usize) -> Result<Vec<u8>> {
143        let buf_len = size + 1; // +1 for report ID
144        let mut buf = vec![0u8; buf_len];
145        buf[0] = report_id;
146        let ret = unsafe {
147            libc::ioctl(
148                self.fd.as_raw_fd(),
149                hidiocgfeature(buf_len) as _,
150                buf.as_mut_ptr(),
151            )
152        };
153        if ret < 0 {
154            return Err(std::io::Error::last_os_error().into());
155        }
156        // Truncate to the actual number of bytes returned by the kernel.
157        // The Linux HIDRAW driver returns the real transfer size; if the
158        // device responded with fewer bytes the remainder is undefined.
159        // Callers (parse_lamp_response, get_attributes_with_fd) validate
160        // the resulting length and produce TruncatedReport errors as needed.
161        buf.truncate(ret as usize);
162        Ok(buf)
163    }
164
165    /// Write a HID Feature report (HIDIOCSFEATURE).
166    fn feat_set(&self, buf: &[u8]) -> Result<()> {
167        let ret = unsafe {
168            libc::ioctl(
169                self.fd.as_raw_fd(),
170                hidiocsfeature(buf.len()) as _,
171                buf.as_ptr(),
172            )
173        };
174        if ret < 0 {
175            return Err(std::io::Error::last_os_error().into());
176        }
177        Ok(())
178    }
179
180    /// Write a HID Output report via write() syscall.
181    ///
182    /// On Linux HIDRAW, Output reports are sent by writing directly to the fd
183    /// (as opposed to Feature reports which use ioctl).
184    fn output_set(&self, buf: &[u8]) -> Result<()> {
185        use std::io::Write;
186        (&self.fd).write_all(buf)?;
187        Ok(())
188    }
189}
190
191// --- Helper to require a report ---
192
193fn require_report<'a>(report: &'a Option<ReportInfo>, name: &str) -> Result<&'a ReportInfo> {
194    report.as_ref().ok_or_else(|| Error::MissingReport {
195        report_name: name.to_string(),
196    })
197}
198
199// --- Lamp response parser ---
200
201/// Parse a LampAttributesResponseReport buffer into [`LampAttributes`].
202///
203/// Layout (Section 26.3, verified against Microsoft reference
204/// `LampArrayReportDescriptor.h` Report 3):
205///   `[ReportID, LampId(16), PosX(32), PosY(32), PosZ(32),
206///    Latency(32), Purposes(32), RedCount(8), GreenCount(8),
207///    BlueCount(8), IntensityCount(8), IsProgrammable(8), InputBinding(8)]`
208fn parse_lamp_response(buf: &[u8]) -> Result<LampAttributes> {
209    // Minimum size: ReportId(1) + LampId(2) + Pos(12) + Latency(4) + Purposes(4)
210    //               + RGBI counts(4) + IsProgrammable(1) + InputBinding(1) = 29 bytes
211    // Verified against Microsoft reference LampAttributesResponseReport struct.
212    if buf.len() < 29 {
213        return Err(Error::TruncatedReport {
214            report_name: "LampAttributesResponse",
215            expected: 29,
216            got: buf.len(),
217        });
218    }
219    let lamp_id = u16::from_le_bytes([buf[1], buf[2]]);
220    let pos_x = u32::from_le_bytes([buf[3], buf[4], buf[5], buf[6]]);
221    let pos_y = u32::from_le_bytes([buf[7], buf[8], buf[9], buf[10]]);
222    let pos_z = u32::from_le_bytes([buf[11], buf[12], buf[13], buf[14]]);
223    let latency = u32::from_le_bytes([buf[15], buf[16], buf[17], buf[18]]);
224    let purposes = u32::from_le_bytes([buf[19], buf[20], buf[21], buf[22]]);
225
226    Ok(LampAttributes {
227        lamp_id,
228        position_x_um: pos_x,
229        position_y_um: pos_y,
230        position_z_um: pos_z,
231        update_latency_us: latency,
232        lamp_purposes: purposes,
233        red_level_count: buf[23],
234        green_level_count: buf[24],
235        blue_level_count: buf[25],
236        intensity_level_count: buf[26],
237        is_programmable: buf[27] != 0,
238        input_binding: buf[28],
239    })
240}
241
242// --- LampArrayDevice ---
243
244/// HID LampArray (Usage Page 0x59) control.
245///
246/// Implements the LampArray operation flow per HUT v1.4 Section 26.6:
247/// interrogation -> disable autonomous -> update lamps -> (re-enable autonomous)
248///
249/// Report IDs and sizes come from descriptor parsing, not hardcoded.
250pub struct LampArrayDevice<'a> {
251    info: &'a DeviceInfo,
252}
253
254impl<'a> LampArrayDevice<'a> {
255    pub fn new(info: &'a DeviceInfo) -> Self {
256        debug_assert!(matches!(info.kind, DeviceKind::LampArray(_)));
257        Self { info }
258    }
259
260    /// The device path (e.g. `/dev/hidraw0`).
261    pub fn path(&self) -> &str {
262        &self.info.hidraw_path
263    }
264
265    /// The device name from sysfs.
266    pub fn name(&self) -> &str {
267        &self.info.name
268    }
269
270    fn reports(&self) -> &LampArrayReports {
271        match &self.info.kind {
272            DeviceKind::LampArray(r) => r,
273            _ => unreachable!(),
274        }
275    }
276
277    /// Read LampArrayAttributesReport (Section 26.2).
278    ///
279    /// Returns lamp count, bounding box dimensions, device kind,
280    /// and minimum update interval.
281    pub fn get_attributes(&self) -> Result<LampArrayAttributes> {
282        let fd = HidrawFd::open(&self.info.hidraw_path)?;
283        self.get_attributes_with_fd(&fd)
284    }
285
286    fn get_attributes_with_fd(&self, fd: &HidrawFd) -> Result<LampArrayAttributes> {
287        let rinfo = require_report(&self.reports().attributes, "attributes")?;
288        let buf = fd.feat_get(rinfo.report_id, rinfo.size)?;
289
290        // Minimum size: ReportId(1) + LampCount(2) + 5×u32(20) = 23 bytes
291        // Verified against Microsoft reference LampArrayAttributesReport struct.
292        if buf.len() < 23 {
293            return Err(Error::TruncatedReport {
294                report_name: "LampArrayAttributes",
295                expected: 23,
296                got: buf.len(),
297            });
298        }
299
300        // Layout: [ReportID, LampCount(16), Width(32), Height(32),
301        //          Depth(32), Kind(32), MinInterval(32)]
302        let lamp_count = u16::from_le_bytes([buf[1], buf[2]]);
303        let width = u32::from_le_bytes([buf[3], buf[4], buf[5], buf[6]]);
304        let height = u32::from_le_bytes([buf[7], buf[8], buf[9], buf[10]]);
305        let depth = u32::from_le_bytes([buf[11], buf[12], buf[13], buf[14]]);
306        let kind = u32::from_le_bytes([buf[15], buf[16], buf[17], buf[18]]);
307        let interval = u32::from_le_bytes([buf[19], buf[20], buf[21], buf[22]]);
308
309        Ok(LampArrayAttributes {
310            lamp_count,
311            width_um: width,
312            height_um: height,
313            depth_um: depth,
314            kind,
315            kind_name: lamp_array_kind_name(kind),
316            min_update_interval_us: interval,
317        })
318    }
319
320    /// Read attributes for a single lamp (Section 26.3).
321    ///
322    /// Sends LampAttributesRequestReport with the lamp index,
323    /// then reads LampAttributesResponseReport.
324    pub fn get_lamp(&self, index: u16) -> Result<LampAttributes> {
325        let fd = HidrawFd::open(&self.info.hidraw_path)?;
326        self.get_lamp_with_fd(&fd, index)
327    }
328
329    fn get_lamp_with_fd(&self, fd: &HidrawFd, index: u16) -> Result<LampAttributes> {
330        let req_info = require_report(&self.reports().attr_request, "attr_request")?;
331        let mut req_buf = vec![0u8; req_info.size + 1];
332        req_buf[0] = req_info.report_id;
333        req_buf[1..3].copy_from_slice(&index.to_le_bytes());
334        fd.feat_set(&req_buf)?;
335
336        let resp_info = require_report(&self.reports().attr_response, "attr_response")?;
337        let buf = fd.feat_get(resp_info.report_id, resp_info.size)?;
338        let lamp = parse_lamp_response(&buf)?;
339
340        // Per Section 26.8.2: "The Host must always check the LampId of
341        // the returned report to ensure it was expected."
342        if lamp.lamp_id != index {
343            return Err(Error::LampIdMismatch {
344                expected: index,
345                got: lamp.lamp_id,
346            });
347        }
348
349        Ok(lamp)
350    }
351
352    /// Read all lamp attributes using the auto-increment mechanism (Section 26.8.2).
353    ///
354    /// Sends a single `LampAttributesRequestReport` for LampId=0, then reads
355    /// `lamp_count` consecutive `LampAttributesResponseReport`s. The device
356    /// auto-increments its internal LampId after each successful response,
357    /// reducing the number of ioctl calls from 2N to N+1.
358    ///
359    /// Each response's LampId is validated against the expected sequence.
360    fn read_all_lamps_with_fd(
361        &self,
362        fd: &HidrawFd,
363        lamp_count: u16,
364    ) -> Result<Vec<LampAttributes>> {
365        if lamp_count == 0 {
366            return Ok(Vec::new());
367        }
368
369        // Send request for lamp 0 (sets device internal counter).
370        let req_info = require_report(&self.reports().attr_request, "attr_request")?;
371        let mut req_buf = vec![0u8; req_info.size + 1];
372        req_buf[0] = req_info.report_id;
373        // LampId = 0 (already zeroed)
374        fd.feat_set(&req_buf)?;
375
376        // Read lamp_count responses; device auto-increments after each.
377        let resp_info = require_report(&self.reports().attr_response, "attr_response")?;
378        let mut lamps = Vec::with_capacity(lamp_count as usize);
379
380        for expected_id in 0..lamp_count {
381            let buf = fd.feat_get(resp_info.report_id, resp_info.size)?;
382            let lamp = parse_lamp_response(&buf)?;
383            if lamp.lamp_id != expected_id {
384                return Err(Error::LampIdMismatch {
385                    expected: expected_id,
386                    got: lamp.lamp_id,
387                });
388            }
389            lamps.push(lamp);
390        }
391
392        Ok(lamps)
393    }
394
395    /// Read attributes and all lamp info using a single fd.
396    ///
397    /// Uses the auto-increment mechanism (Section 26.8.2) to read all
398    /// lamp attributes efficiently with a single request followed by
399    /// sequential responses.
400    pub fn get_attributes_and_lamps(&self) -> Result<(LampArrayAttributes, Vec<LampAttributes>)> {
401        let fd = HidrawFd::open(&self.info.hidraw_path)?;
402        let attrs = self.get_attributes_with_fd(&fd)?;
403        let lamps = self.read_all_lamps_with_fd(&fd, attrs.lamp_count)?;
404        Ok((attrs, lamps))
405    }
406
407    /// Toggle AutonomousMode (Section 26.5, 26.10.1).
408    ///
409    /// When `true`: device controls lamps autonomously (built-in effects).
410    /// When `false`: host has exclusive control, device ignores its own effects.
411    /// Default device state is `true` (autonomous).
412    pub fn set_autonomous(&self, enabled: bool) -> Result<()> {
413        let fd = HidrawFd::open(&self.info.hidraw_path)?;
414        self.set_autonomous_with_fd(&fd, enabled)
415    }
416
417    /// Read current AutonomousMode state (Section 26.5, 26.10.1).
418    ///
419    /// Returns `true` if the device is in autonomous mode (device controls),
420    /// `false` if the host has exclusive control.
421    ///
422    /// The `LampArrayControlReport` is a Feature report, so it supports both
423    /// GET (read) and SET (write) via HIDRAW ioctl.
424    pub fn get_autonomous(&self) -> Result<bool> {
425        let ctrl_info = require_report(&self.reports().control, "control")?;
426        let fd = HidrawFd::open(&self.info.hidraw_path)?;
427        let buf = fd.feat_get(ctrl_info.report_id, ctrl_info.size)?;
428        // LampArrayControlReport layout (MS reference Report 6):
429        //   [ReportID(8), AutonomousMode(8)]
430        // 0 = host control, non-zero = autonomous.
431        Ok(buf.get(1).copied().unwrap_or(0) != 0)
432    }
433
434    fn set_autonomous_with_fd(&self, fd: &HidrawFd, enabled: bool) -> Result<()> {
435        require_report(&self.reports().control, "control")?;
436        self.try_set_autonomous_with_fd(fd, enabled)
437    }
438
439    /// Try to set AutonomousMode; silently succeeds if the device has
440    /// no LampArrayControlReport (Section 26.10.1: "If this field is
441    /// absent, it means no autonomous mode is supported.").
442    fn try_set_autonomous_with_fd(&self, fd: &HidrawFd, enabled: bool) -> Result<()> {
443        let ctrl_info = match &self.reports().control {
444            Some(info) => info,
445            None => return Ok(()),
446        };
447        let mut buf = vec![0u8; ctrl_info.size + 1];
448        buf[0] = ctrl_info.report_id;
449        buf[1] = if enabled { 0x01 } else { 0x00 };
450        fd.feat_set(&buf)
451    }
452
453    /// Set all lamps to a uniform color.
454    ///
455    /// Disables autonomous mode, reads all lamp attributes, scales the
456    /// RGBI values to the device's LevelCounts (Section 26.9), then sends
457    /// a LampRangeUpdate covering all lamps with LampUpdateComplete=1.
458    ///
459    /// RGB values are scaled to the minimum LevelCount across all
460    /// *Programmable* lamps in the range (FixedColor lamps' RGB channels
461    /// are ignored by the device per Section 26.11.2). Intensity is scaled
462    /// to the minimum IntensityLevelCount across *all* lamps.
463    ///
464    /// Opens the fd once for the entire sequence.
465    ///
466    /// Note: Callers performing rapid sequential updates should respect
467    /// the device's `min_update_interval_us` (from [`get_attributes()`])
468    /// between calls. Per Section 26.11, the spec requires no more than
469    /// one LampUpdateComplete per MinUpdateIntervalInMicroseconds.
470    pub fn set_color(&self, r: u8, g: u8, b: u8, intensity: u8) -> Result<()> {
471        let range_info = require_report(&self.reports().range_update, "range_update")?;
472        let range_report_id = range_info.report_id;
473        let range_size = range_info.size;
474
475        let fd = HidrawFd::open(&self.info.hidraw_path)?;
476        self.try_set_autonomous_with_fd(&fd, false)?;
477
478        let attrs = self.get_attributes_with_fd(&fd)?;
479        if attrs.lamp_count == 0 {
480            return Ok(());
481        }
482        let lamp_end = attrs.lamp_count - 1;
483
484        // Read all lamp attributes for LevelCount scaling.
485        let lamps = self.read_all_lamps_with_fd(&fd, attrs.lamp_count)?;
486
487        // Compute scaling limits.
488        // RGB: min LevelCount across Programmable lamps only (Section 26.11.2:
489        //   "For FixedColor Lamps, Red/Green/Blue channels are always ignored.")
490        // Intensity: min across all lamps (FixedColor lamps support intensity).
491        let (max_r, max_g, max_b) = lamps.iter().filter(|l| l.is_programmable).fold(
492            (255u32, 255u32, 255u32),
493            |(mr, mg, mb), l| {
494                (
495                    mr.min(l.red_level_count as u32),
496                    mg.min(l.green_level_count as u32),
497                    mb.min(l.blue_level_count as u32),
498                )
499            },
500        );
501        let max_i = lamps
502            .iter()
503            .map(|l| l.intensity_level_count as u32)
504            .min()
505            .unwrap_or(255);
506
507        // LampRangeUpdateReport layout (Section 26.11.2, verified against
508        // Microsoft reference Report 5):
509        // [ReportID, Flags(8), IdStart(16), IdEnd(16), R(8), G(8), B(8), I(8)]
510        let mut buf = vec![0u8; range_size + 1];
511        buf[0] = range_report_id;
512        buf[1] = 0x01; // LampUpdateFlags: bit 0 = LampUpdateComplete
513        buf[2..4].copy_from_slice(&0u16.to_le_bytes());
514        buf[4..6].copy_from_slice(&lamp_end.to_le_bytes());
515        buf[6] = scale_u8(r, max_r);
516        buf[7] = scale_u8(g, max_g);
517        buf[8] = scale_u8(b, max_b);
518        buf[9] = scale_u8(intensity, max_i);
519
520        fd.feat_set(&buf)
521    }
522
523    /// Set individual lamp colors using LampMultiUpdateReport (Section 26.11.1).
524    ///
525    /// Each entry is `(lamp_id, red, green, blue, intensity)`.
526    ///
527    /// Before sending, this method:
528    /// - Validates all LampIds are within the device's LampCount range
529    ///   (Section 26.11.1: "Any LampId >= Device LampCount" is an error)
530    /// - Rejects duplicate LampIds within a single call
531    ///   (Section 26.11.1: "Identical LampId in multiple slots" is an error)
532    /// - Scales RGBI values to each lamp's declared LevelCounts (Section 26.9)
533    /// - Zeros RGB channels for FixedColor lamps (Section 26.11.1 best practice)
534    ///
535    /// Colors are batched into reports based on the device's slot count.
536    /// Intermediate batches set LampUpdateComplete=0; the final batch sets
537    /// LampUpdateComplete=1 so the device applies all updates atomically.
538    ///
539    /// Requires the device descriptor to include a `multi_update` report.
540    ///
541    /// Note: Callers performing rapid sequential updates should respect
542    /// the device's `min_update_interval_us` (from [`get_attributes()`])
543    /// between calls. Per Section 26.11, the spec requires no more than
544    /// one LampUpdateComplete per MinUpdateIntervalInMicroseconds.
545    pub fn set_lamp_colors(&self, colors: &[LampColor]) -> Result<()> {
546        if colors.is_empty() {
547            return Ok(());
548        }
549
550        let multi_info = require_report(&self.reports().multi_update, "multi_update")?;
551        let multi_report_id = multi_info.report_id;
552        let multi_size = multi_info.size;
553
554        // Derive slot count from report data size:
555        //   data = LampCount(1) + Flags(1) + N×LampId(2) + N×RGBI(4) = 2 + 6N
556        let slot_count = (multi_size.saturating_sub(2)) / 6;
557        if slot_count == 0 {
558            return Err(Error::MissingReport {
559                report_name: "multi_update (invalid size)".to_string(),
560            });
561        }
562
563        let fd = HidrawFd::open(&self.info.hidraw_path)?;
564        self.try_set_autonomous_with_fd(&fd, false)?;
565
566        // Read device attributes and all lamp attributes for validation + scaling.
567        let attrs = self.get_attributes_with_fd(&fd)?;
568        let lamps = self.read_all_lamps_with_fd(&fd, attrs.lamp_count)?;
569
570        // Validate: all LampIds must be < LampCount (Section 26.11.1).
571        for c in colors {
572            if c.lamp_id >= attrs.lamp_count {
573                return Err(Error::LampIdOutOfRange {
574                    lamp_id: c.lamp_id,
575                    lamp_count: attrs.lamp_count,
576                });
577            }
578        }
579
580        // Validate: no duplicate LampIds (Section 26.11.1).
581        let mut seen = HashSet::with_capacity(colors.len());
582        for c in colors {
583            if !seen.insert(c.lamp_id) {
584                return Err(Error::DuplicateLampId { lamp_id: c.lamp_id });
585            }
586        }
587
588        // Pre-compute scaled colors per lamp.
589        // Programmable lamps: scale RGBI to individual LevelCounts.
590        // FixedColor lamps: set RGB to 0, scale Intensity only (Section 26.11.1).
591        let scaled: Vec<LampColor> = colors
592            .iter()
593            .map(|c| {
594                let lamp = &lamps[c.lamp_id as usize];
595                if lamp.is_programmable {
596                    LampColor {
597                        lamp_id: c.lamp_id,
598                        red: scale_u8(c.red, lamp.red_level_count as u32),
599                        green: scale_u8(c.green, lamp.green_level_count as u32),
600                        blue: scale_u8(c.blue, lamp.blue_level_count as u32),
601                        intensity: scale_u8(c.intensity, lamp.intensity_level_count as u32),
602                    }
603                } else {
604                    // FixedColor: "as a best practice these channels should
605                    // always be set to 0 by the Host" (Section 26.11.1)
606                    LampColor {
607                        lamp_id: c.lamp_id,
608                        red: 0,
609                        green: 0,
610                        blue: 0,
611                        intensity: scale_u8(c.intensity, lamp.intensity_level_count as u32),
612                    }
613                }
614            })
615            .collect();
616
617        let total_chunks = scaled.len().div_ceil(slot_count);
618
619        for (chunk_idx, chunk) in scaled.chunks(slot_count).enumerate() {
620            let is_last = chunk_idx == total_chunks - 1;
621            let mut buf = vec![0u8; multi_size + 1];
622            buf[0] = multi_report_id;
623
624            // LampMultiUpdateReport layout (Section 26.11.1, MS reference Report 4):
625            //   [ReportID, LampCount(8), LampUpdateFlags(8),
626            //    LampIds[N](16-bit LE), RGBI[N](8-bit × 4)]
627            buf[1] = chunk.len() as u8; // LampCount
628            buf[2] = if is_last { 0x01 } else { 0x00 }; // LampUpdateFlags
629
630            // Fill LampIds (16-bit LE each)
631            let ids_start = 3;
632            for (j, c) in chunk.iter().enumerate() {
633                let off = ids_start + j * 2;
634                buf[off..off + 2].copy_from_slice(&c.lamp_id.to_le_bytes());
635            }
636
637            // Fill RGBI tuples (4 bytes each, starting after all LampId slots)
638            let rgbi_start = ids_start + slot_count * 2;
639            for (j, c) in chunk.iter().enumerate() {
640                let off = rgbi_start + j * 4;
641                buf[off] = c.red;
642                buf[off + 1] = c.green;
643                buf[off + 2] = c.blue;
644                buf[off + 3] = c.intensity;
645            }
646
647            fd.feat_set(&buf)?;
648        }
649
650        Ok(())
651    }
652
653    /// One-line summary for CLI listing.
654    ///
655    /// Returns a static description based on descriptor info only — does not
656    /// open the device or perform any ioctl calls.
657    pub fn summary(&self) -> &'static str {
658        let reports = self.reports();
659        let has_range = reports.range_update.is_some();
660        let has_multi = reports.multi_update.is_some();
661        match (has_range, has_multi) {
662            (true, true) => "LampArray (range+multi update)",
663            (true, false) => "LampArray (range update)",
664            (false, true) => "LampArray (multi update)",
665            (false, false) => "LampArray",
666        }
667    }
668}
669
670// --- LedRgbDevice ---
671
672/// HID LED Page RGB LED (Usage Page 0x08, Section 11.7) control.
673///
674/// Uses the RGB LED collection (Usage 0x52) with individual channel controls:
675///   - Red LED Channel (Usage 0x53)
676///   - Blue LED Channel (Usage 0x54)  -- Note: spec order is R, B, G
677///   - Green LED Channel (Usage 0x55)
678///   - LED Intensity (Usage 0x56, optional)
679///
680/// Byte offsets are determined by descriptor parsing, not assumed.
681pub struct LedRgbDevice<'a> {
682    info: &'a DeviceInfo,
683}
684
685impl<'a> LedRgbDevice<'a> {
686    pub fn new(info: &'a DeviceInfo) -> Self {
687        debug_assert!(matches!(info.kind, DeviceKind::LedRgb(_)));
688        Self { info }
689    }
690
691    /// The device path (e.g. `/dev/hidraw0`).
692    pub fn path(&self) -> &str {
693        &self.info.hidraw_path
694    }
695
696    /// The device name from sysfs.
697    pub fn name(&self) -> &str {
698        &self.info.name
699    }
700
701    fn channels(&self) -> &LedRgbChannelInfo {
702        match &self.info.kind {
703            DeviceKind::LedRgb(c) => c,
704            _ => unreachable!(),
705        }
706    }
707
708    /// Set RGB LED color.
709    ///
710    /// Maps arguments to the correct channel offsets parsed from the descriptor.
711    /// Values are scaled from the caller's 0-255 range to the device's
712    /// LogicalMaximum (e.g. 0-100 for intensity per Section 11.7).
713    ///
714    /// The report is sent via the appropriate mechanism for the report type
715    /// parsed from the descriptor (Feature report via ioctl, Output report
716    /// via write syscall).
717    pub fn set_color(&self, r: u8, g: u8, b: u8, intensity: u8) -> Result<()> {
718        let ch = self.channels();
719        let fd = HidrawFd::open(&self.info.hidraw_path)?;
720        let mut buf = vec![0u8; ch.report_size + 1];
721        buf[0] = ch.report_id;
722
723        // Scale color channels from 0-255 to each channel's LogicalMaximum.
724        // When logical_max == 255 this is an identity transform.
725        buf[1 + ch.red_offset] = scale_u8(r, ch.red_logical_max);
726        buf[1 + ch.blue_offset] = scale_u8(b, ch.blue_logical_max);
727        buf[1 + ch.green_offset] = scale_u8(g, ch.green_logical_max);
728
729        if let Some(off) = ch.intensity_offset {
730            let int_max = ch.intensity_logical_max.unwrap_or(255);
731            buf[1 + off] = scale_u8(intensity, int_max);
732        }
733
734        match ch.report_type {
735            ReportType::Feature => fd.feat_set(&buf),
736            ReportType::Output => fd.output_set(&buf),
737            ReportType::Input => Err(Error::UnsupportedReportType),
738        }
739    }
740
741    /// Return basic device info (LED Page has no LampArray-style attributes).
742    pub fn get_attributes(&self) -> LedRgbAttributes<'_> {
743        let ch = self.channels();
744        LedRgbAttributes {
745            name: &self.info.name,
746            path: &self.info.hidraw_path,
747            protocol: "LED Page RGB (Usage Page 0x08, Section 11.7)",
748            report_id: ch.report_id,
749            channel_size: ch.channel_size,
750            has_intensity: ch.intensity_offset.is_some(),
751        }
752    }
753
754    /// One-line summary for CLI listing.
755    pub fn summary(&self) -> &'static str {
756        "LED RGB"
757    }
758}