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
33const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
36const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
41
42const REPORT_SET_KEYS: u8 = 0x12;
46const SW_ID: u8 = 0x0a;
49const FN_SET_KEY_RANGE: u8 = 0x3;
50const FN_FRAME_END: u8 = 0x5;
51const SET_RANGE_MODE: u8 = 0x01;
55const KEYS_PER_FRAME: u8 = 0x0e;
56
57const LEGACY_FIXED_EFFECT_INDEX: u8 = 0x01;
67const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
72const FRAME_GAP: Duration = Duration::from_millis(8);
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum LightingMethod {
81 Auto,
84 Effects,
86 PerKey,
88 PerKeyV2,
91}
92
93pub 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
108pub 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#[derive(Clone, Copy)]
130pub struct LightingWrite {
131 pub method: LightingMethod,
133 pub color: openlogi_core::color::Rgb,
135}
136
137impl LightingWrite {
138 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 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 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 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
269pub 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
329async 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
350async 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
426async 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
467fn classify_lighting_error(error: Hidpp20Error) -> WriteError {
469 classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
470}
471
472struct PerKeyV2Target {
485 feature: Arc<PerKeyLightingFeature>,
486 zones: Vec<u8>,
487}
488
489impl PerKeyV2Target {
490 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 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 async fn paint(
516 &self,
517 index: u8,
518 color: Rgb,
519 scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
520 ) -> Result<(), WriteError> {
521 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
545async 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
566pub(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
595fn classify_per_key_v2_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
597 classify_hidpp_error(error, HidppOperation::Lighting, PerKeyLightingFeature::ID)
598}
599
600async 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
682pub(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 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 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
717pub(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
734fn 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
748pub 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
759pub 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}