use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use hidpp::{
channel::{ChannelError, HidppChannel},
device::Device,
feature::{
CreatableFeature,
color_led_effects::{
ColorLedEffectsFeature, EffectId, Persistence, ZONE_EFFECT_PARAM_COUNT,
},
per_key_lighting::{
FramePersistence, MAX_SINGLE_VALUE_ZONES, PerKeyLightingFeature, Rgb,
ZONE_PRESENCE_PAGE_LEN, ZonePresencePage,
},
root::RootFeature,
},
nibble::U4,
protocol::v20::{self, Hidpp20Error},
};
use tracing::debug;
use crate::SharedChannel;
use crate::backend::HidBackend;
use crate::channel::route::DeviceRoute;
use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature, with_route};
mod rgb;
pub use rgb::RgbControlLease;
const PER_KEY_LIGHTING_FEATURE: u16 = 0x8080;
const COLOR_LED_EFFECTS_FEATURE: u16 = 0x8070;
const REPORT_SET_KEYS: u8 = 0x12;
const SW_ID: u8 = 0x0a;
const FN_SET_KEY_RANGE: u8 = 0x3;
const FN_FRAME_END: u8 = 0x5;
const SET_RANGE_MODE: u8 = 0x01;
const KEYS_PER_FRAME: u8 = 0x0e;
const LEGACY_FIXED_EFFECT_INDEX: u8 = 0x01;
const MAX_COLOR_LED_EFFECT_ZONES: u8 = 4;
const FRAME_GAP: Duration = Duration::from_millis(8);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LightingMethod {
Auto,
Effects,
PerKey,
PerKeyV2,
}
pub async fn set_keyboard_color(
backend: &dyn HidBackend,
route: &DeviceRoute,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
set_keyboard_color_with(backend, route, LightingMethod::Auto, r, g, b).await
}
pub async fn set_keyboard_color_with(
backend: &dyn HidBackend,
route: &DeviceRoute,
method: LightingMethod,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
LightingWrite {
method,
color: openlogi_core::color::Rgb::new(r, g, b),
}
.apply(backend, route, || false, || true)
.await
.map(drop)
}
#[derive(Clone, Copy)]
pub struct LightingWrite {
pub method: LightingMethod,
pub color: openlogi_core::color::Rgb,
}
impl LightingWrite {
pub async fn apply(
self,
backend: &dyn HidBackend,
route: &DeviceRoute,
cancelled: impl Fn() -> bool,
available: impl Fn() -> bool,
) -> Result<Option<RgbControlLease>, WriteError> {
with_route(backend, route, |channel| async move {
let shared = SharedChannel::new(channel, route.clone());
self.apply_on(&shared, cancelled, available).await
})
.await
}
pub async fn apply_on(
self,
shared: &SharedChannel,
cancelled: impl Fn() -> bool,
available: impl Fn() -> bool,
) -> Result<Option<RgbControlLease>, WriteError> {
let scope = WriteScope {
deadline: tokio::time::Instant::now() + Duration::from_secs(5),
cancelled,
available,
};
let channel = shared.channel();
let index = shared.device_index();
let (r, g, b) = self.color.components();
match self.method {
LightingMethod::PerKey => {
return scope
.read(set_color_per_key(channel, index, r, g, b))
.await
.map(|()| None);
}
LightingMethod::PerKeyV2 => {
let engine = rgb::probe(channel, index, &scope).await?;
return self
.per_key_v2(channel, index, engine.as_ref(), &scope)
.await;
}
LightingMethod::Effects => {
return scope
.read(set_color_effects(channel, index, r, g, b))
.await
.map(|()| None);
}
LightingMethod::Auto => {}
}
match scope.read(set_color_effects(channel, index, r, g, b)).await {
Err(WriteError::FeatureUnsupported { feature_hex })
if feature_hex == COLOR_LED_EFFECTS_FEATURE =>
{
debug!("no 0x8070 fixed colour — trying 0x8071 RGB effects");
}
other => return other.map(|()| None),
}
let engine = rgb::probe(channel, index, &scope).await?;
if let Some(engine) = engine.as_ref().filter(|engine| engine.is_complete()) {
return engine.apply(index, self.color, &scope).await.map(Some);
}
debug!("no supported 0x8071 static effect — trying per-key paths");
match self
.per_key_v2(channel, index, engine.as_ref(), &scope)
.await
{
Err(WriteError::FeatureUnsupported { feature_hex })
if feature_hex == PerKeyLightingFeature::ID =>
{
scope
.read(set_color_per_key(channel, index, r, g, b))
.await
.map(|()| None)
}
other => other,
}
}
async fn per_key_v2(
self,
channel: &Arc<HidppChannel>,
index: u8,
engine: Option<&rgb::RgbEngine>,
scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
) -> Result<Option<RgbControlLease>, WriteError> {
let target = scope.read(PerKeyV2Target::open(channel, index)).await?;
let (r, g, b) = self.color.components();
let color = Rgb {
red: r,
green: g,
blue: b,
};
match engine {
None => scope
.read(target.paint(index, color, scope))
.await
.map(|()| None),
Some(engine) => engine
.apply_per_key(index, self.color, scope, async || {
target.paint(index, color, scope).await
})
.await
.map(Some),
}
}
}
pub async fn release_rgb_control(
shared: &SharedChannel,
lease: RgbControlLease,
available: impl Fn() -> bool,
) -> Result<(), WriteError> {
let scope = WriteScope {
deadline: tokio::time::Instant::now() + Duration::from_secs(5),
cancelled: || false,
available,
};
rgb::release(shared.channel(), shared.device_index(), lease, &scope).await
}
struct WriteScope<C, A> {
deadline: tokio::time::Instant,
cancelled: C,
available: A,
}
impl<C: Fn() -> bool, A: Fn() -> bool> WriteScope<C, A> {
fn check_available(&self) -> Result<(), WriteError> {
if (self.available)() {
Ok(())
} else {
Err(WriteError::DeviceNotFound)
}
}
fn check(&self) -> Result<(), WriteError> {
self.check_available()?;
if (self.cancelled)() || tokio::time::Instant::now() >= self.deadline {
Err(WriteError::RequestTimedOut {
operation: HidppOperation::Lighting,
})
} else {
Ok(())
}
}
async fn read<T>(
&self,
future: impl std::future::Future<Output = Result<T, WriteError>>,
) -> Result<T, WriteError> {
self.check()?;
let result = tokio::time::timeout_at(self.deadline, future)
.await
.map_err(|_| WriteError::RequestTimedOut {
operation: HidppOperation::Lighting,
})?;
self.check()?;
result
}
}
async fn resolve_feature_index(
channel: &Arc<HidppChannel>,
device_index: u8,
feature_id: u16,
) -> Result<Option<u8>, WriteError> {
let device = Device::new(Arc::clone(channel), device_index)
.await
.map_err(|_| WriteError::DeviceUnreachable {
index: device_index,
})?;
let info = device
.root()
.get_feature(feature_id)
.await
.map_err(|e| classify_hidpp_error(e, HidppOperation::ResolveFeature, feature_id))?;
Ok(info.map(|i| i.index))
}
async fn set_color_effects(
channel: &Arc<HidppChannel>,
index: u8,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
let mut device = Device::new(Arc::clone(channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let feature = open_feature::<ColorLedEffectsFeature>(&mut device).await?;
let zone_count = feature
.get_info()
.await
.map_err(classify_lighting_error)?
.zone_count;
let mut params = [0u8; ZONE_EFFECT_PARAM_COUNT];
params[0] = r;
params[1] = g;
params[2] = b;
let targets: Vec<(u8, u8)> = if zone_count == 0 {
debug!(
index,
"0x8070 reported zero zones; applying legacy 4-zone fallback"
);
(0..MAX_COLOR_LED_EFFECT_ZONES)
.map(|zone| (zone, LEGACY_FIXED_EFFECT_INDEX))
.collect()
} else {
if zone_count > MAX_COLOR_LED_EFFECT_ZONES {
debug!(
index,
zone_count,
capped_zone_count = MAX_COLOR_LED_EFFECT_ZONES,
"0x8070 zone count capped to legacy write limit"
);
}
fixed_color_targets(&feature, index, zone_count.min(MAX_COLOR_LED_EFFECT_ZONES)).await?
};
if targets.is_empty() {
debug!(index, zone_count, "no 0x8070 zone offers a fixed colour");
return Err(WriteError::FeatureUnsupported {
feature_hex: COLOR_LED_EFFECTS_FEATURE,
});
}
for &(zone, effect) in &targets {
feature
.set_zone_effect(zone, effect, params, Persistence::Volatile)
.await
.map_err(classify_lighting_error)?;
tokio::time::sleep(FRAME_GAP).await;
}
debug!(
index,
zone_count,
zones_written = targets.len(),
r,
g,
b,
"set keyboard colour via typed 0x8070"
);
Ok(())
}
async fn fixed_color_targets(
feature: &ColorLedEffectsFeature,
index: u8,
zone_count: u8,
) -> Result<Vec<(u8, u8)>, WriteError> {
let mut targets = Vec::new();
for zone in 0..zone_count {
let effects_number = match feature.get_zone_info(zone).await {
Ok(info) => info.effects_number,
Err(Hidpp20Error::UnsupportedResponse) => {
debug!(
index,
zone, "0x8070 zone info rejected; using the legacy fixed-colour index"
);
targets.push((zone, LEGACY_FIXED_EFFECT_INDEX));
continue;
}
Err(error) => return Err(classify_lighting_error(error)),
};
for effect in 0..effects_number {
match feature.get_zone_effect_info(zone, effect).await {
Ok(info) if info.effect_id == EffectId::FixedColor => {
targets.push((zone, effect));
break;
}
Ok(_) | Err(Hidpp20Error::UnsupportedResponse) => {}
Err(error) => return Err(classify_lighting_error(error)),
}
}
}
Ok(targets)
}
fn classify_lighting_error(error: Hidpp20Error) -> WriteError {
classify_hidpp_error(error, HidppOperation::Lighting, ColorLedEffectsFeature::ID)
}
struct PerKeyV2Target {
feature: Arc<PerKeyLightingFeature>,
zones: Vec<u8>,
}
impl PerKeyV2Target {
async fn open(channel: &Arc<HidppChannel>, index: u8) -> Result<Self, WriteError> {
let mut device = Device::new(Arc::clone(channel), index)
.await
.map_err(|_| WriteError::DeviceUnreachable { index })?;
let feature = open_feature::<PerKeyLightingFeature>(&mut device).await?;
let zones = present_zones(&feature).await?;
if zones.is_empty() {
debug!(index, "0x8081 reported no present zones");
return Err(WriteError::FeatureUnsupported {
feature_hex: PerKeyLightingFeature::ID,
});
}
Ok(Self { feature, zones })
}
async fn paint(
&self,
index: u8,
color: Rgb,
scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
) -> Result<(), WriteError> {
for chunk in self.zones.chunks(MAX_SINGLE_VALUE_ZONES) {
scope.check()?;
self.feature
.set_rgb_zones_single_value(color, chunk)
.await
.map_err(classify_per_key_v2_error)?;
}
scope.check()?;
self.feature
.frame_end(FramePersistence::Volatile, 0, 0)
.await
.map_err(classify_per_key_v2_error)?;
debug!(
index,
zone_count = self.zones.len(),
?color,
"set keyboard colour via typed 0x8081"
);
Ok(())
}
}
async fn present_zones(feature: &PerKeyLightingFeature) -> Result<Vec<u8>, WriteError> {
let mut zones = Vec::new();
for (page, base) in [
(ZonePresencePage::Zones0To111, 0u16),
(ZonePresencePage::Zones112To223, 112),
(ZonePresencePage::Zones224To255, 224),
] {
let bitfield = feature
.get_rgb_zone_presence(page)
.await
.map_err(classify_per_key_v2_error)?;
collect_present_zones(base, &bitfield, &mut zones);
}
Ok(zones)
}
pub(super) fn collect_present_zones(
base: u16,
bitfield: &[u8; ZONE_PRESENCE_PAGE_LEN],
zones: &mut Vec<u8>,
) {
for (byte_index, byte) in bitfield.iter().enumerate() {
for bit in 0..8u16 {
if byte & (1 << bit) == 0 {
continue;
}
let Ok(offset) = u16::try_from(byte_index * 8) else {
continue;
};
let Ok(zone_id) = u8::try_from(base + offset + bit) else {
continue;
};
if !matches!(zone_id, 0 | 0xff) {
zones.push(zone_id);
}
}
}
}
fn classify_per_key_v2_error(error: hidpp::protocol::v20::Hidpp20Error) -> WriteError {
classify_hidpp_error(error, HidppOperation::Lighting, PerKeyLightingFeature::ID)
}
async fn set_color_per_key(
channel: &Arc<HidppChannel>,
device_index: u8,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
let feature_index = resolve_feature_index(channel, device_index, PER_KEY_LIGHTING_FEATURE)
.await?
.ok_or(WriteError::FeatureUnsupported {
feature_hex: PER_KEY_LIGHTING_FEATURE,
})?;
let frame = per_key_header(device_index, feature_index, FN_SET_KEY_RANGE);
let frame_error: Arc<Mutex<Option<Hidpp20Error>>> = Arc::default();
let _frame_errors = {
let frame_error = Arc::clone(&frame_error);
channel.add_msg_listener_guarded(move |message, _| {
if let Some(error) = v20::Message::from(message).feature_error_for(frame) {
frame_error
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert(error);
}
})
};
for report in per_key_frames(device_index, feature_index, r, g, b) {
let written = channel
.write_raw_report(&report)
.await
.map_err(classify_raw_lighting_error)?;
if written != report.len() {
return Err(WriteError::Hidpp(format!(
"raw lighting report wrote {written} of {} bytes",
report.len()
)));
}
}
RootFeature::new(Arc::clone(channel), device_index, 0)
.ping(0)
.await
.map_err(classify_per_key_error)?;
let rejected = frame_error
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(error) = rejected {
debug!(
device_index,
feature_index,
?error,
"0x8080 rejected a frame; not committing"
);
return Err(classify_per_key_error(error));
}
channel
.send_v20(per_key_commit(device_index, feature_index))
.await
.map_err(classify_per_key_error)?;
debug!(
device_index,
feature_index, r, g, b, "set keyboard colour via 0x8080"
);
Ok(())
}
pub(super) fn per_key_frames(
device_index: u8,
feature_index: u8,
r: u8,
g: u8,
b: u8,
) -> Vec<Vec<u8>> {
let mut reports = Vec::new();
let key_ids: Vec<u8> = (0x00u8..=0xe8).collect();
for chunk in key_ids.chunks(KEYS_PER_FRAME as usize) {
let mut rep = vec![0u8; 64];
rep[0] = REPORT_SET_KEYS;
rep[1] = device_index;
rep[2] = feature_index;
rep[3] = (FN_SET_KEY_RANGE << 4) | SW_ID;
rep[5] = SET_RANGE_MODE;
rep[7] = u8::try_from(chunk.len()).unwrap_or(KEYS_PER_FRAME);
for (i, &key) in chunk.iter().enumerate() {
let off = 8 + i * 4;
rep[off] = key;
rep[off + 1] = r;
rep[off + 2] = g;
rep[off + 3] = b;
}
reports.push(rep);
}
reports
}
pub(super) fn per_key_commit(device_index: u8, feature_index: u8) -> v20::Message {
v20::Message::Long(
per_key_header(device_index, feature_index, FN_FRAME_END),
[0; 16],
)
}
fn per_key_header(device_index: u8, feature_index: u8, function: u8) -> v20::MessageHeader {
v20::MessageHeader {
device_index,
feature_index,
function_id: U4::from_lo(function),
software_id: U4::from_lo(SW_ID),
}
}
fn classify_per_key_error(error: Hidpp20Error) -> WriteError {
classify_hidpp_error(error, HidppOperation::Lighting, PER_KEY_LIGHTING_FEATURE)
}
fn classify_raw_lighting_error(error: ChannelError) -> WriteError {
match error {
ChannelError::Timeout => WriteError::RequestTimedOut {
operation: HidppOperation::Lighting,
},
other => WriteError::Hidpp(format!("{other:?}")),
}
}
pub async fn set_keyboard_color_on(
shared: &SharedChannel,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
set_keyboard_color_with_on(shared, LightingMethod::Auto, r, g, b).await
}
pub async fn set_keyboard_color_with_on(
shared: &SharedChannel,
method: LightingMethod,
r: u8,
g: u8,
b: u8,
) -> Result<(), WriteError> {
LightingWrite {
method,
color: openlogi_core::color::Rgb::new(r, g, b),
}
.apply_on(shared, || false, || shared.channel().is_connected())
.await
.map(drop)
}