Skip to main content

openlogi_hid/write/
lighting.rs

1use std::time::Duration;
2
3use async_hid::AsyncHidWrite;
4use hidpp::{
5    device::Device,
6    feature::{
7        CreatableFeature,
8        color_led_effects::{ColorLedEffectsFeature, Persistence, ZONE_EFFECT_PARAM_COUNT},
9    },
10};
11use tracing::debug;
12
13use crate::route::DeviceRoute;
14
15use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
16
17/// HID++ `PerKeyLighting` (`0x8080`) — streams each key's colour individually.
18/// Its feature *index* varies per device, so it's resolved at runtime.
19const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
20/// HID++ `ColorLedEffects` (`0x8070`) — the keyboard's effect engine. Writing a
21/// *fixed* effect here replaces a running onboard profile, which a per-key
22/// (`0x8080`) write can't override on G-series keyboards (the firmware keeps
23/// replaying its stored effect). Preferred for a solid colour for that reason.
24const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
25
26// HID++ 2.0 report ids: 0x12 is the 64-byte "very long" report that streams a
27// batch of (keyID, R, G, B) entries; 0x11 is the 20-byte "long" report used both
28// to commit a per-key frame and to carry a single `ColorLedEffects` request.
29const REPORT_SET_KEYS: u8 = 0x12;
30const REPORT_LONG: u8 = 0x11;
31// Function byte = `function_id << 4 | software_id`. Software id 0xa just tags our
32// requests; for 0x8080: function 0x3 streams a key range, 0x5 commits the frame.
33const SW_ID: u8 = 0x0a;
34const FN_SET_KEY_RANGE: u8 = 0x3;
35const FN_FRAME_END: u8 = 0x5;
36// Fixed bytes of the "set key range" payload: a mode flag (byte 5) and the
37// per-frame entry count (byte 7), which is also the chunk size below.
38const SET_RANGE_MODE: u8 = 0x01;
39const KEYS_PER_FRAME: u8 = 0x0e;
40
41// 0x8070 `ColorLedEffects`: zone-effect index 0x01 is the fixed/static single
42// colour, applied volatilely (RAM only) so it shows live and overrides the
43// running onboard profile without touching flash. Reboot survival comes from the
44// agent re-applying the saved colour on device arrival (orchestrator reapply),
45// avoiding flash wear on every colour pick.
46const EFFECT_FIXED: u8 = 0x01;
47// The old raw `0x8070` path intentionally wrote only zones 0..4: enough for the
48// keyboards this path targets and bounded by a small, predictable delay budget.
49// Keep that cap even though the typed wrapper can query the reported zone count;
50// a malformed or unexpectedly large count should not stall a color apply.
51const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
52// Zones are paced apart because the controller can drop closely-spaced reports.
53const FRAME_GAP: Duration = Duration::from_millis(8);
54
55/// Which HID++ lighting path drives a solid keyboard colour. [`Auto`] is what
56/// the GUI/agent use; the explicit variants exist for the `diag` A/B test.
57///
58/// [`Auto`]: LightingMethod::Auto
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LightingMethod {
61    /// Prefer `ColorLedEffects` (`0x8070`), falling back to `PerKeyLighting`
62    /// (`0x8080`) when the device exposes no effect engine.
63    Auto,
64    /// Force `ColorLedEffects` (`0x8070`) — the fixed-effect override.
65    Effects,
66    /// Force `PerKeyLighting` (`0x8080`) — the per-key stream.
67    PerKey,
68}
69
70/// Set a keyboard to a solid `(r, g, b)` colour, choosing the HID++ path
71/// automatically: the `0x8070` effect engine (which overrides the onboard
72/// profile) when present, else the `0x8080` per-key stream. `FeatureUnsupported`
73/// when the device exposes neither.
74pub async fn set_keyboard_color(
75    route: &DeviceRoute,
76    r: u8,
77    g: u8,
78    b: u8,
79) -> Result<(), WriteError> {
80    set_keyboard_color_with(route, LightingMethod::Auto, r, g, b).await
81}
82
83/// [`set_keyboard_color`] with an explicit [`LightingMethod`]. `Auto` tries
84/// `0x8070` first and falls back to `0x8080` only when the effect engine is
85/// absent (a missing-`0x8070` `FeatureUnsupported`); any other error propagates.
86pub async fn set_keyboard_color_with(
87    route: &DeviceRoute,
88    method: LightingMethod,
89    r: u8,
90    g: u8,
91    b: u8,
92) -> Result<(), WriteError> {
93    match method {
94        LightingMethod::PerKey => set_color_per_key(route, r, g, b).await,
95        LightingMethod::Effects => set_color_effects(route, r, g, b).await,
96        LightingMethod::Auto => match set_color_effects(route, r, g, b).await {
97            Err(WriteError::FeatureUnsupported { feature_hex })
98                if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
99            {
100                debug!("no 0x8070 effect engine — falling back to 0x8080 per-key");
101                set_color_per_key(route, r, g, b).await
102            }
103            other => other,
104        },
105    }
106}
107
108/// Resolve `route`'s runtime feature *index* for HID++ `feature_id`. `Ok(None)`
109/// when the device doesn't expose it; the index differs per device, so callers
110/// can't hard-code it.
111async fn resolve_feature_index(
112    route: &DeviceRoute,
113    feature_id: u16,
114) -> Result<Option<u8>, WriteError> {
115    let device_index = route.device_index();
116    with_route(route, move |channel| async move {
117        let device = Device::new(std::sync::Arc::clone(&channel), device_index)
118            .await
119            .map_err(|_| WriteError::DeviceUnreachable {
120                index: device_index,
121            })?;
122        let info = device
123            .root()
124            .get_feature(feature_id)
125            .await
126            .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
127        Ok(info.map(|i| i.index))
128    })
129    .await
130}
131
132/// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone,
133/// stored in RAM only (overrides the running onboard profile without touching
134/// flash). `FeatureUnsupported` when the device exposes no `0x8070`.
135///
136/// Uses the typed [`ColorLedEffectsFeature`] wrapper: the real zone count is read
137/// first so only existing zones are driven (a typed `set_zone_effect` awaits the
138/// device's reply, so unlike the former raw fire-and-forget path a write to a
139/// non-existent zone would surface as an error rather than a silent no-op).
140async fn set_color_effects(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
141    let index = route.device_index();
142    with_route(route, move |channel| async move {
143        let mut device = Device::new(std::sync::Arc::clone(&channel), index)
144            .await
145            .map_err(|_| WriteError::DeviceUnreachable { index })?;
146        let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
147        let zone_count = feature
148            .get_info()
149            .await
150            .map_err(classify_lighting_error)?
151            .zone_count;
152
153        let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
154        params[0] = r;
155        params[1] = g;
156        params[2] = b;
157        let zones_to_write = if zone_count == 0 {
158            debug!(
159                index,
160                "0x8070 reported zero zones; applying legacy 4-zone fallback"
161            );
162            MAX_COLOR_LED_EFFECT_ZONES
163        } else {
164            zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)
165        };
166        if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
167            debug!(
168                index,
169                zone_count,
170                capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
171                "0x8070 zone count capped to legacy write limit"
172            );
173        }
174        for zone in 0..zones_to_write {
175            feature
176                .set_zone_effect(zone, EFFECT_FIXED, params, Persistence::Volatile)
177                .await
178                .map_err(classify_lighting_error)?;
179            tokio::time::sleep(FRAME_GAP).await;
180        }
181        debug!(
182            index,
183            zone_count, zones_to_write, r, g, b, "set keyboard colour via typed 0x8070"
184        );
185        Ok(())
186    })
187    .await
188}
189
190/// Classify a HID++ error from the `ColorLedEffects` functions.
191fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
192    classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
193}
194
195/// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour
196/// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device
197/// exposes no `0x8080`.
198async fn set_color_per_key(route: &DeviceRoute, r: u8, g: u8, b: u8) -> Result<(), WriteError> {
199    let device_index = route.device_index();
200    let feature_index = resolve_feature_index(route, PER_KEY_LIGHTING_FEATURE)
201        .await?
202        .ok_or(WriteError::FeatureUnsupported {
203            feature_hex: PER_KEY_LIGHTING_FEATURE,
204        })?;
205
206    let Some(mut writer) = crate::transport::open_route_writer(route).await? else {
207        return Err(WriteError::DeviceNotFound);
208    };
209    // Each 64-byte `0x12` "set group keys" packet carries up to 14
210    // `(keyID, R, G, B)` entries; keyIDs are HID usage codes. Cover the whole
211    // keyboard usage range (incl. modifiers at `0xe0..`) so every key lights,
212    // then commit the frame.
213    let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
214    for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
215        let mut rep = vec![0u8; 64];
216        rep[0] = REPORT_SET_KEYS;
217        rep[1] = device_index;
218        rep[2] = feature_index;
219        rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
220        rep[5] = SET_RANGE_MODE;
221        rep[7] = KEYS_PER_FRAME;
222        for (i, &key) in chunk.iter().enumerate() {
223            let off = 8 + i * 4;
224            rep[off] = key;
225            rep[off + 1] = r;
226            rep[off + 2] = g;
227            rep[off + 3] = b;
228        }
229        writer
230            .write_output_report(&rep)
231            .await
232            .map_err(WriteError::from)?;
233    }
234    let mut commit = vec![0u8; 20];
235    commit[0] = REPORT_LONG;
236    commit[1] = device_index;
237    commit[2] = feature_index;
238    commit[3] = (FN_FRAME_END << 4) | SW_ID;
239    writer
240        .write_output_report(&commit)
241        .await
242        .map_err(WriteError::from)?;
243    debug!(
244        device_index,
245        feature_index, r, g, b, "set keyboard colour via 0x8080"
246    );
247    Ok(())
248}