Skip to main content

openlogi_device/write/lighting/
rgb.rs

1//! Preflight and compensated ownership for `0x8071` software control: solid
2//! Static effects, `0x8081` per-key frames painted under the same claim, and
3//! the hand-back a long-lived host owes on exit.
4
5use std::sync::Arc;
6
7use hidpp::channel::{ChannelError, HidppChannel};
8use hidpp::device::Device;
9use hidpp::feature::CreatableFeature;
10use hidpp::feature::device_information::DeviceInformationFeature;
11use hidpp::feature::rgb_effects::{
12    CLUSTER_EFFECT_PARAM_COUNT, EventsNotificationFlags, PowerModeTarget, RgbEffectsFeature,
13    RgbPersistence, SwControlFlags,
14};
15use hidpp::protocol::v20::Hidpp20Error;
16use openlogi_core::color::Rgb;
17use tracing::{debug, warn};
18
19use super::{
20    FRAME_GAP, HidppOperation, WriteError, WriteScope, classify_hidpp_error, open_feature,
21};
22
23const FEATURE: u16 = 0x8071;
24// The effect ID is stable; each cluster's index for it is discovered separately.
25const STATIC_RGB: u16 = 1;
26
27#[cfg(test)]
28mod tests;
29
30/// `0x8071` software control a lighting write left asserted, with the
31/// control and event flags the device reported before that claim.
32///
33/// A volatile effect shows only while software control is held, so a
34/// successful write keeps it. A long-lived host that leaves owes it back
35/// through [`release_rgb_control`](super::release_rgb_control): G HUB and
36/// Solaar both return LED control to the firmware on exit. Disconnects and
37/// crashes cannot send it; the firmware drops volatile state on power loss.
38///
39/// Every write also records the unit ID the device reported (`0x0003`), so a
40/// later write and the release can tell the claimed device from another one
41/// paired to the same receiver slot since.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub struct RgbControlLease {
44    control: SwControlFlags,
45    events: EventsNotificationFlags,
46    device: Option<[u8; 4]>,
47}
48
49impl RgbControlLease {
50    /// Whether this lease, from a later write through the same connection,
51    /// continues `earlier`'s claim: both writes reached the same device. A
52    /// write to another device does not, even one that found control already
53    /// held, and its flags replace the recorded ones so that device is still
54    /// handed back.
55    #[must_use]
56    pub fn continues(&self, earlier: &Self) -> bool {
57        self.device == earlier.device
58    }
59}
60
61/// The `0x8071` effect engine and the Static effect each cluster offers,
62/// read before any mutation.
63pub(super) struct RgbEngine {
64    feature: Arc<RgbEffectsFeature>,
65    unit: Option<[u8; 4]>,
66    statics: Vec<(u8, u8)>,
67    complete: bool,
68}
69
70/// Open `0x8071`, identify the device, and read every cluster's Static
71/// effect. `None` when the device has no RGB effect engine.
72///
73/// Every write reads the unit ID, including one that will find control
74/// already held: a device paired into the slot since the last write may be
75/// held by this session's earlier claim or by another host, and its lease
76/// must name it. The read is part of the read-only preflight, so a failure
77/// leaves nothing to roll back.
78pub(super) async fn probe(
79    channel: &Arc<HidppChannel>,
80    index: u8,
81    scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
82) -> Result<Option<RgbEngine>, WriteError> {
83    let opened = scope
84        .read(async {
85            let mut device = device(channel, index).await?;
86            let feature = open_feature::<RgbEffectsFeature>(&mut device).await?;
87            Ok((device, feature))
88        })
89        .await;
90    let (mut device, feature) = match opened {
91        Ok(opened) => opened,
92        Err(WriteError::FeatureUnsupported {
93            feature_hex: FEATURE,
94        }) => return Ok(None),
95        Err(error) => return Err(error),
96    };
97    let unit = scope.read(unit_id(&mut device)).await?;
98    let (statics, complete) = discover(&feature, scope).await?;
99    Ok(Some(RgbEngine {
100        feature,
101        unit,
102        statics,
103        complete,
104    }))
105}
106
107impl RgbEngine {
108    /// Whether every cluster offers a Static effect, so one claim colours the
109    /// whole device without a per-key frame.
110    pub(super) fn is_complete(&self) -> bool {
111        self.complete
112    }
113
114    /// Colour every cluster with its Static effect. Callers check
115    /// [`Self::is_complete`] first: a partial engine cannot colour the device
116    /// by itself and must fall back before claiming.
117    pub(super) async fn apply(
118        &self,
119        index: u8,
120        color: Rgb,
121        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
122    ) -> Result<RgbControlLease, WriteError> {
123        debug_assert!(self.complete, "a partial engine must not claim alone");
124        self.under_control(index, scope, async |_| {
125            self.paint_statics(color, scope).await
126        })
127        .await
128    }
129
130    /// Paint a `0x8081` frame under software control. Per-key paint is a
131    /// sub-mode of the Static effect: while the firmware owns a cluster, it
132    /// draws its own effect over host frames. Every cluster that offers Static
133    /// gets it as the base colour first. The first frame after a fresh claim
134    /// can land before the firmware leaves onboard mode, so it is replayed
135    /// once. Solaar's per-key setting follows the same order.
136    pub(super) async fn apply_per_key(
137        &self,
138        index: u8,
139        color: Rgb,
140        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
141        paint: impl AsyncFn() -> Result<(), WriteError>,
142    ) -> Result<RgbControlLease, WriteError> {
143        self.under_control(index, scope, async |fresh| {
144            self.paint_statics(color, scope).await?;
145            paint().await?;
146            if fresh {
147                scope.check()?;
148                paint().await?;
149            }
150            Ok(())
151        })
152        .await
153    }
154
155    async fn paint_statics(
156        &self,
157        color: Rgb,
158        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
159    ) -> Result<(), WriteError> {
160        let (r, g, b) = color.components();
161        let mut params = [0; CLUSTER_EFFECT_PARAM_COUNT];
162        params[..3].copy_from_slice(&[r, g, b]);
163        for &(cluster, effect) in &self.statics {
164            scope.check()?;
165            self.feature
166                .set_rgb_cluster_effect(
167                    cluster,
168                    effect,
169                    params,
170                    RgbPersistence::VOLATILE,
171                    PowerModeTarget::FullPower,
172                )
173                .await
174                .map_err(classify)?;
175            tokio::time::sleep(FRAME_GAP).await;
176        }
177        scope.check()
178    }
179
180    /// Snapshot the control flags, claim every cluster, run `body`, and on any
181    /// failure restore the snapshot. `body` learns whether this claim took
182    /// control from the firmware rather than finding it already held.
183    async fn under_control(
184        &self,
185        index: u8,
186        scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
187        body: impl AsyncFnOnce(bool) -> Result<(), WriteError>,
188    ) -> Result<RgbControlLease, WriteError> {
189        let feature = &self.feature;
190        let previous = scope
191            .read(async { feature.get_sw_control().await.map_err(classify) })
192            .await?;
193        scope.check()?;
194        let fresh = !previous.control.contains(SwControlFlags::ALL_CLUSTERS);
195
196        // Recovery is owed from BEFORE sending the claim: losing its reply does
197        // not mean firmware ignored it. The owner retains this future until all
198        // submitted native writes and any compensation finish; no async Drop task.
199        let result = async {
200            feature
201                .set_sw_control(
202                    previous.control | SwControlFlags::ALL_CLUSTERS,
203                    previous.events,
204                )
205                .await
206                .map_err(classify)?;
207            body(fresh).await?;
208            scope.check()
209        }
210        .await;
211
212        if let Err(error) = result {
213            // Cancellation/deadline does not cancel compensation. Retirement or
214            // suspension does: never send an old snapshot through a replacement.
215            let restored = async {
216                scope.check_available()?;
217                feature
218                    .set_sw_control(previous.control, previous.events)
219                    .await
220                    .map_err(classify)
221            }
222            .await;
223            if let Err(restore_error) = restored {
224                warn!(
225                    index,
226                    ?error,
227                    ?restore_error,
228                    "RGB control restoration incomplete"
229                );
230                return Err(restore_error);
231            }
232            return Err(error);
233        }
234        // A successful volatile effect needs software control to remain asserted.
235        Ok(RgbControlLease {
236            control: previous.control,
237            events: previous.events,
238            device: self.unit,
239        })
240    }
241}
242
243/// Return the cluster control in `lease` to the firmware. Clears only
244/// [`SwControlFlags::ALL_CLUSTERS`] and keeps the other flags the device had
245/// before the claim. A lease taken while an earlier process still held
246/// control, such as an agent that never handed it back, therefore still
247/// releases it.
248///
249/// The lease is released only to the device that still reports the unit ID
250/// it recorded. Another device on the route, such as one paired to the same
251/// receiver slot since, is not one this lease claimed; it is left alone with
252/// [`WriteError::DeviceNotFound`]. A lease from a device without `0x0003`
253/// goes to a device that still has none.
254pub(super) async fn release(
255    channel: &Arc<HidppChannel>,
256    index: u8,
257    lease: RgbControlLease,
258    scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
259) -> Result<(), WriteError> {
260    let mut device = scope.read(device(channel, index)).await?;
261    if scope.read(unit_id(&mut device)).await? != lease.device {
262        debug!(
263            index,
264            "another device answers on the route; leaving its RGB control"
265        );
266        return Err(WriteError::DeviceNotFound);
267    }
268    scope.check()?;
269    let feature = scope
270        .read(open_feature::<RgbEffectsFeature>(&mut device))
271        .await?;
272    scope.check()?;
273    feature
274        .set_sw_control(
275            lease.control.difference(SwControlFlags::ALL_CLUSTERS),
276            lease.events,
277        )
278        .await
279        .map_err(classify)?;
280    debug!(index, "returned 0x8071 cluster control to the firmware");
281    Ok(())
282}
283
284async fn device(channel: &Arc<HidppChannel>, index: u8) -> Result<Device, WriteError> {
285    Device::new(Arc::clone(channel), index)
286        .await
287        .map_err(|_| WriteError::DeviceUnreachable { index })
288}
289
290/// The unit ID `device` reports through `0x0003`, which tells two devices
291/// on one receiver slot apart. `None` when the device has no
292/// `DeviceInformation` feature.
293async fn unit_id(device: &mut Device) -> Result<Option<[u8; 4]>, WriteError> {
294    let feature = match open_feature::<DeviceInformationFeature>(device).await {
295        Ok(feature) => feature,
296        Err(WriteError::FeatureUnsupported { .. }) => return Ok(None),
297        Err(error) => return Err(error),
298    };
299    let info = feature
300        .get_device_info()
301        .await
302        .map_err(|error| match error {
303            Hidpp20Error::Channel(ChannelError::Timeout) => WriteError::RequestTimedOut {
304                operation: HidppOperation::Lighting,
305            },
306            other => classify_hidpp_error(
307                other,
308                HidppOperation::Lighting,
309                DeviceInformationFeature::ID,
310            ),
311        })?;
312    Ok(Some(info.unit_id))
313}
314
315/// Each cluster's Static effect `(cluster, effect index)`, and whether every
316/// cluster has one. A device that reports no clusters is never complete.
317async fn discover(
318    feature: &RgbEffectsFeature,
319    scope: &WriteScope<impl Fn() -> bool, impl Fn() -> bool>,
320) -> Result<(Vec<(u8, u8)>, bool), WriteError> {
321    let info = scope
322        .read(async { feature.get_device_info().await.map_err(classify) })
323        .await?;
324    let mut statics = Vec::with_capacity(usize::from(info.cluster_count));
325    let mut complete = info.cluster_count > 0;
326    for cluster in 0..info.cluster_count {
327        let info = scope
328            .read(async { feature.get_cluster_info(cluster).await.map_err(classify) })
329            .await?;
330        let mut found = None;
331        for effect in 0..info.effects_number {
332            let info = scope
333                .read(async {
334                    feature
335                        .get_effect_info(cluster, effect)
336                        .await
337                        .map_err(classify)
338                })
339                .await?;
340            if info.effect_id == STATIC_RGB {
341                found = Some(effect);
342                break;
343            }
344        }
345        match found {
346            Some(effect) => statics.push((cluster, effect)),
347            None => complete = false,
348        }
349    }
350    Ok((statics, complete))
351}
352
353fn classify(error: Hidpp20Error) -> WriteError {
354    match error {
355        Hidpp20Error::Channel(ChannelError::Timeout) => WriteError::RequestTimedOut {
356            operation: HidppOperation::Lighting,
357        },
358        other => classify_hidpp_error(other, HidppOperation::Lighting, FEATURE),
359    }
360}