logiops_core/features/
onboard_profiles.rs

1//! `On-board Profiles` feature (0x8100) - Device profile management.
2//!
3//! This feature provides control over on-board profiles stored in device
4//! flash memory. Supports profile switching and DPI index selection.
5//!
6//! Note: Full profile read/write operations (sector I/O, macros, button
7//! bindings) are complex and may be added in future versions.
8
9use hidpp_transport::HidapiChannel;
10use tracing::{debug, trace};
11
12use crate::error::{HidppErrorCode, ProtocolError, Result};
13use crate::protocol::{build_long_request, get_error_code, is_error_response};
14
15/// Function IDs for the On-board Profiles feature.
16mod function_id {
17    /// Get profiles description (capabilities).
18    pub const GET_PROFILES_DESC: u8 = 0x00;
19    /// Set onboard mode (host vs onboard control).
20    pub const SET_ONBOARD_MODE: u8 = 0x01;
21    /// Get onboard mode.
22    pub const GET_ONBOARD_MODE: u8 = 0x02;
23    /// Get current profile index.
24    pub const GET_CURRENT_PROFILE: u8 = 0x04;
25    /// Set current profile index.
26    pub const SET_CURRENT_PROFILE: u8 = 0x05;
27    /// Get current DPI index within profile.
28    pub const GET_CURRENT_DPI_INDEX: u8 = 0x06;
29    /// Set current DPI index within profile.
30    pub const SET_CURRENT_DPI_INDEX: u8 = 0x07;
31}
32
33/// Memory model types for profile storage.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35#[repr(u8)]
36pub enum MemoryModel {
37    /// No on-board memory.
38    None = 0,
39    /// G402 memory model.
40    G402 = 1,
41    /// G303 memory model.
42    G303 = 2,
43    /// G900 memory model.
44    G900 = 3,
45    /// G915 memory model.
46    G915 = 4,
47    /// Unknown model.
48    Unknown(u8),
49}
50
51impl From<u8> for MemoryModel {
52    fn from(value: u8) -> Self {
53        match value {
54            0 => Self::None,
55            1 => Self::G402,
56            2 => Self::G303,
57            3 => Self::G900,
58            4 => Self::G915,
59            v => Self::Unknown(v),
60        }
61    }
62}
63
64/// Onboard mode - who controls the device configuration.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[repr(u8)]
67pub enum OnboardMode {
68    /// Device uses onboard profiles.
69    Onboard = 1,
70    /// Host software controls device.
71    Host = 2,
72}
73
74impl TryFrom<u8> for OnboardMode {
75    type Error = ();
76
77    fn try_from(value: u8) -> std::result::Result<Self, Self::Error> {
78        match value {
79            1 => Ok(Self::Onboard),
80            2 => Ok(Self::Host),
81            _ => Err(()),
82        }
83    }
84}
85
86/// Profile capabilities and description.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProfilesDescription {
89    /// Memory model type.
90    pub memory_model: MemoryModel,
91    /// Profile format version.
92    pub profile_format: u8,
93    /// Macro format version.
94    pub macro_format: u8,
95    /// Number of profiles.
96    pub profile_count: u8,
97    /// Maximum number of profiles supported.
98    pub profile_count_oob: u8,
99    /// Number of buttons per profile.
100    pub button_count: u8,
101    /// Number of sectors for profiles.
102    pub sector_count: u8,
103    /// Sector page size in bytes.
104    pub sector_size: u16,
105    /// Device mechanical layout.
106    pub mechanical_layout: u8,
107    /// Number of different modes.
108    pub num_modes: u8,
109}
110
111/// `On-board Profiles` feature implementation.
112pub struct OnboardProfilesFeature {
113    device_index: u8,
114    feature_index: u8,
115}
116
117impl OnboardProfilesFeature {
118    /// Creates a new on-board profiles feature accessor.
119    ///
120    /// # Arguments
121    /// * `device_index` - Device index (0xFF for direct)
122    /// * `feature_index` - Feature index from root feature discovery
123    #[must_use]
124    pub fn new(device_index: u8, feature_index: u8) -> Self {
125        Self {
126            device_index,
127            feature_index,
128        }
129    }
130
131    /// Gets the profiles description (device capabilities).
132    ///
133    /// # Errors
134    /// Returns an error if HID++ communication fails.
135    pub async fn get_profiles_description(
136        &self,
137        channel: &HidapiChannel,
138    ) -> Result<ProfilesDescription> {
139        let request = build_long_request(
140            self.device_index,
141            self.feature_index,
142            function_id::GET_PROFILES_DESC,
143            &[],
144        );
145
146        trace!("getting profiles description");
147        let response = channel.request(&request, 5).await?;
148
149        if is_error_response(&response) {
150            let code = get_error_code(&response).unwrap_or(0);
151            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
152        }
153
154        if response.len() < 16 {
155            return Err(ProtocolError::InvalidResponse(
156                "profiles description response too short".to_string(),
157            ));
158        }
159
160        // Response format (bytes 4+):
161        // [0]: memory model
162        // [1]: profile format
163        // [2]: macro format
164        // [3]: profile count
165        // [4]: profile count OOB
166        // [5]: button count
167        // [6]: sector count
168        // [7-8]: sector size (big-endian)
169        // [9]: mechanical layout
170        // [10]: num modes
171        let desc = ProfilesDescription {
172            memory_model: MemoryModel::from(response[4]),
173            profile_format: response[5],
174            macro_format: response[6],
175            profile_count: response[7],
176            profile_count_oob: response[8],
177            button_count: response[9],
178            sector_count: response[10],
179            sector_size: u16::from_be_bytes([response[11], response[12]]),
180            mechanical_layout: response[13],
181            num_modes: response[14],
182        };
183
184        debug!(
185            memory_model = ?desc.memory_model,
186            profile_count = desc.profile_count,
187            button_count = desc.button_count,
188            "got profiles description"
189        );
190
191        Ok(desc)
192    }
193
194    /// Gets the current onboard mode.
195    ///
196    /// # Errors
197    /// Returns an error if HID++ communication fails.
198    pub async fn get_onboard_mode(&self, channel: &HidapiChannel) -> Result<OnboardMode> {
199        let request = build_long_request(
200            self.device_index,
201            self.feature_index,
202            function_id::GET_ONBOARD_MODE,
203            &[],
204        );
205
206        trace!("getting onboard mode");
207        let response = channel.request(&request, 5).await?;
208
209        if is_error_response(&response) {
210            let code = get_error_code(&response).unwrap_or(0);
211            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
212        }
213
214        if response.len() < 5 {
215            return Err(ProtocolError::InvalidResponse(
216                "onboard mode response too short".to_string(),
217            ));
218        }
219
220        let mode = OnboardMode::try_from(response[4]).map_err(|()| {
221            ProtocolError::InvalidResponse(format!("invalid onboard mode: {}", response[4]))
222        })?;
223
224        debug!(mode = ?mode, "got onboard mode");
225        Ok(mode)
226    }
227
228    /// Sets the onboard mode.
229    ///
230    /// # Arguments
231    /// * `channel` - HID channel
232    /// * `mode` - Mode to set (Onboard or Host)
233    ///
234    /// # Errors
235    /// Returns an error if HID++ communication fails.
236    pub async fn set_onboard_mode(
237        &self,
238        channel: &HidapiChannel,
239        mode: OnboardMode,
240    ) -> Result<()> {
241        let request = build_long_request(
242            self.device_index,
243            self.feature_index,
244            function_id::SET_ONBOARD_MODE,
245            &[mode as u8],
246        );
247
248        trace!(mode = ?mode, "setting onboard mode");
249        let response = channel.request(&request, 5).await?;
250
251        if is_error_response(&response) {
252            let code = get_error_code(&response).unwrap_or(0);
253            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
254        }
255
256        debug!(mode = ?mode, "set onboard mode");
257        Ok(())
258    }
259
260    /// Gets the current active profile index.
261    ///
262    /// Profile indices are 1-based (1 = first profile).
263    ///
264    /// # Errors
265    /// Returns an error if HID++ communication fails.
266    pub async fn get_current_profile(&self, channel: &HidapiChannel) -> Result<u8> {
267        let request = build_long_request(
268            self.device_index,
269            self.feature_index,
270            function_id::GET_CURRENT_PROFILE,
271            &[],
272        );
273
274        trace!("getting current profile");
275        let response = channel.request(&request, 5).await?;
276
277        if is_error_response(&response) {
278            let code = get_error_code(&response).unwrap_or(0);
279            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
280        }
281
282        if response.len() < 5 {
283            return Err(ProtocolError::InvalidResponse(
284                "current profile response too short".to_string(),
285            ));
286        }
287
288        let profile = response[4];
289        debug!(profile, "got current profile");
290        Ok(profile)
291    }
292
293    /// Sets the current active profile.
294    ///
295    /// # Arguments
296    /// * `channel` - HID channel
297    /// * `profile_index` - Profile index (1-based)
298    ///
299    /// # Errors
300    /// Returns an error if HID++ communication fails or the profile index is invalid.
301    pub async fn set_current_profile(
302        &self,
303        channel: &HidapiChannel,
304        profile_index: u8,
305    ) -> Result<()> {
306        let request = build_long_request(
307            self.device_index,
308            self.feature_index,
309            function_id::SET_CURRENT_PROFILE,
310            &[profile_index],
311        );
312
313        trace!(profile = profile_index, "setting current profile");
314        let response = channel.request(&request, 5).await?;
315
316        if is_error_response(&response) {
317            let code = get_error_code(&response).unwrap_or(0);
318            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
319        }
320
321        debug!(profile = profile_index, "set current profile");
322        Ok(())
323    }
324
325    /// Gets the current DPI index within the active profile.
326    ///
327    /// DPI indices are 0-based (0 = first DPI slot).
328    ///
329    /// # Errors
330    /// Returns an error if HID++ communication fails.
331    pub async fn get_current_dpi_index(&self, channel: &HidapiChannel) -> Result<u8> {
332        let request = build_long_request(
333            self.device_index,
334            self.feature_index,
335            function_id::GET_CURRENT_DPI_INDEX,
336            &[],
337        );
338
339        trace!("getting current DPI index");
340        let response = channel.request(&request, 5).await?;
341
342        if is_error_response(&response) {
343            let code = get_error_code(&response).unwrap_or(0);
344            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
345        }
346
347        if response.len() < 5 {
348            return Err(ProtocolError::InvalidResponse(
349                "current DPI index response too short".to_string(),
350            ));
351        }
352
353        let dpi_index = response[4];
354        debug!(dpi_index, "got current DPI index");
355        Ok(dpi_index)
356    }
357
358    /// Sets the current DPI index within the active profile.
359    ///
360    /// # Arguments
361    /// * `channel` - HID channel
362    /// * `dpi_index` - DPI slot index (0-based)
363    ///
364    /// # Errors
365    /// Returns an error if HID++ communication fails or the index is invalid.
366    pub async fn set_current_dpi_index(
367        &self,
368        channel: &HidapiChannel,
369        dpi_index: u8,
370    ) -> Result<()> {
371        let request = build_long_request(
372            self.device_index,
373            self.feature_index,
374            function_id::SET_CURRENT_DPI_INDEX,
375            &[dpi_index],
376        );
377
378        trace!(dpi_index, "setting current DPI index");
379        let response = channel.request(&request, 5).await?;
380
381        if is_error_response(&response) {
382            let code = get_error_code(&response).unwrap_or(0);
383            return Err(ProtocolError::HidppError(HidppErrorCode::from_byte(code)));
384        }
385
386        debug!(dpi_index, "set current DPI index");
387        Ok(())
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn test_memory_model() {
397        assert_eq!(MemoryModel::from(0), MemoryModel::None);
398        assert_eq!(MemoryModel::from(1), MemoryModel::G402);
399        assert_eq!(MemoryModel::from(3), MemoryModel::G900);
400        assert!(matches!(MemoryModel::from(99), MemoryModel::Unknown(99)));
401    }
402
403    #[test]
404    fn test_onboard_mode() {
405        assert_eq!(OnboardMode::try_from(1), Ok(OnboardMode::Onboard));
406        assert_eq!(OnboardMode::try_from(2), Ok(OnboardMode::Host));
407        assert!(OnboardMode::try_from(0).is_err());
408        assert!(OnboardMode::try_from(3).is_err());
409    }
410}