Skip to main content

openlogi_device/write/
lighting.rs

1use std::sync::{Arc, Mutex, PoisonError};
2use std::time::Duration;
3
4use hidpp::{
5    channel::{ChannelError, HidppChannel},
6    device::Device,
7    feature::{
8        CreatableFeature,
9        color_led_effects::{
10            ColorLedEffectsFeature, EffectId, Persistence, ZONE_EFFECT_PARAM_COUNT,
11        },
12        per_key_lighting::{
13            FramePersistence, MAX_SINGLE_VALUE_ZONES, PerKeyLightingFeature, Rgb,
14            ZONE_PRESENCE_PAGE_LEN, ZonePresencePage,
15        },
16        root::RootFeature,
17    },
18    nibble::U4,
19    protocol::v20::{self, Hidpp20Error},
20};
21use tracing::debug;
22
23use crate::SharedChannel;
24use crate::backend::HidBackend;
25use crate::channel::route::DeviceRoute;
26
27use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
28
29mod rgb;
30
31pub use rgb::RgbControlLease;
32
33/// HID++ `PerKeyLighting` (`0x8080`) — streams each key's colour individually.
34/// Its feature *index* varies per device, so it's resolved at runtime.
35const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
36/// HID++ `ColorLedEffects` (`0x8070`) — the keyboard's effect engine. Writing a
37/// *fixed* effect here replaces a running onboard profile, which a per-key
38/// (`0x8080`) write can't override on G-series keyboards (the firmware keeps
39/// replaying its stored effect). Preferred for a solid colour for that reason.
40const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
41
42// HID++ 2.0 report id 0x12 is the 64-byte "very long" report that streams a
43// batch of (keyID, R, G, B) entries. The frame commit is an ordinary 20-byte
44// request and goes through `send_v20`.
45const REPORT_SET_KEYS: u8 = 0x12;
46// Function byte = `function_id << 4 | software_id`. Software id 0xa just tags our
47// requests; for 0x8080: function 0x3 streams a key range, 0x5 commits the frame.
48const SW_ID: u8 = 0x0a;
49const FN_SET_KEY_RANGE: u8 = 0x3;
50const FN_FRAME_END: u8 = 0x5;
51// Fixed bytes of the "set key range" payload: a mode flag (byte 5) and the
52// most entries one frame carries, which is also the chunk size below. Bytes
53// 6..8 hold each frame's own big-endian entry count.
54const SET_RANGE_MODE: u8 = 0x01;
55const KEYS_PER_FRAME: u8 = 0x0e;
56
57// 0x8070 `ColorLedEffects`: the fixed/static single colour is applied
58// volatilely (RAM only) so it shows live and overrides the running onboard
59// profile without touching flash. Reboot survival comes from the agent
60// re-applying the saved colour on device arrival (orchestrator reapply),
61// avoiding flash wear on every colour pick.
62//
63// Each zone lists its own effects, so the fixed colour's index is read from
64// that list. Index 0x01 is only the legacy guess, kept where no list can be
65// read: a device reporting zero zones, and a zone whose info is rejected.
66const LEGACY_FIXED_EFFECT_INDEX: u8 = 0x01;
67// The old raw `0x8070` path intentionally wrote only zones 0..4: enough for the
68// keyboards this path targets and bounded by a small, predictable delay budget.
69// Keep that cap even though the typed wrapper can query the reported zone count;
70// a malformed or unexpectedly large count should not stall a color apply.
71const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
72// Zones are paced apart because the controller can drop closely-spaced reports.
73const FRAME_GAP: Duration = Duration::from_millis(8);
74
75/// Which HID++ lighting path drives a solid keyboard colour. [`Auto`] is what
76/// the GUI/agent use; the explicit variants exist for the `diag` A/B test.
77///
78/// [`Auto`]: LightingMethod::Auto
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum LightingMethod {
81    /// Prefer `ColorLedEffects` (`0x8070`), then `RgbEffects` (`0x8071`),
82    /// `PerKeyLighting2` (`0x8081`) and `PerKeyLighting` (`0x8080`).
83    Auto,
84    /// Force `ColorLedEffects` (`0x8070`) — the fixed-effect override.
85    Effects,
86    /// Force `PerKeyLighting` (`0x8080`) — the raw per-key stream.
87    PerKey,
88    /// Force `PerKeyLighting2` (`0x8081`) — the zone-addressed successor to
89    /// `0x8080`.
90    PerKeyV2,
91}
92
93/// Set a device to a solid `(r, g, b)` colour, choosing the HID++ path
94/// automatically: `0x8070` → `0x8071` → `0x8081` → `0x8080`.
95///
96/// Drive this future to completion; native callers should use the owned
97/// lighting job in `openlogi-hid` so cancellation cannot discard RGB rollback.
98pub async fn set_keyboard_color(
99    backend: &dyn HidBackend,
100    route: &DeviceRoute,
101    r: u8,
102    g: u8,
103    b: u8,
104) -> Result<(), WriteError> {
105    set_keyboard_color_with(backend, route, LightingMethod::Auto, r, g, b).await
106}
107
108/// [`set_keyboard_color`] with an explicit [`LightingMethod`]. Unsupported
109/// discovery permits fallback; failures after RGB ownership is claimed do not.
110/// Like [`LightingWrite::apply_on`], this future must be driven to completion.
111pub async fn set_keyboard_color_with(
112    backend: &dyn HidBackend,
113    route: &DeviceRoute,
114    method: LightingMethod,
115    r: u8,
116    g: u8,
117    b: u8,
118) -> Result<(), WriteError> {
119    LightingWrite {
120        method,
121        color: openlogi_core::color::Rgb::new(r, g, b),
122    }
123    .apply(backend, route, || false, || true)
124    .await
125    .map(drop)
126}
127
128/// One complete solid-colour transaction, including RGB ownership rollback.
129#[derive(Clone, Copy)]
130pub struct LightingWrite {
131    /// Preferred protocol path.
132    pub method: LightingMethod,
133    /// The colour after applying the configured brightness.
134    pub color: openlogi_core::color::Rgb,
135}
136
137impl LightingWrite {
138    /// Open a standalone route and apply with the same ownership contract as
139    /// [`Self::apply_on`]. The native worker owns this channel through cleanup.
140    pub async fn apply(
141        self,
142        backend: &dyn HidBackend,
143        route: &DeviceRoute,
144        cancelled: impl Fn() -> bool,
145        available: impl Fn() -> bool,
146    ) -> Result<Option<RgbControlLease>, WriteError> {
147        with_route(backend, route, |channel| async move {
148            let shared = SharedChannel::new(channel, route.clone());
149            self.apply_on(&shared, cancelled, available).await
150        })
151        .await
152    }
153
154    /// Apply on the supplied channel, observing caller cancellation and channel
155    /// availability between requests. `available` must reject retired agent
156    /// publications and suspended host I/O, including during compensation.
157    ///
158    /// The owner MUST poll to completion and serialize lighting transactions on
159    /// this route. Do not wrap this future in `timeout` or abort its task. Reads
160    /// have a five-second overall deadline; a started RGB native write drains
161    /// before cancellation/deadline errors trigger rollback. Its response and
162    /// the rollback response each have the channel's own bounded wait. A native
163    /// write that never completes cannot safely release ownership on a timer.
164    ///
165    /// Returns the `0x8071` software control the write left held, if any. A
166    /// long-lived host keeps the first lease per device and hands it back with
167    /// [`release_rgb_control`] when it leaves; a standalone command leaves the
168    /// colour in place.
169    pub async fn apply_on(
170        self,
171        shared: &SharedChannel,
172        cancelled: impl Fn() -> bool,
173        available: impl Fn() -> bool,
174    ) -> Result<Option<RgbControlLease>, WriteError> {
175        let scope = WriteScope {
176            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
177            cancelled,
178            available,
179        };
180        let channel = shared.channel();
181        let index = shared.device_index();
182        let (r, g, b) = self.color.components();
183        match self.method {
184            LightingMethod::PerKey => {
185                return scope
186                    .read(set_color_per_key(channel, index, r, g, b))
187                    .await
188                    .map(|()| None);
189            }
190            LightingMethod::PerKeyV2 => {
191                let engine = rgb::probe(channel, index, &scope).await?;
192                return self
193                    .per_key_v2(channel, index, engine.as_ref(), &scope)
194                    .await;
195            }
196            LightingMethod::Effects => {
197                return scope
198                    .read(set_color_effects(channel, index, r, g, b))
199                    .await
200                    .map(|()| None);
201            }
202            LightingMethod::Auto => {}
203        }
204        match scope.read(set_color_effects(channel, index, r, g, b)).await {
205            Err(WriteError::FeatureUnsupported { feature_hex })
206                if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
207            {
208                debug!("no 0x8070 fixed colour — trying 0x8071 RGB effects");
209            }
210            other => return other.map(|()| None),
211        }
212        // Every probe precedes the first claim, so an unusable effect engine
213        // falls back without mutating the device.
214        let engine = rgb::probe(channel, index, &scope).await?;
215        if let Some(engine) = engine.as_ref().filter(|engine| engine.is_complete()) {
216            return engine.apply(index, self.color, &scope).await.map(Some);
217        }
218        debug!("no supported 0x8071 static effect — trying per-key paths");
219        match self
220            .per_key_v2(channel, index, engine.as_ref(), &scope)
221            .await
222        {
223            Err(WriteError::FeatureUnsupported { feature_hex })
224                if feature_hex == PerKeyLightingFeature::ID =>
225            {
226                scope
227                    .read(set_color_per_key(channel, index, r, g, b))
228                    .await
229                    .map(|()| None)
230            }
231            other => other,
232        }
233    }
234
235    /// Paint through `0x8081`. With an `0x8071` effect engine the frame goes
236    /// under its software control, since the firmware otherwise draws its own
237    /// effect over host frames; without one there is nothing to claim. Zone
238    /// discovery precedes the claim, so a device without usable zones still
239    /// falls back without mutation.
240    async fn per_key_v2(
241        self,
242        channel: &Arc<HidppChannel>,
243        index: u8,
244        engine: Option<&rgb::RgbEngine>,
245        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
246    ) -> Result<Option<RgbControlLease>, WriteError> {
247        let target = scope.read(PerKeyV2Target::open(channel, index)).await?;
248        let (r, g, b) = self.color.components();
249        let color = Rgb {
250            red: r,
251            green: g,
252            blue: b,
253        };
254        match engine {
255            None => scope
256                .read(target.paint(index, color, scope))
257                .await
258                .map(|()| None),
259            Some(engine) => engine
260                .apply_per_key(index, self.color, scope, async || {
261                    target.paint(index, color, scope).await
262                })
263                .await
264                .map(Some),
265        }
266    }
267}
268
269/// Hand `lease` back to the firmware on `shared`, as a long-lived host does
270/// when it leaves. `available` must reject retired publications and suspended
271/// host I/O. Bounded by the same five-second read deadline as a write.
272///
273/// The owner MUST poll to completion and hold the route's lighting lock, as
274/// for [`LightingWrite::apply_on`].
275pub async fn release_rgb_control(
276    shared: &SharedChannel,
277    lease: RgbControlLease,
278    available: impl Fn() -> bool,
279) -> Result<(), WriteError> {
280    let scope = WriteScope {
281        deadline: tokio::time::Instant::now() + Duration::from_secs(5),
282        cancelled: || false,
283        available,
284    };
285    rgb::release(shared.channel(), shared.device_index(), lease, &scope).await
286}
287
288struct WriteScope<C, A> {
289    deadline: tokio::time::Instant,
290    cancelled: C,
291    available: A,
292}
293
294impl<C: Fn() -> bool, A: Fn() -> bool> WriteScope<C, A> {
295    fn check_available(&self) -> Result<(), WriteError> {
296        if (self.available)() {
297            Ok(())
298        } else {
299            Err(WriteError::DeviceNotFound)
300        }
301    }
302
303    fn check(&self) -> Result<(), WriteError> {
304        self.check_available()?;
305        if (self.cancelled)() || tokio::time::Instant::now() >= self.deadline {
306            Err(WriteError::RequestTimedOut {
307                operation: HidppOperation::Lighting,
308            })
309        } else {
310            Ok(())
311        }
312    }
313
314    async fn read<T>(
315        &self,
316        future: impl std::future::Future<Output = Result<T, WriteError>>,
317    ) -> Result<T, WriteError> {
318        self.check()?;
319        let result = tokio::time::timeout_at(self.deadline, future)
320            .await
321            .map_err(|_| WriteError::RequestTimedOut {
322                operation: HidppOperation::Lighting,
323            })?;
324        self.check()?;
325        result
326    }
327}
328
329/// Resolve `route`'s runtime feature *index* for HID++ `feature_id`. `Ok(None)`
330/// when the device doesn't expose it; the index differs per device, so callers
331/// can't hard-code it.
332async fn resolve_feature_index(
333    channel: &Arc<HidppChannel>,
334    device_index: u8,
335    feature_id: u16,
336) -> Result<Option<u8>, WriteError> {
337    let device = Device::new(Arc::clone(channel), device_index)
338        .await
339        .map_err(|_| WriteError::DeviceUnreachable {
340            index: device_index,
341        })?;
342    let info = device
343        .root()
344        .get_feature(feature_id)
345        .await
346        .map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
347    Ok(info.map(|i| i.index))
348}
349
350/// Set a solid colour via `ColorLedEffects` (`0x8070`): a fixed effect per zone,
351/// stored in RAM only (overrides the running onboard profile without touching
352/// flash). `FeatureUnsupported` when the device exposes no `0x8070` or none of
353/// its zones offers a fixed colour.
354///
355/// Uses the typed [`ColorLedEffectsFeature`] wrapper: the real zone count is read
356/// first so only existing zones are driven (a typed `set_zone_effect` awaits the
357/// device's reply, so unlike the former raw fire-and-forget path a write to a
358/// non-existent zone would surface as an error rather than a silent no-op).
359/// Every target is located before the first write, so a keyboard without a
360/// fixed colour is left untouched for the next path.
361async fn set_color_effects(
362    channel: &Arc<HidppChannel>,
363    index: u8,
364    r: u8,
365    g: u8,
366    b: u8,
367) -> Result<(), WriteError> {
368    let mut device = Device::new(Arc::clone(channel), index)
369        .await
370        .map_err(|_| WriteError::DeviceUnreachable { index })?;
371    let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
372    let zone_count = feature
373        .get_info()
374        .await
375        .map_err(classify_lighting_error)?
376        .zone_count;
377
378    let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
379    params[0] = r;
380    params[1] = g;
381    params[2] = b;
382    let targets: Vec<(u8, u8)> = if zone_count == 0 {
383        debug!(
384            index,
385            "0x8070 reported zero zones; applying legacy 4-zone fallback"
386        );
387        (0..MAX_COLOR_LED_EFFECT_ZONES)
388            .map(|zone| (zone, LEGACY_FIXED_EFFECT_INDEX))
389            .collect()
390    } else {
391        if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
392            debug!(
393                index,
394                zone_count,
395                capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
396                "0x8070 zone count capped to legacy write limit"
397            );
398        }
399        fixed_color_targets(&feature, index, zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)).await?
400    };
401    if targets.is_empty() {
402        debug!(index, zone_count, "no 0x8070 zone offers a fixed colour");
403        return Err(WriteError::FeatureUnsupported {
404            feature_hex: COLOR_LED_EFFECTS_FEATURE,
405        });
406    }
407    for &(zone, effect) in &targets {
408        feature
409            .set_zone_effect(zone, effect, params, Persistence::Volatile)
410            .await
411            .map_err(classify_lighting_error)?;
412        tokio::time::sleep(FRAME_GAP).await;
413    }
414    debug!(
415        index,
416        zone_count,
417        zones_written = targets.len(),
418        r,
419        g,
420        b,
421        "set keyboard colour via typed 0x8070"
422    );
423    Ok(())
424}
425
426/// The `(zone, effect index)` of the fixed-colour effect in each of zones
427/// `0..zone_count`, read from the device's own effect lists as Logi Options+
428/// does. A zone without one is skipped: a guessed index would drive whatever
429/// effect sits there with RGB bytes as its parameters.
430///
431/// An effect id this crate does not model is not the fixed colour, so it is
432/// passed over. A zone whose info is rejected (an unmodelled location) keeps
433/// the legacy index rather than going dark.
434async fn fixed_color_targets(
435    feature: &ColorLedEffectsFeature,
436    index: u8,
437    zone_count: u8,
438) -> Result<Vec<(u8, u8)>, WriteError> {
439    let mut targets = Vec::new();
440    for zone in 0..zone_count {
441        let effects_number = match feature.get_zone_info(zone).await {
442            Ok(info) => info.effects_number,
443            Err(Hidpp20Error::UnsupportedResponse) => {
444                debug!(
445                    index,
446                    zone, "0x8070 zone info rejected; using the legacy fixed-colour index"
447                );
448                targets.push((zone, LEGACY_FIXED_EFFECT_INDEX));
449                continue;
450            }
451            Err(error) => return Err(classify_lighting_error(error)),
452        };
453        for effect in 0..effects_number {
454            match feature.get_zone_effect_info(zone, effect).await {
455                Ok(info) if info.effect_id == EffectId::FixedColor => {
456                    targets.push((zone, effect));
457                    break;
458                }
459                Ok(_) | Err(Hidpp20Error::UnsupportedResponse) => {}
460                Err(error) => return Err(classify_lighting_error(error)),
461            }
462        }
463    }
464    Ok(targets)
465}
466
467/// Classify a HID++ error from the `ColorLedEffects` functions.
468fn classify_lighting_error(error: Hidpp20Error) -> WriteError {
469    classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
470}
471
472/// A solid colour via `PerKeyLighting2` (`0x8081`): every zone the device
473/// reports as present, painted and then committed as one frame.
474///
475/// `0x8081` supersedes `0x8080`. It addresses *zones* rather than HID key
476/// usages and answers each request, so unlike the raw `0x8080` stream a write
477/// to a zone the device does not have surfaces as an error instead of being
478/// swallowed. Nothing had ever driven it, which left a keyboard exposing only
479/// `0x8081` with no way to set its colour at all.
480///
481/// Committed volatilely for the same reason as the `0x8070` path: the colour
482/// shows live without a flash write on every colour pick, and the agent
483/// re-applies the saved colour on device arrival.
484struct PerKeyV2Target {
485    feature: Arc<PerKeyLightingFeature>,
486    zones: Vec<u8>,
487}
488
489impl PerKeyV2Target {
490    /// Open `0x8081` and read its present zones without mutating anything.
491    /// `FeatureUnsupported` when the device exposes no `0x8081` or reports no
492    /// zones.
493    async fn open(channel: &Arc<HidppChannel>, index: u8) -> Result<Self, WriteError> {
494        let mut device = Device::new(Arc::clone(channel), index)
495            .await
496            .map_err(|_| WriteError::DeviceUnreachable { index })?;
497        let feature = open_feature::<PerKeyLightingFeature>(&mut device).await?;
498        let zones = present_zones(&feature).await?;
499        if zones.is_empty() {
500            // The device announces 0x8081 but claims no zones, so there is
501            // nothing to paint — and that won't change on retry. Reported as
502            // unsupported so `Auto` falls through to the 0x8080 stream.
503            debug!(index, "0x8081 reported no present zones");
504            return Err(WriteError::FeatureUnsupported {
505                feature_hex: PerKeyLightingFeature::ID,
506            });
507        }
508        Ok(Self { feature, zones })
509    }
510
511    /// Paint every present zone `color`, then commit the frame. `scope` is
512    /// checked before each request, so a retired channel, suspended host I/O,
513    /// cancellation or the deadline stops the frame between requests without
514    /// cutting one off in flight.
515    async fn paint(
516        &self,
517        index: u8,
518        color: Rgb,
519        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
520    ) -> Result<(), WriteError> {
521        // One request carries at most MAX_SINGLE_VALUE_ZONES ids and silently
522        // ignores the rest, so the chunking is the caller's job.
523        for chunk in self.zones.chunks(MAX_SINGLE_VALUE_ZONES) {
524            scope.check()?;
525            self.feature
526                .set_rgb_zones_single_value(color, chunk)
527                .await
528                .map_err(classify_per_key_v2_error)?;
529        }
530        scope.check()?;
531        self.feature
532            .frame_end(FramePersistence::Volatile, 0, 0)
533            .await
534            .map_err(classify_per_key_v2_error)?;
535        debug!(
536            index,
537            zone_count = self.zones.len(),
538            ?color,
539            "set keyboard colour via typed 0x8081"
540        );
541        Ok(())
542    }
543}
544
545/// Every zone id `0x8081` reports as present, read across all three presence
546/// pages.
547///
548/// Ids `0` and `0xff` are end-of-list sentinels the feature rejects, so they
549/// are skipped even if a device sets their bits.
550async fn present_zones(feature: &PerKeyLightingFeature) -> Result<Vec<u8>, WriteError> {
551    let mut zones = Vec::new();
552    for (page, base) in [
553        (ZonePresencePage::Zones0To111, 0u16),
554        (ZonePresencePage::Zones112To223, 112),
555        (ZonePresencePage::Zones224To255, 224),
556    ] {
557        let bitfield = feature
558            .get_rgb_zone_presence(page)
559            .await
560            .map_err(classify_per_key_v2_error)?;
561        collect_present_zones(base, &bitfield, &mut zones);
562    }
563    Ok(zones)
564}
565
566/// Appends the zone ids whose presence bit is set in `bitfield`, a 112-bit
567/// field covering ids `base..base + 112` (bit `i` LSB-first within each byte).
568///
569/// The last page covers only 224..=255, so its high bits are padding; ids past
570/// 255 are skipped rather than wrapped. Ids `0` and `0xff` are the feature's
571/// end-of-list sentinels and are skipped even if a device sets their bits.
572pub(super) fn collect_present_zones(
573    base: u16,
574    bitfield: &[u8; ZONE_PRESENCE_PAGE_LEN],
575    zones: &mut Vec<u8>,
576) {
577    for (byte_index, byte) in bitfield.iter().enumerate() {
578        for bit in 0..8u16 {
579            if byte & (1 << bit) == 0 {
580                continue;
581            }
582            let Ok(offset) = u16::try_from(byte_index * 8) else {
583                continue;
584            };
585            let Ok(zone_id) = u8::try_from(base + offset + bit) else {
586                continue;
587            };
588            if !matches!(zone_id, 0 | 0xff) {
589                zones.push(zone_id);
590            }
591        }
592    }
593}
594
595/// Classify a HID++ error from the `PerKeyLighting2` functions.
596fn classify_per_key_v2_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
597    classify_hidpp_error(error, HidppOperation::Lighting, PerKeyLightingFeature::ID)
598}
599
600/// Set a solid colour via `PerKeyLighting` (`0x8080`): stream every key's colour
601/// in 64-byte `0x12` frames, then commit. `FeatureUnsupported` when the device
602/// exposes no `0x8080`.
603///
604/// The frames are written raw: which report id the device answers a 64-byte
605/// request with is not established (g810-led never reads a reply), so there is
606/// no success reply to wait for. A HID++ feature error names its request in
607/// the header, so errors addressed to the frames are collected while they
608/// stream. A root ping after the last frame fences them: the device answers
609/// in order, so once the ping returns, any error for an earlier frame has been
610/// read. A rejected frame fails the write before the commit. The commit is a
611/// 20-byte request that awaits its own reply, as Logi Options+'s `FlushLEDS`
612/// does.
613async fn set_color_per_key(
614    channel: &Arc<HidppChannel>,
615    device_index: u8,
616    r: u8,
617    g: u8,
618    b: u8,
619) -> Result<(), WriteError> {
620    let feature_index = resolve_feature_index(channel, device_index, PER_KEY_LIGHTING_FEATURE)
621        .await?
622        .ok_or(WriteError::FeatureUnsupported {
623            feature_hex: PER_KEY_LIGHTING_FEATURE,
624        })?;
625
626    let frame = per_key_header(device_index, feature_index, FN_SET_KEY_RANGE);
627    let frame_error: Arc<Mutex<Option<Hidpp20Error>>> = Arc::default();
628    let _frame_errors = {
629        let frame_error = Arc::clone(&frame_error);
630        channel.add_msg_listener_guarded(move |message, _| {
631            if let Some(error) = v20::Message::from(message).feature_error_for(frame) {
632                frame_error
633                    .lock()
634                    .unwrap_or_else(PoisonError::into_inner)
635                    .get_or_insert(error);
636            }
637        })
638    };
639
640    for report in per_key_frames(device_index, feature_index, r, g, b) {
641        let written = channel
642            .write_raw_report(&report)
643            .await
644            .map_err(classify_raw_lighting_error)?;
645        if written != report.len() {
646            return Err(WriteError::Hidpp(format!(
647                "raw lighting report wrote {written} of {} bytes",
648                report.len()
649            )));
650        }
651    }
652
653    RootFeature::new(Arc::clone(channel), device_index, 0)
654        .ping(0)
655        .await
656        .map_err(classify_per_key_error)?;
657    let rejected = frame_error
658        .lock()
659        .unwrap_or_else(PoisonError::into_inner)
660        .take();
661    if let Some(error) = rejected {
662        debug!(
663            device_index,
664            feature_index,
665            ?error,
666            "0x8080 rejected a frame; not committing"
667        );
668        return Err(classify_per_key_error(error));
669    }
670
671    channel
672        .send_v20(per_key_commit(device_index, feature_index))
673        .await
674        .map_err(classify_per_key_error)?;
675    debug!(
676        device_index,
677        feature_index, r, g, b, "set keyboard colour via 0x8080"
678    );
679    Ok(())
680}
681
682/// The 64-byte `0x12` frames that set every key to `(r, g, b)`.
683pub(super) fn per_key_frames(
684    device_index: u8,
685    feature_index: u8,
686    r: u8,
687    g: u8,
688    b: u8,
689) -> Vec<Vec<u8>> {
690    let mut reports = Vec::new();
691    // Each 64-byte `0x12` "set group keys" packet carries up to 14
692    // `(keyID, R, G, B)` entries; keyIDs are HID usage codes. Cover the whole
693    // keyboard usage range (incl. modifiers at `0xe0..`) so every key lights.
694    let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
695    for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
696        let mut rep = vec![0u8; 64];
697        rep[0] = REPORT_SET_KEYS;
698        rep[1] = device_index;
699        rep[2] = feature_index;
700        rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
701        rep[5] = SET_RANGE_MODE;
702        // Only the last frame is short (233 keys = 16 × 14 + 9). Declaring 14
703        // there would hand the firmware five zeroed entries as real ones.
704        rep[7] = u8::try_from(chunk.len()).unwrap_or(KEYS_PER_FRAME);
705        for (i, &key) in chunk.iter().enumerate() {
706            let off = 8 + i * 4;
707            rep[off] = key;
708            rep[off + 1] = r;
709            rep[off + 2] = g;
710            rep[off + 3] = b;
711        }
712        reports.push(rep);
713    }
714    reports
715}
716
717/// The `0x8080` frameEnd request that commits the streamed frames.
718pub(super) fn per_key_commit(device_index: u8, feature_index: u8) -> v20::Message {
719    v20::Message::Long(
720        per_key_header(device_index, feature_index, FN_FRAME_END),
721        [0; 16],
722    )
723}
724
725fn per_key_header(device_index: u8, feature_index: u8, function: u8) -> v20::MessageHeader {
726    v20::MessageHeader {
727        device_index,
728        feature_index,
729        function_id: U4::from_lo(function),
730        software_id: U4::from_lo(SW_ID),
731    }
732}
733
734/// Classify a HID++ error from the `PerKeyLighting` request path.
735fn classify_per_key_error(error: Hidpp20Error) -> WriteError {
736    classify_hidpp_error(error, HidppOperation::Lighting, PER_KEY_LIGHTING_FEATURE)
737}
738
739fn classify_raw_lighting_error(error: ChannelError) -> WriteError {
740    match error {
741        ChannelError::Timeout => WriteError::RequestTimedOut {
742            operation: HidppOperation::Lighting,
743        },
744        other => WriteError::Hidpp(format!("{other:?}")),
745    }
746}
747
748/// Set a solid keyboard colour on an already-open [`SharedChannel`], using
749/// [`LightingMethod::Auto`].
750pub async fn set_keyboard_color_on(
751    shared: &SharedChannel,
752    r: u8,
753    g: u8,
754    b: u8,
755) -> Result<(), WriteError> {
756    set_keyboard_color_with_on(shared, LightingMethod::Auto, r, g, b).await
757}
758
759/// Set a solid keyboard colour on an already-open [`SharedChannel`] with an
760/// explicit lighting method. Drive to completion; see [`LightingWrite::apply_on`].
761pub async fn set_keyboard_color_with_on(
762    shared: &SharedChannel,
763    method: LightingMethod,
764    r: u8,
765    g: u8,
766    b: u8,
767) -> Result<(), WriteError> {
768    LightingWrite {
769        method,
770        color: openlogi_core::color::Rgb::new(r, g, b),
771    }
772    .apply_on(shared, || false, || shared.channel().is_connected())
773    .await
774    .map(drop)
775}