Skip to main content

hidpp/feature/color_led_effects/
mod.rs

1//! Implements the `ColorLedEffects` feature (ID `0x8070`, version 7), the
2//! per-zone RGB effect engine used by Logitech keyboards and mice.
3//!
4//! Each device exposes one or more LED *zones*; each zone supports a set of
5//! *effects* (fixed color, breathing, color wave, …). An effect is applied with
6//! [`set_zone_effect`](ColorLedEffectsFeature::set_zone_effect), whose ten
7//! parameter bytes have effect-specific meaning, and can be stored volatilely or
8//! in EEPROM via [`Persistence`].
9//!
10//! All multi-byte fields in this feature are big-endian.
11
12pub mod event;
13pub mod types;
14
15#[cfg(test)]
16mod tests;
17
18use std::sync::Arc;
19
20pub use event::ColorLedEffectsEvent;
21pub use types::{
22    ColorLedInfo, CyclingDirection, EffectId, EffectSettings, ExtCapabilities, LedBinIndex,
23    LedBinInfo, LocationEffect, NvCapabilities, NvCapabilityState, NvConfig, Persistence,
24    PersistenceSource, PersistencyCapabilities, Rgb, SwControl, SwControlState,
25    ZONE_EFFECT_PARAM_COUNT, ZoneEffect, ZoneEffectInfo, ZoneInfo,
26};
27
28use self::types::be16;
29use crate::{
30    channel::{HidppChannel, MessageListenerGuard},
31    event::EventEmitter,
32    feature::{CreatableFeature, EmittingFeature, Feature, FeatureEndpoint, event_payload},
33    protocol::v20::{ErrorType, Hidpp20Error},
34};
35
36/// Implements the `ColorLedEffects` / `0x8070` feature.
37pub struct ColorLedEffectsFeature {
38    /// The endpoint this feature talks to.
39    endpoint: FeatureEndpoint,
40
41    /// The emitter used to publish decoded events.
42    emitter: Arc<EventEmitter<ColorLedEffectsEvent>>,
43
44    /// Removes the message listener when the feature is dropped.
45    _msg_listener: MessageListenerGuard,
46}
47
48impl CreatableFeature for ColorLedEffectsFeature {
49    const ID: u16 = 0x8070;
50    const STARTING_VERSION: u8 = 0;
51
52    fn new(chan: Arc<HidppChannel>, device_index: u8, feature_index: u8) -> Self {
53        let emitter = Arc::new(EventEmitter::new());
54
55        let listener = chan.add_msg_listener_guarded({
56            let emitter = Arc::clone(&emitter);
57
58            move |raw, matched| {
59                let Some((func, payload)) =
60                    event_payload(raw, matched, device_index, feature_index)
61                else {
62                    return;
63                };
64                if let Some(event) = event::decode_event(func.to_lo(), &payload) {
65                    emitter.emit(event);
66                }
67            }
68        });
69
70        Self {
71            endpoint: FeatureEndpoint::new(chan, device_index, feature_index),
72            emitter,
73            _msg_listener: listener,
74        }
75    }
76}
77
78impl Feature for ColorLedEffectsFeature {}
79
80impl EmittingFeature<ColorLedEffectsEvent> for ColorLedEffectsFeature {
81    fn listen(&self) -> async_channel::Receiver<ColorLedEffectsEvent> {
82        self.emitter.create_receiver()
83    }
84}
85
86impl ColorLedEffectsFeature {
87    /// Retrieves the zone count and capability bitmasks.
88    pub async fn get_info(&self) -> Result<ColorLedInfo, Hidpp20Error> {
89        let payload = self.endpoint.call(0, [0; 3]).await?.extend_payload();
90        Ok(ColorLedInfo::from_payload(&payload))
91    }
92
93    /// Retrieves information about `zone_index`.
94    pub async fn get_zone_info(&self, zone_index: u8) -> Result<ZoneInfo, Hidpp20Error> {
95        let payload = self
96            .endpoint
97            .call(1, [zone_index, 0, 0])
98            .await?
99            .extend_payload();
100        ZoneInfo::from_payload(&payload)
101    }
102
103    /// Retrieves information about effect `zone_effect_index` of `zone_index`.
104    pub async fn get_zone_effect_info(
105        &self,
106        zone_index: u8,
107        zone_effect_index: u8,
108    ) -> Result<ZoneEffectInfo, Hidpp20Error> {
109        let payload = self
110            .endpoint
111            .call(2, [zone_index, zone_effect_index, 0])
112            .await?
113            .extend_payload();
114        ZoneEffectInfo::from_payload(&payload)
115    }
116
117    /// Applies effect `zone_effect_index` to `zone_index` with effect-specific
118    /// `params`.
119    ///
120    /// The meaning of each parameter byte depends on the effect's
121    /// [`EffectId`] (discoverable with [`Self::get_zone_effect_info`]). For
122    /// example, the [`EffectId::FixedColor`] effect uses the first three
123    /// parameters as red, green and blue.
124    pub async fn set_zone_effect(
125        &self,
126        zone_index: u8,
127        zone_effect_index: u8,
128        params: [u8; ZONE_EFFECT_PARAM_COUNT],
129        persistence: Persistence,
130    ) -> Result<(), Hidpp20Error> {
131        let mut args = [0; 16];
132        args[0] = zone_index;
133        args[1] = zone_effect_index;
134        args[2..2 + ZONE_EFFECT_PARAM_COUNT].copy_from_slice(&params);
135        args[12] = persistence.into();
136        self.endpoint.call_long(3, args).await?;
137        Ok(())
138    }
139
140    /// Reads one non-volatile configuration `capability`.
141    ///
142    /// Exactly one [`NvCapabilities`] bit must be set.
143    pub async fn get_nv_config(
144        &self,
145        capability: NvCapabilities,
146    ) -> Result<NvConfig, Hidpp20Error> {
147        validate_single_nv_capability(capability)?;
148        let [cap_hi, cap_lo] = capability.bits().to_be_bytes();
149        let payload = self
150            .endpoint
151            .call(4, [cap_hi, cap_lo, 0])
152            .await?
153            .extend_payload();
154        Ok(NvConfig {
155            capability: NvCapabilities::from_bits_retain(be16(&payload, 0)),
156            state: NvCapabilityState::try_from(payload[2])
157                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
158            param1: payload[3],
159            param2: payload[4],
160        })
161    }
162
163    /// Writes one non-volatile configuration entry (to EEPROM, so use sparingly).
164    pub async fn set_nv_config(
165        &self,
166        capability: NvCapabilities,
167        state: NvCapabilityState,
168        param1: u8,
169        param2: u8,
170    ) -> Result<(), Hidpp20Error> {
171        validate_single_nv_capability(capability)?;
172        let [cap_hi, cap_lo] = capability.bits().to_be_bytes();
173        let mut args = [0; 16];
174        args[..5].copy_from_slice(&[cap_hi, cap_lo, state.into(), param1, param2]);
175        self.endpoint.call_long(5, args).await?;
176        Ok(())
177    }
178
179    /// Reads manufacturing LED bin information.
180    pub async fn get_led_bin_info(
181        &self,
182        zone_index: u8,
183        led_bin_index: LedBinIndex,
184    ) -> Result<LedBinInfo, Hidpp20Error> {
185        let payload = self
186            .endpoint
187            .call(6, [zone_index, led_bin_index.into(), 0])
188            .await?
189            .extend_payload();
190        LedBinInfo::from_payload(&payload)
191    }
192
193    /// Retrieves whether firmware or software owns the LEDs.
194    pub async fn get_sw_control(&self) -> Result<SwControlState, Hidpp20Error> {
195        let payload = self.endpoint.call(7, [0; 3]).await?.extend_payload();
196        Ok(SwControlState {
197            control: SwControl::try_from(payload[0])
198                .map_err(|_| Hidpp20Error::UnsupportedResponse)?,
199            sync_events: payload[1] != 0,
200        })
201    }
202
203    /// Takes or releases software control of the LEDs.
204    ///
205    /// `sync_events` enables the [`ColorLedEffectsEvent::SyncEffect`] event. This
206    /// is not stored in EEPROM.
207    pub async fn set_sw_control(
208        &self,
209        control: SwControl,
210        sync_events: bool,
211    ) -> Result<(), Hidpp20Error> {
212        self.endpoint
213            .call(8, [control.into(), u8::from(sync_events), 0])
214            .await?;
215        Ok(())
216    }
217
218    /// Reads the effect settings of `zone_index`.
219    ///
220    /// Not supported when [`ExtCapabilities::NO_GET_EFFECT_SETTINGS`] is set.
221    pub async fn get_effect_settings(
222        &self,
223        zone_index: u8,
224        source: PersistenceSource,
225    ) -> Result<EffectSettings, Hidpp20Error> {
226        let payload = self
227            .endpoint
228            .call(9, [zone_index, source.into(), 0])
229            .await?
230            .extend_payload();
231        Ok(EffectSettings::from_payload(&payload))
232    }
233
234    /// Clears the effect settings of `zone_index`, reverting it to the default
235    /// mode.
236    pub async fn clear_effect_settings(&self, zone_index: u8) -> Result<(), Hidpp20Error> {
237        self.endpoint.call(10, [zone_index, 0, 0]).await?;
238        Ok(())
239    }
240
241    /// Sets the color-cycling direction.
242    pub async fn set_cycling_direction(
243        &self,
244        direction: CyclingDirection,
245    ) -> Result<(), Hidpp20Error> {
246        self.endpoint.call(11, [direction.into(), 0, 0]).await?;
247        Ok(())
248    }
249
250    /// Retrieves the color currently displayed by `zone_index`.
251    pub async fn get_current_color(&self, zone_index: u8) -> Result<Rgb, Hidpp20Error> {
252        let payload = self
253            .endpoint
254            .call(12, [zone_index, 0, 0])
255            .await?
256            .extend_payload();
257        Ok(Rgb {
258            red: payload[1],
259            green: payload[2],
260            blue: payload[3],
261        })
262    }
263
264    /// Synchronizes effect timing across devices by applying a `drift_value`
265    /// correction (milliseconds).
266    ///
267    /// Valid only while sync events are enabled. A `zone_index` of `0xff` targets
268    /// all zones.
269    pub async fn synchronize_effect(
270        &self,
271        zone_index: u8,
272        drift_value: i16,
273    ) -> Result<(), Hidpp20Error> {
274        let [drift_hi, drift_lo] = drift_value.to_be_bytes();
275        let mut args = [0; 16];
276        args[..4].copy_from_slice(&[zone_index, 0, drift_hi, drift_lo]);
277        self.endpoint.call_long(13, args).await?;
278        Ok(())
279    }
280
281    /// Retrieves the currently configured effect of `zone_index`.
282    ///
283    /// Requires [`ExtCapabilities::GET_ZONE_EFFECT`].
284    pub async fn get_zone_effect(
285        &self,
286        zone_index: u8,
287        source: PersistenceSource,
288    ) -> Result<ZoneEffect, Hidpp20Error> {
289        let payload = self
290            .endpoint
291            .call(14, [zone_index, source.into(), 0])
292            .await?
293            .extend_payload();
294        Ok(ZoneEffect::from_payload(&payload))
295    }
296
297    /// Stores manufacturing LED bin information and returns the device's echo.
298    ///
299    /// Requires [`ExtCapabilities::SET_LED_BIN_INFO`].
300    pub async fn set_led_bin_info(&self, info: &LedBinInfo) -> Result<LedBinInfo, Hidpp20Error> {
301        let mut args = [0; 16];
302        args[0] = info.zone_index;
303        args[1] = info.led_bin_index.into();
304        args[2..4].copy_from_slice(&info.red.to_be_bytes());
305        args[4..6].copy_from_slice(&info.green.to_be_bytes());
306        args[6..8].copy_from_slice(&info.blue.to_be_bytes());
307        args[8..10].copy_from_slice(&info.white.to_be_bytes());
308        let payload = self.endpoint.call_long(15, args).await?.extend_payload();
309        LedBinInfo::from_payload(&payload)
310    }
311}
312
313fn validate_single_nv_capability(capability: NvCapabilities) -> Result<(), Hidpp20Error> {
314    if capability.bits().count_ones() != 1 {
315        return Err(Hidpp20Error::Feature(ErrorType::InvalidArgument));
316    }
317    Ok(())
318}