Skip to main content

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