Skip to main content

openlogi_hid/write/
lighting.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use hidpp::{
5    channel::{ChannelError, HidppChannel},
6    device::Device,
7    feature::{
8        CreatableFeature,
9        color_led_effects::{ColorLedEffectsFeature, Persistence, ZONE_EFFECT_PARAM_COUNT},
10        per_key_lighting::{
11            FramePersistence, MAX_SINGLE_VALUE_ZONES, PerKeyLightingFeature, Rgb,
12            ZONE_PRESENCE_PAGE_LEN, ZonePresencePage,
13        },
14    },
15};
16use tracing::debug;
17
18use crate::route::DeviceRoute;
19
20use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
21
22/// HID++ `PerKeyLighting` (`0x8080`) — streams each key's colour individually.
23/// Its feature *index* varies per device, so it's resolved at runtime.
24const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
25/// HID++ `ColorLedEffects` (`0x8070`) — the keyboard's effect engine. Writing a
26/// *fixed* effect here replaces a running onboard profile, which a per-key
27/// (`0x8080`) write can't override on G-series keyboards (the firmware keeps
28/// replaying its stored effect). Preferred for a solid colour for that reason.
29const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
30
31// HID++ 2.0 report ids: 0x12 is the 64-byte "very long" report that streams a
32// batch of (keyID, R, G, B) entries; 0x11 is the 20-byte "long" report used both
33// to commit a per-key frame and to carry a single `ColorLedEffects` request.
34const REPORT_SET_KEYS: u8 = 0x12;
35const REPORT_LONG: u8 = 0x11;
36// Function byte = `function_id << 4 | software_id`. Software id 0xa just tags our
37// requests; for 0x8080: function 0x3 streams a key range, 0x5 commits the frame.
38const SW_ID: u8 = 0x0a;
39const FN_SET_KEY_RANGE: u8 = 0x3;
40const FN_FRAME_END: u8 = 0x5;
41// Fixed bytes of the "set key range" payload: a mode flag (byte 5) and the
42// per-frame entry count (byte 7), which is also the chunk size below.
43const SET_RANGE_MODE: u8 = 0x01;
44const KEYS_PER_FRAME: u8 = 0x0e;
45
46// 0x8070 `ColorLedEffects`: zone-effect index 0x01 is the fixed/static single
47// colour, applied volatilely (RAM only) so it shows live and overrides the
48// running onboard profile without touching flash. Reboot survival comes from the
49// agent re-applying the saved colour on device arrival (orchestrator reapply),
50// avoiding flash wear on every colour pick.
51const EFFECT_FIXED: u8 = 0x01;
52// The old raw `0x8070` path intentionally wrote only zones 0..4: enough for the
53// keyboards this path targets and bounded by a small, predictable delay budget.
54// Keep that cap even though the typed wrapper can query the reported zone count;
55// a malformed or unexpectedly large count should not stall a color apply.
56const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
57// Zones are paced apart because the controller can drop closely-spaced reports.
58const FRAME_GAP: Duration = Duration::from_millis(8);
59
60/// Which HID++ lighting path drives a solid keyboard colour. [`Auto`] is what
61/// the GUI/agent use; the explicit variants exist for the `diag` A/B test.
62///
63/// [`Auto`]: LightingMethod::Auto
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum LightingMethod {
66    /// Prefer `ColorLedEffects` (`0x8070`), falling back to `PerKeyLighting2`
67    /// (`0x8081`) and then `PerKeyLighting` (`0x8080`) when the device exposes
68    /// no effect engine.
69    Auto,
70    /// Force `ColorLedEffects` (`0x8070`) — the fixed-effect override.
71    Effects,
72    /// Force `PerKeyLighting` (`0x8080`) — the raw per-key stream.
73    PerKey,
74    /// Force `PerKeyLighting2` (`0x8081`) — the zone-addressed successor to
75    /// `0x8080`.
76    PerKeyV2,
77}
78
79/// Set a keyboard to a solid `(r, g, b)` colour, choosing the HID++ path
80/// automatically: the `0x8070` effect engine (which overrides the onboard
81/// profile) when present, else the `0x8080` per-key stream. `FeatureUnsupported`
82/// when the device exposes neither.
83pub async fn set_keyboard_color(
84    route: &DeviceRoute,
85    r: u8,
86    g: u8,
87    b: u8,
88) -> Result<(), WriteError> {
89    set_keyboard_color_with(route, LightingMethod::Auto, r, g, b).await
90}
91
92/// [`set_keyboard_color`] with an explicit [`LightingMethod`]. `Auto` tries
93/// `0x8070` first and falls back to `0x8080` only when the effect engine is
94/// absent (a missing-`0x8070` `FeatureUnsupported`); any other error propagates.
95pub async fn set_keyboard_color_with(
96    route: &DeviceRoute,
97    method: LightingMethod,
98    r: u8,
99    g: u8,
100    b: u8,
101) -> Result<(), WriteError> {
102    let device_index = route.device_index();
103    with_route(route, move |channel| async move {
104        set_keyboard_color_with_on_channel(&channel, device_index, method, r, g, b).await
105    })
106    .await
107}
108
109pub(super) async fn set_keyboard_color_with_on_channel(
110    channel: &Arc<HidppChannel>,
111    device_index: u8,
112    method: LightingMethod,
113    r: u8,
114    g: u8,
115    b: u8,
116) -> Result<(), WriteError> {
117    match method {
118        LightingMethod::PerKey => set_color_per_key(channel, device_index, r, g, b).await,
119        LightingMethod::PerKeyV2 => set_color_per_key_v2(channel, device_index, r, g, b).await,
120        LightingMethod::Effects => set_color_effects(channel, device_index, r, g, b).await,
121        LightingMethod::Auto => match set_color_effects(channel, device_index, r, g, b).await {
122            Err(WriteError::FeatureUnsupported { feature_hex })
123                if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
124            {
125                debug!("no 0x8070 effect engine — trying the per-key paths");
126                // 0x8081 supersedes 0x8080 and is the one newer keyboards ship,
127                // so it is tried first; a device with neither reports the
128                // original 0x8080 as missing, which is the error this fallback
129                // chain has always ended with.
130                match set_color_per_key_v2(channel, device_index, r, g, b).await {
131                    Err(WriteError::FeatureUnsupported { feature_hex })
132                        if feature_hex == PerKeyLightingFeature::ID =>
133                    {
134                        debug!("no 0x8081 per-key zones — falling back to 0x8080 per-key");
135                        set_color_per_key(channel, device_index, r, g, b).await
136                    }
137                    other => other,
138                }
139            }
140            other => other,
141        },
142    }
143}
144
145/// Resolve `route`'s runtime feature *index* for HID++ `feature_id`. `Ok(None)`
146/// when the device doesn't expose it; the index differs per device, so callers
147/// can't hard-code it.
148async fn resolve_feature_index(
149    channel: &Arc<HidppChannel>,
150    device_index: u8,
151    feature_id: u16,
152) -> Result<Option<u8>, WriteError> {
153    let device = Device::new(Arc::clone(channel), device_index)
154        .await
155        .map_err(|_| WriteError::DeviceUnreachable {
156            index: device_index,
157        })?;
158    let info = device
159        .root()
160        .get_feature(feature_id)
161        .await
162        .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
163    Ok(info.map(|i| i.index))
164}
165
166/// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone,
167/// stored in RAM only (overrides the running onboard profile without touching
168/// flash). `FeatureUnsupported` when the device exposes no `0x8070`.
169///
170/// Uses the typed [`ColorLedEffectsFeature`] wrapper: the real zone count is read
171/// first so only existing zones are driven (a typed `set_zone_effect` awaits the
172/// device's reply, so unlike the former raw fire-and-forget path a write to a
173/// non-existent zone would surface as an error rather than a silent no-op).
174async fn set_color_effects(
175    channel: &Arc<HidppChannel>,
176    index: u8,
177    r: u8,
178    g: u8,
179    b: u8,
180) -> Result<(), WriteError> {
181    let mut device = Device::new(Arc::clone(channel), index)
182        .await
183        .map_err(|_| WriteError::DeviceUnreachable { index })?;
184    let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
185    let zone_count = feature
186        .get_info()
187        .await
188        .map_err(classify_lighting_error)?
189        .zone_count;
190
191    let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
192    params[0] = r;
193    params[1] = g;
194    params[2] = b;
195    let zones_to_write = if zone_count == 0 {
196        debug!(
197            index,
198            "0x8070 reported zero zones; applying legacy 4-zone fallback"
199        );
200        MAX_COLOR_LED_EFFECT_ZONES
201    } else {
202        zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)
203    };
204    if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
205        debug!(
206            index,
207            zone_count,
208            capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
209            "0x8070 zone count capped to legacy write limit"
210        );
211    }
212    for zone in 0..zones_to_write {
213        feature
214            .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile)
215            .await
216            .map_err(classify_lighting_error)?;
217        tokio::time::sleep(FRAME_GAP).await;
218    }
219    debug!(
220        index,
221        zone_count, zones_to_write, r, g, b, "set keyboard colour via typed 0x8070"
222    );
223    Ok(())
224}
225
226/// Classify a HID++ error from the `ColorLedEffects` functions.
227fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
228    classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
229}
230
231/// Set a solid colour via `PerKeyLighting2` (`0x8081`): paint every zone the
232/// device reports as present, then commit the frame. `FeatureUnsupported` when
233/// the device exposes no `0x8081` or reports no zones.
234///
235/// `0x8081` supersedes `0x8080`. It addresses *zones* rather than HID key
236/// usages and answers each request, so unlike the raw `0x8080` stream a write
237/// to a zone the device does not have surfaces as an error instead of being
238/// swallowed. Nothing had ever driven it, which left a keyboard exposing only
239/// `0x8081` with no way to set its colour at all.
240///
241/// Committed volatilely for the same reason as the `0x8070` path: the colour
242/// shows live without a flash write on every colour pick, and the agent
243/// re-applies the saved colour on device arrival.
244async fn set_color_per_key_v2(
245    channel: &Arc<HidppChannel>,
246    index: u8,
247    r: u8,
248    g: u8,
249    b: u8,
250) -> Result<(), WriteError> {
251    let mut device = Device::new(Arc::clone(channel), index)
252        .await
253        .map_err(|_| WriteError::DeviceUnreachable { index })?;
254    let feature = open_feature::<PerKeyLightingFeature>(&mut device).await?;
255
256    let zones = present_zones(&feature).await?;
257    if zones.is_empty() {
258        // The device announces 0x8081 but claims no zones, so there is nothing
259        // to paint — and that won't change on retry. Reported as unsupported so
260        // `Auto` falls through to the 0x8080 stream.
261        debug!(index, "0x8081 reported no present zones");
262        return Err(WriteError::FeatureUnsupported {
263            feature_hex: PerKeyLightingFeature::ID,
264        });
265    }
266
267    let color = Rgb {
268        red: r,
269        green: g,
270        blue: b,
271    };
272    // One request carries at most MAX_SINGLE_VALUE_ZONES ids and silently
273    // ignores the rest, so the chunking is the caller's job.
274    for chunk in zones.chunks(MAX_SINGLE_VALUE_ZONES) {
275        feature
276            .set_rgb_zones_single_value(color, chunk)
277            .await
278            .map_err(classify_per_key_v2_error)?;
279    }
280    feature
281        .frame_end(FramePersistence::Volatile, 0, 0)
282        .await
283        .map_err(classify_per_key_v2_error)?;
284
285    debug!(
286        index,
287        zone_count = zones.len(),
288        r,
289        g,
290        b,
291        "set keyboard colour via typed 0x8081"
292    );
293    Ok(())
294}
295
296/// Every zone id `0x8081` reports as present, read across all three presence
297/// pages.
298///
299/// Ids `0` and `0xff` are end-of-list sentinels the feature rejects, so they
300/// are skipped even if a device sets their bits.
301async fn present_zones(feature: &PerKeyLightingFeature) -> Result<Vec<u8>, WriteError> {
302    let mut zones = Vec::new();
303    for (page, base) in [
304        (ZonePresencePage::Zones0To111, 0u16),
305        (ZonePresencePage::Zones112To223, 112),
306        (ZonePresencePage::Zones224To255, 224),
307    ] {
308        let bitfield = feature
309            .get_rgb_zone_presence(page)
310            .await
311            .map_err(classify_per_key_v2_error)?;
312        collect_present_zones(base, &bitfield, &mut zones);
313    }
314    Ok(zones)
315}
316
317/// Appends the zone ids whose presence bit is set in `bitfield`, a 112-bit
318/// field covering ids `base..base + 112` (bit `i` LSB-first within each byte).
319///
320/// The last page covers only 224..=255, so its high bits are padding; ids past
321/// 255 are skipped rather than wrapped. Ids `0` and `0xff` are the feature's
322/// end-of-list sentinels and are skipped even if a device sets their bits.
323pub(super) fn collect_present_zones(
324    base: u16,
325    bitfield: &[u8; ZONE_PRESENCE_PAGE_LEN],
326    zones: &mut Vec<u8>,
327) {
328    for (byte_index, byte) in bitfield.iter().enumerate() {
329        for bit in 0..8u16 {
330            if byte & (1 << bit) == 0 {
331                continue;
332            }
333            let Ok(offset) = u16::try_from(byte_index * 8) else {
334                continue;
335            };
336            let Ok(zone_id) = u8::try_from(base + offset + bit) else {
337                continue;
338            };
339            if !matches!(zone_id, 0 | 0xff) {
340                zones.push(zone_id);
341            }
342        }
343    }
344}
345
346/// Classify a HID++ error from the `PerKeyLighting2` functions.
347fn classify_per_key_v2_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
348    classify_hidpp_error(error, HidppOperation::Lighting, PerKeyLightingFeature::ID)
349}
350
351/// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour
352/// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device
353/// exposes no `0x8080`.
354async fn set_color_per_key(
355    channel: &Arc<HidppChannel>,
356    device_index: u8,
357    r: u8,
358    g: u8,
359    b: u8,
360) -> Result<(), WriteError> {
361    let feature_index = resolve_feature_index(channel, device_index, PER_KEY_LIGHTING_FEATURE)
362        .await?
363        .ok_or(WriteError::FeatureUnsupported {
364            feature_hex: PER_KEY_LIGHTING_FEATURE,
365        })?;
366
367    for report in per_key_reports(device_index, feature_index, r, g, b) {
368        let written = channel
369            .write_raw_report(&report)
370            .await
371            .map_err(classify_raw_lighting_error)?;
372        if written != report.len() {
373            return Err(WriteError::Hidpp(format!(
374                "raw lighting report wrote {written} of {} bytes",
375                report.len()
376            )));
377        }
378    }
379    debug!(
380        device_index,
381        feature_index, r, g, b, "set keyboard colour via 0x8080"
382    );
383    Ok(())
384}
385
386pub(super) fn per_key_reports(
387    device_index: u8,
388    feature_index: u8,
389    r: u8,
390    g: u8,
391    b: u8,
392) -> Vec<Vec<u8>> {
393    let mut reports = Vec::new();
394    // Each 64-byte `0x12` "set group keys" packet carries up to 14
395    // `(keyID, R, G, B)` entries; keyIDs are HID usage codes. Cover the whole
396    // keyboard usage range (incl. modifiers at `0xe0..`) so every key lights,
397    // then commit the frame.
398    let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
399    for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
400        let mut rep = vec![0u8; 64];
401        rep[0] = REPORT_SET_KEYS;
402        rep[1] = device_index;
403        rep[2] = feature_index;
404        rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
405        rep[5] = SET_RANGE_MODE;
406        rep[7] = KEYS_PER_FRAME;
407        for (i, &key) in chunk.iter().enumerate() {
408            let off = 8 + i * 4;
409            rep[off] = key;
410            rep[off + 1] = r;
411            rep[off + 2] = g;
412            rep[off + 3] = b;
413        }
414        reports.push(rep);
415    }
416    let mut commit = vec![0u8; 20];
417    commit[0] = REPORT_LONG;
418    commit[1] = device_index;
419    commit[2] = feature_index;
420    commit[3] = (FN_FRAME_END << 4) | SW_ID;
421    reports.push(commit);
422    reports
423}
424
425fn classify_raw_lighting_error(error: ChannelError) -> WriteError {
426    match error {
427        ChannelError::Timeout => WriteError::RequestTimedOut {
428            operation: HidppOperation::Lighting,
429        },
430        other => WriteError::Hidpp(format!("{other:?}")),
431    }
432}