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