Skip to main content

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