openseries-gg 1.0.0

Control supported SteelSeries headsets and mice from the command line or Rust
Documentation
use super::*;

const AEROX_3_ZONES: [MouseZone; 3] = [MouseZone::Top, MouseZone::Middle, MouseZone::Bottom];
const SKIPPED_SENSITIVITY: [u8; 35] = [
    0x08, 0x0f, 0x15, 0x1c, 0x22, 0x24, 0x2b, 0x31, 0x37, 0x3d, 0x43, 0x49, 0x4f, 0x55, 0x5b, 0x61,
    0x67, 0x6d, 0x73, 0x79, 0x7f, 0x85, 0x8b, 0x91, 0x97, 0x9d, 0xa3, 0xa9, 0xaf, 0xbe, 0xc1, 0xc8,
    0xce, 0xd4, 0xff,
];
const CORE_SENSITIVITY: [u8; 84] = [
    0x04, 0x06, 0x08, 0x0b, 0x0d, 0x0f, 0x12, 0x14, 0x16, 0x19, 0x1b, 0x1d, 0x20, 0x22, 0x24, 0x27,
    0x29, 0x2b, 0x2e, 0x30, 0x32, 0x34, 0x37, 0x39, 0x3b, 0x3e, 0x40, 0x42, 0x45, 0x47, 0x49, 0x4c,
    0x4e, 0x50, 0x53, 0x55, 0x57, 0x5a, 0x5c, 0x5e, 0x61, 0x63, 0x65, 0x68, 0x6a, 0x6c, 0x6f, 0x71,
    0x73, 0x76, 0x78, 0x7a, 0x7d, 0x7f, 0x81, 0x84, 0x86, 0x88, 0x8b, 0x8d, 0x8f, 0x92, 0x94, 0x96,
    0x99, 0x9b, 0x9d, 0xa0, 0xa2, 0xa4, 0xa7, 0xa9, 0xab, 0xad, 0xb0, 0xb2, 0xb4, 0xb7, 0xb9, 0xbc,
    0xbe, 0xc0, 0xc3, 0xc5,
];

pub(crate) static DEFINITION: DeviceDefinition = DeviceDefinition {
    slug: "aerox-3",
    product_ids: &[0x1836, 0x1838, 0x183a, 0x1878, 0x187a],
    rule: MatchRule::VendorControl,
    connect: |identity, transport| {
        Device::Mouse(Mouse::new(Box::new(Aerox3::new(identity, transport))))
    },
};

struct Aerox3 {
    base: DeviceContext,
}

impl Aerox3 {
    fn new(identity: Identity, transport: HidTransport) -> Self {
        Self {
            base: DeviceContext::new(identity, transport),
        }
    }

    fn receiver(&self) -> bool {
        [0x1838, 0x1878].contains(&self.base.identity.product_id)
    }

    fn wireless(&self) -> bool {
        [0x1838, 0x183a, 0x1878, 0x187a].contains(&self.base.identity.product_id)
    }

    fn send(&mut self, command: &[u8], read: bool) -> Result<Vec<u8>> {
        let mut framed = command.to_vec();
        if self.receiver() {
            framed[0] |= 0x40;
        }
        if read {
            self.base
                .transport
                .write_output_and_read(&framed, 64, 1, 0, None)
        } else {
            self.base.transport.write_output(&framed, 1, 0)?;
            Ok(Vec::new())
        }
    }

    fn send_and_save(&mut self, command: &[u8]) -> Result<()> {
        let receiver = self.receiver();
        self.send(command, receiver)?;
        thread::sleep(Duration::from_millis(50));
        self.send(&[0x11, 0], receiver)?;
        Ok(())
    }

    fn encode_air(dpi: u16, index: usize) -> Result<u8> {
        if !(100..=18_000).contains(&dpi) || !dpi.is_multiple_of(100) {
            return Err(invalid(format!(
                "DPI preset {} must be from 100 to 18000 in steps of 100.",
                index + 1
            )));
        }
        if dpi == 100 {
            return Ok(0);
        }
        let mut encoded = usize::from(dpi / 100);
        for skipped in SKIPPED_SENSITIVITY {
            if encoded < usize::from(skipped) {
                break;
            }
            encoded += 1;
        }
        u8::try_from(encoded).map_err(|_| invalid("DPI encoding overflow."))
    }

    fn battery(&mut self) -> Result<BatteryInfo> {
        if !self.wireless() {
            return Err(unsupported(
                "Battery status is only available on Aerox 3 Wireless models.",
            ));
        }
        let receiver = self.receiver();
        let response = self.send(&[0x92], true)?;
        let command = if receiver { 0xd2 } else { 0x92 };
        let offset = usize::from(response.len() >= 3 && response[0] == 0 && response[1] == command);
        if response.len().saturating_sub(offset) < 2 {
            return Err(OpenSeriesError::Protocol(format!(
                "Device returned a short battery response ({} bytes).",
                response.len()
            )));
        }
        let raw = response[offset + 1];
        let step = raw & 0x7f;
        if !(1..=21).contains(&step) {
            return Ok(BatteryInfo {
                level_percentage: 0,
                status: BatteryStatus::Disconnected,
            });
        }
        let level = u16::from(step - 1) * 5;
        let status = if raw & 0x80 != 0 {
            BatteryStatus::Charging
        } else if level == 100 {
            BatteryStatus::Charged
        } else {
            BatteryStatus::Discharging
        };
        Ok(BatteryInfo {
            level_percentage: level,
            status,
        })
    }
}

