Skip to main content

openlogi_device/write/
os_layout.rs

1//! Keep a keyboard's `0x4531 MultiPlatform` layout on the host OS — the
2//! "keep keyboard in OS layout" setting of Logi Options+.
3//!
4//! A multi-platform keyboard keeps one platform (key layout) per Easy-Switch
5//! slot. Logi Options+ 2.8 (`feature_4531_multi_platform::_update_platform`)
6//! picks the platform whose descriptor covers the host OS
7//! (`platforms_info::find`), compares it with the platform the current slot
8//! holds, and writes it only when they differ. It writes only when the
9//! keyboard advertises `SET_HOST_PLATFORM`, the slot is below the host count
10//! and the platform below the platform count (`platforms_info::set_platform`).
11//! [`keep_os_layout_on`] makes the same decision, and then reads the slot back,
12//! because `setHostPlatform` replies with nothing to check.
13
14#[cfg(test)]
15mod tests;
16
17use std::cmp::Reverse;
18use std::sync::Arc;
19
20use hidpp::{
21    channel::HidppChannel,
22    device::Device,
23    feature::{
24        CreatableFeature as _,
25        hosts_info::HostIndex,
26        multi_platform::{
27            MultiPlatformCapabilities, MultiPlatformFeature, OsMask, PlatformDescriptor,
28        },
29    },
30};
31use tracing::debug;
32
33use crate::SharedChannel;
34
35use super::{HidppOperation, WriteError, classify_hidpp_error, open_feature};
36
37/// The operating system a keyboard's platform descriptors are matched against.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct HostOs {
40    /// The OS, as its `0x4531` descriptor bit.
41    pub os: OsMask,
42    /// The OS version, or `None` when the host does not report one. An
43    /// unknown version is taken to be newer than every bounded range, so only
44    /// a descriptor with no upper bound can match it.
45    pub version: Option<OsVersion>,
46}
47
48impl HostOs {
49    /// The OS this build runs on, its version unknown, or `None` on an OS no
50    /// `0x4531` descriptor names.
51    #[must_use]
52    pub const fn current() -> Option<Self> {
53        let os = if cfg!(target_os = "macos") {
54            OsMask::MACOS
55        } else if cfg!(windows) {
56            OsMask::WINDOWS
57        } else if cfg!(target_os = "linux") {
58            OsMask::LINUX
59        } else {
60            return None;
61        };
62        Some(Self { os, version: None })
63    }
64}
65
66/// An OS version as a platform descriptor bounds it: a major version and a
67/// revision.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
69pub struct OsVersion {
70    /// Major version.
71    pub major: u8,
72    /// Revision.
73    pub revision: u8,
74}
75
76/// What [`keep_os_layout_on`] did.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum OsLayoutOutcome {
79    /// The current slot already holds the host OS's platform.
80    AlreadySet {
81        /// The platform the slot holds.
82        platform_index: u8,
83    },
84    /// The current slot was switched to the host OS's platform.
85    Switched {
86        /// The platform the slot held before, `None` when undefined.
87        from: Option<u8>,
88        /// The platform it holds now.
89        to: u8,
90    },
91    /// The keyboard does not let software set its platform, or reports a
92    /// current slot outside its own host count.
93    NotSettable,
94    /// No platform descriptor covers the host OS.
95    NoMatchingPlatform,
96    /// The caller withdrew the write before it was sent: the setting was
97    /// turned off, or the device disabled, while the job waited.
98    Withdrawn,
99}
100
101/// The part of a platform descriptor the selection reads, with each end of
102/// its version range packed as `major << 8 | revision` (`0` leaves that end
103/// open).
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105struct Candidate {
106    platform: u8,
107    descriptor: u8,
108    os: OsMask,
109    from: u16,
110    to: u16,
111}
112
113impl From<&PlatformDescriptor> for Candidate {
114    fn from(descriptor: &PlatformDescriptor) -> Self {
115        Self {
116            platform: descriptor.platform_index,
117            descriptor: descriptor.descriptor_index,
118            os: descriptor.os_mask,
119            from: u16::from_be_bytes([descriptor.from_version, descriptor.from_revision]),
120            to: u16::from_be_bytes([descriptor.to_version, descriptor.to_revision]),
121        }
122    }
123}
124
125impl Candidate {
126    /// Whether this descriptor covers `host`'s OS and version.
127    fn covers(self, host: HostOs) -> bool {
128        if !self.os.intersects(host.os) {
129            return false;
130        }
131        match host.version {
132            Some(version) => {
133                let version = u16::from_be_bytes([version.major, version.revision]);
134                (self.from == 0 || version >= self.from) && (self.to == 0 || version <= self.to)
135            }
136            None => self.to == 0,
137        }
138    }
139}
140
141/// The platform for `host`, chosen the way Logi Options+ 2.8 does it
142/// (`platforms_info::find`): among the descriptors that cover the host, the
143/// one with the highest upper bound wins, then the one with the lowest lower
144/// bound, and on a tie the first in platform and descriptor order. A range
145/// open at the top has upper bound `0`, so a bounded match ranks before it.
146fn select_platform(candidates: impl IntoIterator<Item = Candidate>, host: HostOs) -> Option<u8> {
147    candidates
148        .into_iter()
149        .filter(|candidate| candidate.covers(host))
150        .min_by_key(|candidate| {
151            (
152                Reverse(candidate.to),
153                candidate.from,
154                candidate.platform,
155                candidate.descriptor,
156            )
157        })
158        .map(|candidate| candidate.platform)
159}
160
161/// Keep the keyboard's current Easy-Switch slot on `host`'s platform, on an
162/// already-open [`SharedChannel`]. A slot already on it is left alone; a
163/// write the keyboard does not read back surfaces as
164/// [`WriteError::UnsupportedResponse`].
165///
166/// `still_wanted` is asked before the first request and again just before the
167/// write. When it says no, nothing more is sent and the outcome is
168/// [`OsLayoutOutcome::Withdrawn`], so a job that waited for the receiver does
169/// not override a preference the user turned off in the meantime.
170pub async fn keep_os_layout_on(
171    shared: &SharedChannel,
172    host: HostOs,
173    still_wanted: impl Fn() -> bool,
174) -> Result<OsLayoutOutcome, WriteError> {
175    keep_os_layout_on_channel(shared.channel(), shared.device_index(), host, still_wanted).await
176}
177
178async fn keep_os_layout_on_channel(
179    channel: &Arc<HidppChannel>,
180    index: u8,
181    host: HostOs,
182    still_wanted: impl Fn() -> bool,
183) -> Result<OsLayoutOutcome, WriteError> {
184    if !still_wanted() {
185        return Ok(OsLayoutOutcome::Withdrawn);
186    }
187    let classify =
188        |e| classify_hidpp_error(e, HidppOperation::WriteOsLayout, MultiPlatformFeature::ID);
189    let mut device = Device::new(Arc::clone(channel), index)
190        .await
191        .map_err(|_| WriteError::DeviceUnreachable { index })?;
192    let feature = open_feature::<MultiPlatformFeature>(&mut device).await?;
193    let info = feature.get_feature_infos().await.map_err(classify)?;
194    let slot = match info.current_host {
195        HostIndex::Slot(slot) if slot < info.host_count => HostIndex::Slot(slot),
196        _ => return Ok(OsLayoutOutcome::NotSettable),
197    };
198    if !info
199        .capabilities
200        .contains(MultiPlatformCapabilities::SET_HOST_PLATFORM)
201    {
202        return Ok(OsLayoutOutcome::NotSettable);
203    }
204    let mut descriptors = Vec::with_capacity(usize::from(info.descriptor_count));
205    for descriptor_index in 0..info.descriptor_count {
206        descriptors.push(
207            feature
208                .get_platform_descriptor(descriptor_index)
209                .await
210                .map_err(classify)?,
211        );
212    }
213    let Some(target) = select_platform(descriptors.iter().map(Candidate::from), host)
214        .filter(|&platform| platform < info.platform_count)
215    else {
216        return Ok(OsLayoutOutcome::NoMatchingPlatform);
217    };
218    let before = feature
219        .get_host_platform(slot)
220        .await
221        .map_err(classify)?
222        .platform_index;
223    if before == Some(target) {
224        return Ok(OsLayoutOutcome::AlreadySet {
225            platform_index: target,
226        });
227    }
228    if !still_wanted() {
229        return Ok(OsLayoutOutcome::Withdrawn);
230    }
231    feature
232        .set_host_platform(slot, target)
233        .await
234        .map_err(classify)?;
235    let after = feature
236        .get_host_platform(slot)
237        .await
238        .map_err(classify)?
239        .platform_index;
240    if after != Some(target) {
241        return Err(WriteError::UnsupportedResponse {
242            operation: HidppOperation::WriteOsLayout,
243            feature_hex: MultiPlatformFeature::ID,
244        });
245    }
246    debug!(
247        index,
248        ?before,
249        target,
250        "keyboard platform set to the host OS"
251    );
252    Ok(OsLayoutOutcome::Switched {
253        from: before,
254        to: target,
255    })
256}