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
26const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
29const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
34
35const REPORT_SET_KEYS: u8 = 0x12;
39const REPORT_LONG: u8 = 0x11;
40const SW_ID: u8 = 0x0a;
43const FN_SET_KEY_RANGE: u8 = 0x3;
44const FN_FRAME_END: u8 = 0x5;
45const SET_RANGE_MODE: u8 = 0x01;
48const KEYS_PER_FRAME: u8 = 0x0e;
49
50const EFFECT_FIXED: u8 = 0x01;
56const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
61const FRAME_GAP: Duration = Duration::from_millis(8);
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum LightingMethod {
70 Auto,
73 Effects,
75 PerKey,
77 PerKeyV2,
80}
81
82pub 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
97pub 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#[derive(Clone, Copy)]
118pub struct LightingWrite {
119 pub method: LightingMethod,
121 pub color: openlogi_core::color::Rgb,
123}
124
125impl LightingWrite {
126 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 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
251async 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
272async 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
332fn classify_lighting_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
334 classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
335}
336
337async 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 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 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
402async 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
423pub(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
452fn classify_per_key_v2_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
454 classify_hidpp_error(error, HidppOperation::Lighting, PerKeyLightingFeature::ID)
455}
456
457async 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 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
540pub 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
551pub 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}