Skip to main content

openlogi_core/hid/
easy_switch.rs

1//! Easy-Switch host slots — pure data, no I/O.
2//!
3//! The HID++ `0x1815 HostsInfo` reads and the rename that produce and change
4//! an [`EasySwitchHosts`] live in `openlogi_device::write::easy_switch`.
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10use super::error::WriteError;
11
12/// How a host slot reaches its computer.
13///
14/// Logi Options+ 2.8 shows Bluetooth classic and Bluetooth Low Energy alike,
15/// so they share one variant here.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub enum HostConnection {
18    /// The device reports no bus for the slot.
19    Unknown,
20    /// A Unifying (eQuad) receiver.
21    Unifying,
22    /// A USB cable.
23    Usb,
24    /// Bluetooth, classic or Low Energy.
25    Bluetooth,
26    /// A Logi Bolt receiver.
27    Bolt,
28}
29
30/// An operating system a host slot can record.
31///
32/// The values Logi Options+ 2.8 writes to and reads from `0x1815`. "No OS
33/// recorded" is the absence of a [`HostOsInfo`], not a variant.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35pub enum HostOsKind {
36    /// Windows.
37    Windows,
38    /// Windows Embedded.
39    WindowsEmbedded,
40    /// Linux.
41    Linux,
42    /// ChromeOS.
43    ChromeOs,
44    /// Android.
45    Android,
46    /// macOS.
47    MacOs,
48    /// iOS or iPadOS.
49    Ios,
50    /// webOS.
51    WebOs,
52    /// Tizen.
53    Tizen,
54}
55
56impl fmt::Display for HostOsKind {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(match self {
59            Self::Windows => "Windows",
60            Self::WindowsEmbedded => "Windows Embedded",
61            Self::Linux => "Linux",
62            Self::ChromeOs => "ChromeOS",
63            Self::Android => "Android",
64            Self::MacOs => "macOS",
65            Self::Ios => "iOS",
66            Self::WebOs => "webOS",
67            Self::Tizen => "Tizen",
68        })
69    }
70}
71
72/// The OS and version the computer on a slot recorded in the device.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74pub struct HostOsInfo {
75    /// The operating system.
76    pub kind: HostOsKind,
77    /// Major version.
78    pub major: u8,
79    /// Minor version.
80    pub minor: u16,
81    /// Patch version.
82    pub patch: u16,
83}
84
85impl fmt::Display for HostOsInfo {
86    /// The OS and `major.minor`, as Logi Options+ shows it; a version of
87    /// `0.0` is left out.
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        if self.major == 0 && self.minor == 0 {
90            write!(f, "{}", self.kind)
91        } else {
92            write!(f, "{} {}.{}", self.kind, self.major, self.minor)
93        }
94    }
95}
96
97/// One Easy-Switch channel of a multi-host device.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct EasySwitchSlot {
100    /// Zero-based slot index; the device labels it `index + 1`.
101    pub index: u8,
102    /// Whether a computer is paired on the slot.
103    pub paired: bool,
104    /// How the slot reaches its computer.
105    pub connection: HostConnection,
106    /// The friendly name the device stores for the slot's computer. `None`
107    /// when the slot is empty, stores no name, or the device cannot report
108    /// names.
109    pub name: Option<String>,
110    /// The longest name, in UTF-8 bytes, the device stores for the slot.
111    pub name_max_len: u8,
112    /// The OS the slot's computer recorded. `None` when the slot is empty,
113    /// records none, or the device cannot report it.
114    pub os: Option<HostOsInfo>,
115}
116
117/// The Easy-Switch channels of a multi-host device, read from `0x1815`.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct EasySwitchHosts {
120    /// The slot the device is talking to now, when it names one.
121    pub current: Option<u8>,
122    /// Every slot, in index order.
123    pub slots: Vec<EasySwitchSlot>,
124    /// Whether the device lets software rename a slot's computer.
125    pub can_rename: bool,
126}
127
128impl EasySwitchHosts {
129    /// The slot at `index`, if the device has one.
130    #[must_use]
131    pub fn slot(&self, index: u8) -> Option<&EasySwitchSlot> {
132        self.slots.iter().find(|slot| slot.index == index)
133    }
134
135    /// The name to write when `name` is asked for slot `index`: `name`
136    /// without surrounding whitespace, checked the way the device layer
137    /// checks it before writing. A device that cannot rename fails with
138    /// [`WriteError::HostRenameUnsupported`], an empty or missing slot with
139    /// [`WriteError::HostSlotEmpty`], and a name that is empty, holds a NUL
140    /// or is longer than the slot stores with
141    /// [`WriteError::HostNameRejected`].
142    pub fn name_for<'a>(&self, index: u8, name: &'a str) -> Result<&'a str, WriteError> {
143        if !self.can_rename {
144            return Err(WriteError::HostRenameUnsupported);
145        }
146        let slot = self
147            .slot(index)
148            .filter(|slot| slot.paired)
149            .ok_or(WriteError::HostSlotEmpty { slot: index })?;
150        let name = name.trim();
151        if name.is_empty() || name.contains('\0') || name.len() > usize::from(slot.name_max_len) {
152            return Err(WriteError::HostNameRejected {
153                max_len: slot.name_max_len,
154            });
155        }
156        Ok(name)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::{
163        EasySwitchHosts, EasySwitchSlot, HostConnection, HostOsInfo, HostOsKind, WriteError,
164    };
165
166    fn slot(index: u8, paired: bool) -> EasySwitchSlot {
167        EasySwitchSlot {
168            index,
169            paired,
170            connection: HostConnection::Bluetooth,
171            name: paired.then(|| "Studio".to_owned()),
172            name_max_len: 8,
173            os: None,
174        }
175    }
176
177    fn hosts(can_rename: bool) -> EasySwitchHosts {
178        EasySwitchHosts {
179            current: Some(0),
180            slots: vec![slot(0, true), slot(1, false)],
181            can_rename,
182        }
183    }
184
185    #[test]
186    fn a_name_is_trimmed_and_must_fit_the_slot() {
187        let hosts = hosts(true);
188        assert_eq!(hosts.name_for(0, "  Laptop \n"), Ok("Laptop"));
189        assert_eq!(hosts.name_for(0, "12345678"), Ok("12345678"));
190        for rejected in ["", "   ", "123456789", "a\0b"] {
191            assert_eq!(
192                hosts.name_for(0, rejected),
193                Err(WriteError::HostNameRejected { max_len: 8 }),
194                "{rejected:?}"
195            );
196        }
197        // Eight bytes, not eight characters: "ü" is two bytes in UTF-8.
198        assert_eq!(
199            hosts.name_for(0, "üüüüü"),
200            Err(WriteError::HostNameRejected { max_len: 8 })
201        );
202    }
203
204    #[test]
205    fn only_a_paired_slot_of_a_renaming_device_takes_a_name() {
206        assert_eq!(
207            hosts(false).name_for(0, "Laptop"),
208            Err(WriteError::HostRenameUnsupported)
209        );
210        assert_eq!(
211            hosts(true).name_for(1, "Laptop"),
212            Err(WriteError::HostSlotEmpty { slot: 1 })
213        );
214        assert_eq!(
215            hosts(true).name_for(2, "Laptop"),
216            Err(WriteError::HostSlotEmpty { slot: 2 })
217        );
218    }
219
220    #[test]
221    fn an_os_shows_major_and_minor_unless_both_are_zero() {
222        let os = |major, minor| HostOsInfo {
223            kind: HostOsKind::MacOs,
224            major,
225            minor,
226            patch: 3,
227        };
228        assert_eq!(os(15, 2).to_string(), "macOS 15.2");
229        assert_eq!(os(10, 0).to_string(), "macOS 10.0");
230        assert_eq!(os(0, 0).to_string(), "macOS");
231    }
232}