Skip to main content

hidpp/feature/per_key_lighting/
mod.rs

1//! Implements the `PerKeyLighting` feature (ID `0x8081`, version 0) that sets
2//! individual RGB zones (typically per-key) on a keyboard.
3//!
4//! Zone updates are staged with the various `set_*_rgb_zones` functions and then
5//! committed as a frame with [`frame_end`](PerKeyLightingFeature::frame_end).
6//! Several setters trade addressing flexibility for the number of zones updated
7//! per request; the delta-compression variants pack the most zones by sending
8//! signed per-channel deltas from the previous frame.
9
10#[cfg(test)]
11mod tests;
12
13use std::sync::Arc;
14
15use num_enum::{IntoPrimitive, TryFromPrimitive};
16
17use crate::{
18    channel::HidppChannel,
19    feature::{CreatableFeature, Feature, FeatureEndpoint},
20    protocol::v20::{ErrorType, Hidpp20Error},
21};
22
23/// Length of the zone-presence bitfield page returned by `getInfo`.
24pub const ZONE_PRESENCE_PAGE_LEN: usize = 14;
25/// Length of the packed payload for the delta-compression setters.
26pub const DELTA_PACKED_LEN: usize = 15;
27/// `typeOfInfo` value selecting the zone-presence query.
28const TYPE_RGB_ZONE_PRESENCE: u8 = 0x00;
29/// Maximum zones per `setIndividualRgbZones` request.
30const MAX_INDIVIDUAL_ZONES: usize = 4;
31/// Number of zones per `setConsecutiveRgbZones` request.
32const CONSECUTIVE_ZONES: usize = 5;
33/// Maximum ranges per `setRangeRgbZones` request.
34const MAX_RANGES: usize = 3;
35/// Maximum zones per `setRgbZonesSingleValue` request.
36const MAX_SINGLE_VALUE_ZONES: usize = 13;
37
38/// An 8-bit-per-channel RGB color.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize))]
41pub struct Rgb {
42    /// Red channel.
43    pub red: u8,
44    /// Green channel.
45    pub green: u8,
46    /// Blue channel.
47    pub blue: u8,
48}
49
50/// A single zone and the color to apply to it.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52#[cfg_attr(feature = "serde", derive(serde::Serialize))]
53pub struct RgbZone {
54    /// Zone identifier (`0` and `255` are reserved end-of-list sentinels).
55    pub zone_id: u8,
56    /// Color to apply.
57    pub color: Rgb,
58}
59
60/// A contiguous range of zones to fill with one color.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize))]
63pub struct RgbZoneRange {
64    /// First zone identifier in the range (inclusive).
65    pub first_zone_id: u8,
66    /// Last zone identifier in the range (inclusive).
67    pub last_zone_id: u8,
68    /// Color to apply across the range.
69    pub color: Rgb,
70}
71
72/// Which page of zone IDs a presence query covers.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize))]
75#[non_exhaustive]
76#[repr(u8)]
77pub enum ZonePresencePage {
78    /// Zone IDs 0..=111.
79    Zones0To111 = 0,
80    /// Zone IDs 112..=223.
81    Zones112To223 = 1,
82    /// Zone IDs 224..=255.
83    Zones224To255 = 2,
84}
85
86/// Storage persistence for [`frame_end`](PerKeyLightingFeature::frame_end).
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, IntoPrimitive, TryFromPrimitive)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89#[non_exhaustive]
90#[repr(u8)]
91pub enum FramePersistence {
92    /// Volatile: applied to RAM only.
93    Volatile = 0,
94    /// Applied to RAM and stored in EEPROM.
95    VolatileAndNonVolatile = 1,
96}
97
98/// Implements the `PerKeyLighting` / `0x8081` feature.
99#[derive(Clone)]
100pub struct PerKeyLightingFeature {
101    /// The endpoint this feature talks to.
102    endpoint: FeatureEndpoint,
103}
104
105impl CreatableFeature for PerKeyLightingFeature {
106    const ID: u16 = 0x8081;
107    const STARTING_VERSION: u8 = 0;
108
109    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
110        Self {
111            endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
112        }
113    }
114}
115
116impl Feature for PerKeyLightingFeature {}
117
118impl PerKeyLightingFeature {
119    /// Retrieves a page of the RGB zone-presence bitfield.
120    ///
121    /// The returned 14 bytes form a 112-bit field; bit `i` (LSB-first within each
122    /// byte) reports whether the zone at `page` base `+ i` is present.
123    pub async fn get_rgb_zone_presence(
124        &self,
125        page: ZonePresencePage,
126    ) -> Result<[u8; ZONE_PRESENCE_PAGE_LEN], Hidpp20Error> {
127        let payload = self
128            .endpoint
129            .call(0, [TYPE_RGB_ZONE_PRESENCE, page.into(), 0])
130            .await?
131            .extend_payload();
132        let mut bitfield = [0; ZONE_PRESENCE_PAGE_LEN];
133        bitfield.copy_from_slice(&payload[2..2 + ZONE_PRESENCE_PAGE_LEN]);
134        Ok(bitfield)
135    }
136
137    /// Sets up to four individually addressed zones.
138    ///
139    /// At most four zones are sent; extra entries are ignored.
140    pub async fn set_individual_rgb_zones(&self, zones: &[RgbZone]) -> Result<(), Hidpp20Error> {
141        validate_individual_zones(zones)?;
142        self.endpoint
143            .call_long(1, individual_zones_args(zones))
144            .await?;
145        Ok(())
146    }
147
148    /// Sets five consecutive zones starting at `first_zone_id`.
149    pub async fn set_consecutive_rgb_zones(
150        &self,
151        first_zone_id: u8,
152        colors: [Rgb; CONSECUTIVE_ZONES],
153    ) -> Result<(), Hidpp20Error> {
154        validate_zone_id(first_zone_id)?;
155        self.endpoint
156            .call_long(2, consecutive_zones_args(first_zone_id, colors))
157            .await?;
158        Ok(())
159    }
160
161    /// Sets eight consecutive zones from `first_zone_id` using 5-bit signed
162    /// per-channel deltas.
163    ///
164    /// `packed` carries the 8×3 5-bit deltas packed MSB-first, zone-by-zone then
165    /// channel-by-channel, exactly as defined by the feature spec; this wrapper
166    /// transmits it verbatim.
167    pub async fn set_consecutive_rgb_zones_delta_5bit(
168        &self,
169        first_zone_id: u8,
170        packed: [u8; DELTA_PACKED_LEN],
171    ) -> Result<(), Hidpp20Error> {
172        self.send_delta(3, first_zone_id, packed).await
173    }
174
175    /// Sets ten consecutive zones from `first_zone_id` using 4-bit signed
176    /// per-channel deltas.
177    ///
178    /// `packed` carries the 10×3 4-bit signed deltas, two per byte (high nibble
179    /// first), as defined by the feature spec; this wrapper transmits it verbatim.
180    pub async fn set_consecutive_rgb_zones_delta_4bit(
181        &self,
182        first_zone_id: u8,
183        packed: [u8; DELTA_PACKED_LEN],
184    ) -> Result<(), Hidpp20Error> {
185        self.send_delta(4, first_zone_id, packed).await
186    }
187
188    /// Sets up to three independent ranges, each filled with one color.
189    ///
190    /// At most three ranges are sent; extra entries are ignored.
191    pub async fn set_range_rgb_zones(&self, ranges: &[RgbZoneRange]) -> Result<(), Hidpp20Error> {
192        validate_ranges(ranges)?;
193        self.endpoint.call_long(5, range_zones_args(ranges)).await?;
194        Ok(())
195    }
196
197    /// Applies one color to up to thirteen individually addressed zones.
198    ///
199    /// At most thirteen zone IDs are sent; extra entries are ignored.
200    pub async fn set_rgb_zones_single_value(
201        &self,
202        color: Rgb,
203        zone_ids: &[u8],
204    ) -> Result<(), Hidpp20Error> {
205        validate_single_value_zones(zone_ids)?;
206        self.endpoint
207            .call_long(6, single_value_args(color, zone_ids))
208            .await?;
209        Ok(())
210    }
211
212    /// Commits all pending zone changes and updates the display.
213    ///
214    /// `current_frame` and `frames_till_next_change` drive frame animations; pass
215    /// `0` for both for a one-shot update.
216    pub async fn frame_end(
217        &self,
218        persistence: FramePersistence,
219        current_frame: u16,
220        frames_till_next_change: u16,
221    ) -> Result<(), Hidpp20Error> {
222        let args = frame_end_args(persistence, current_frame, frames_till_next_change);
223        self.endpoint.call_long(7, args).await?;
224        Ok(())
225    }
226
227    /// Shared body of the delta-compression setters.
228    async fn send_delta(
229        &self,
230        function: u8,
231        first_zone_id: u8,
232        packed: [u8; DELTA_PACKED_LEN],
233    ) -> Result<(), Hidpp20Error> {
234        validate_zone_id(first_zone_id)?;
235        self.endpoint
236            .call_long(function, delta_args(first_zone_id, packed))
237            .await?;
238        Ok(())
239    }
240}
241
242fn validate_zone_id(zone_id: u8) -> Result<(), Hidpp20Error> {
243    if matches!(zone_id, 0 | 0xff) {
244        return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument));
245    }
246    Ok(())
247}
248
249fn validate_individual_zones(zones: &[RgbZone]) -> Result<(), Hidpp20Error> {
250    for zone in zones.iter().take(MAX_INDIVIDUAL_ZONES) {
251        validate_zone_id(zone.zone_id)?;
252    }
253    Ok(())
254}
255
256fn validate_ranges(ranges: &[RgbZoneRange]) -> Result<(), Hidpp20Error> {
257    for range in ranges.iter().take(MAX_RANGES) {
258        validate_zone_id(range.first_zone_id)?;
259        validate_zone_id(range.last_zone_id)?;
260    }
261    Ok(())
262}
263
264fn validate_single_value_zones(zone_ids: &[u8]) -> Result<(), Hidpp20Error> {
265    for &zone_id in zone_ids.iter().take(MAX_SINGLE_VALUE_ZONES) {
266        validate_zone_id(zone_id)?;
267    }
268    Ok(())
269}
270
271/// Encodes a `setIndividualRgbZones` request.
272fn individual_zones_args(zones: &[RgbZone]) -> [u8; 16] {
273    let mut args = [0; 16];
274    for (slot, zone) in zones.iter().take(MAX_INDIVIDUAL_ZONES).enumerate() {
275        let base = slot * 4;
276        args[base] = zone.zone_id;
277        args[base + 1] = zone.color.red;
278        args[base + 2] = zone.color.green;
279        args[base + 3] = zone.color.blue;
280    }
281    args
282}
283
284/// Encodes a `setConsecutiveRgbZones` request.
285fn consecutive_zones_args(first_zone_id: u8, colors: [Rgb; CONSECUTIVE_ZONES]) -> [u8; 16] {
286    let mut args = [0; 16];
287    args[0] = first_zone_id;
288    for (i, color) in colors.iter().enumerate() {
289        let base = 1 + i * 3;
290        args[base] = color.red;
291        args[base + 1] = color.green;
292        args[base + 2] = color.blue;
293    }
294    args
295}
296
297/// Encodes a `setRangeRgbZones` request.
298fn range_zones_args(ranges: &[RgbZoneRange]) -> [u8; 16] {
299    let mut args = [0; 16];
300    for (slot, range) in ranges.iter().take(MAX_RANGES).enumerate() {
301        let base = slot * 5;
302        args[base] = range.first_zone_id;
303        args[base + 1] = range.last_zone_id;
304        args[base + 2] = range.color.red;
305        args[base + 3] = range.color.green;
306        args[base + 4] = range.color.blue;
307    }
308    args
309}
310
311/// Encodes a `setRgbZonesSingleValue` request.
312fn single_value_args(color: Rgb, zone_ids: &[u8]) -> [u8; 16] {
313    let mut args = [0; 16];
314    args[0] = color.red;
315    args[1] = color.green;
316    args[2] = color.blue;
317    for (i, &zone_id) in zone_ids.iter().take(MAX_SINGLE_VALUE_ZONES).enumerate() {
318        args[3 + i] = zone_id;
319    }
320    args
321}
322
323/// Encodes a `frameEnd` request.
324fn frame_end_args(
325    persistence: FramePersistence,
326    current_frame: u16,
327    frames_till_next_change: u16,
328) -> [u8; 16] {
329    let [frame_hi, frame_lo] = current_frame.to_be_bytes();
330    let [next_hi, next_lo] = frames_till_next_change.to_be_bytes();
331    let mut args = [0; 16];
332    args[..5].copy_from_slice(&[persistence.into(), frame_hi, frame_lo, next_hi, next_lo]);
333    args
334}
335
336/// Encodes a delta-compression request body (`first_zone_id` + packed deltas).
337fn delta_args(first_zone_id: u8, packed: [u8; DELTA_PACKED_LEN]) -> [u8; 16] {
338    let mut args = [0; 16];
339    args[0] = first_zone_id;
340    args[1..1 + DELTA_PACKED_LEN].copy_from_slice(&packed);
341    args
342}