impl DeviceProtocol for Aerox3 {
    fn id(&self) -> &str {
        &self.base.identity.id
    }

    fn name(&self) -> &str {
        match self.base.identity.product_id {
            0x1836 => "SteelSeries Aerox 3",
            0x1878 | 0x187a => "SteelSeries Aerox 3 Wireless CS2 Dragon Lore Edition",
            _ => "SteelSeries Aerox 3 Wireless",
        }
    }

    fn product_id(&self) -> u16 {
        self.base.identity.product_id
    }

    fn supported_features(&self) -> Capabilities {
        Capabilities::MOUSE_SENSITIVITY
            | Capabilities::POLLING_RATE
            | Capabilities::ILLUMINATION
            | if self.wireless() {
                Capabilities::BATTERY_STATUS | Capabilities::SLEEP_TIMER
            } else {
                Capabilities::empty()
            }
    }
}

impl MouseProtocol for Aerox3 {
    fn sensitivity_info(&self) -> Result<MouseSensitivityInfo> {
        Ok(if self.wireless() {
            MouseSensitivityInfo {
                minimum: 100,
                maximum: 18_000,
                step: 100,
                maximum_preset_count: 5,
            }
        } else {
            MouseSensitivityInfo {
                minimum: 200,
                maximum: 8_500,
                step: 100,
                maximum_preset_count: 5,
            }
        })
    }

    fn supported_polling_rates(&self) -> Result<&'static [u16]> {
        Ok(&POLLING_RATES)
    }

    fn supported_illumination_zones(&self) -> Result<&'static [MouseZone]> {
        Ok(&AEROX_3_ZONES)
    }

    fn set_sensitivity(&mut self, values: &[u16]) -> Result<()> {
        if !(1..=5).contains(&values.len()) {
            return Err(invalid("Between one and five DPI presets are required."));
        }
        let wireless = self.wireless();
        let mut command = vec![0x2d, values.len() as u8, if wireless { 0 } else { 1 }];
        for (index, dpi) in values.iter().copied().enumerate() {
            command.push(if wireless {
                Self::encode_air(dpi, index)?
            } else {
                if !(200..=8_500).contains(&dpi) || !dpi.is_multiple_of(100) {
                    return Err(invalid(format!(
                        "DPI preset {} must be from 200 to 8500 in steps of 100.",
                        index + 1
                    )));
                }
                CORE_SENSITIVITY[usize::from((dpi - 200) / 100)]
            });
        }
        self.send_and_save(&command)
    }

    fn set_polling_rate(&mut self, rate: u16) -> Result<()> {
        let encoded = match (rate, self.wireless()) {
            (125, true) => 3,
            (250, true) => 2,
            (500, true) => 1,
            (1000, true) => 0,
            (125, false) => 4,
            (250, false) => 3,
            (500, false) => 2,
            (1000, false) => 1,
            _ => return Err(invalid("Polling rate must be 125, 250, 500, or 1000 Hz.")),
        };
        self.send_and_save(&[0x2b, encoded])
    }

    fn set_illumination(
        &mut self,
        zone: MouseZone,
        color: RgbColor,
        persistence: Persistence,
    ) -> Result<()> {
        if !AEROX_3_ZONES.contains(&zone) {
            return Err(invalid(
                "Aerox 3 lighting zone must be top, middle, or bottom.",
            ));
        }
        let mut command = if self.wireless() {
            vec![0x21, 1, zone as u8]
        } else {
            match zone {
                MouseZone::Top => vec![0x21, 1],
                MouseZone::Middle => vec![0x21, 2, 0, 0, 0],
                MouseZone::Bottom => vec![0x21, 4, 0, 0, 0, 0, 0, 0],
                _ => unreachable!(),
            }
        };
        command.extend([color.red, color.green, color.blue]);
        if persistence == Persistence::Save {
            self.send_and_save(&command)
        } else {
            self.send(&command, self.receiver()).map(drop)
        }
    }

    fn set_sleep_timer(&mut self, minutes: u8) -> Result<()> {
        if !self.wireless() {
            return Err(unsupported(
                "Sleep timer is only available on Aerox 3 Wireless models.",
            ));
        }
        if minutes > 20 {
            return Err(invalid("Sleep timer must be between 0 and 20 minutes."));
        }
        let ms = u32::from(minutes) * 60_000;
        let bytes = ms.to_le_bytes();
        self.send_and_save(&[0x29, bytes[0], bytes[1], bytes[2]])
    }

    fn get_battery(&mut self) -> Result<BatteryInfo> {
        self.battery()
    }
}