diff --git a/rootfs/usr/share/inputplumber/devices/60-switch_pro.yaml b/rootfs/usr/share/inputplumber/devices/60-switch_pro.yaml index a9a264b..b931fec 100644 --- a/rootfs/usr/share/inputplumber/devices/60-switch_pro.yaml +++ b/rootfs/usr/share/inputplumber/devices/60-switch_pro.yaml @@ -19,13 +19,17 @@ matches: [] # One or more source devices to combine into a single virtual device. The events # from these devices will be watched and translated according to the key map. source_devices: - - group: gamepad - evdev: - name: Nintendo Co., Ltd. Pro Controller - handler: event* + #- group: gamepad + # evdev: + # name: Nintendo Co., Ltd. Pro Controller + # handler: event* #- group: imu # evdev: # name: Nintendo Co., Ltd. Pro Controller (IMU) + - group: gamepad + hidraw: + vendor_id: 0x057e + product_id: 0x2009 # The target input device(s) that the virtual device profile can use target_devices: diff --git a/src/drivers/mod.rs b/src/drivers/mod.rs index d08575a..c1bcabe 100644 --- a/src/drivers/mod.rs +++ b/src/drivers/mod.rs @@ -4,3 +4,4 @@ pub mod iio_imu; pub mod lego; pub mod opineo; pub mod steam_deck; +pub mod switch; diff --git a/src/drivers/switch/driver.rs b/src/drivers/switch/driver.rs new file mode 100644 index 0000000..d3fb7d6 --- /dev/null +++ b/src/drivers/switch/driver.rs @@ -0,0 +1,107 @@ +use std::{error::Error, ffi::CString}; + +use hidapi::HidDevice; +use packed_struct::prelude::*; + +use super::{ + event::Event, + hid_report::{PackedInputDataReport, ReportType}, +}; + +// Hardware IDs +pub const VID: u16 = 0x057e; +pub const PID: u16 = 0x2009; + +/// Size of the HID packet +const PACKET_SIZE: usize = 64 + 35; + +/// Nintendo Switch input driver +pub struct Driver { + state: Option, + device: HidDevice, +} + +impl Driver { + pub fn new(path: String) -> Result> { + let path = CString::new(path)?; + let api = hidapi::HidApi::new()?; + let device = api.open_path(&path)?; + let info = device.get_device_info()?; + if info.vendor_id() != VID || info.product_id() != PID { + return Err("Device '{path}' is not a Switch Controller".into()); + } + + Ok(Self { + device, + state: None, + }) + } + + /// Poll the device and read input reports + pub fn poll(&mut self) -> Result, Box> { + log::debug!("Polling device"); + + // Read data from the device into a buffer + let mut buf = [0; PACKET_SIZE]; + let bytes_read = self.device.read(&mut buf[..])?; + + // Handle the incoming input report + let events = self.handle_input_report(buf, bytes_read)?; + + Ok(events) + } + + /// Unpacks the buffer into a [PackedInputDataReport] structure and updates + /// the internal gamepad state + fn handle_input_report( + &mut self, + buf: [u8; PACKET_SIZE], + bytes_read: usize, + ) -> Result, Box> { + // Read the report id + let report_id = buf[0]; + let report_type = ReportType::try_from(report_id)?; + log::debug!("Received report: {report_type:?}"); + + let slice = &buf[..bytes_read]; + match report_type { + ReportType::CommandOutputReport => todo!(), + ReportType::McuUpdateOutputReport => todo!(), + ReportType::BasicOutputReport => todo!(), + ReportType::McuOutputReport => todo!(), + ReportType::AttachmentOutputReport => todo!(), + ReportType::CommandInputReport => todo!(), + ReportType::McuUpdateInputReport => todo!(), + ReportType::BasicInputReport => { + let sized_buf = slice.try_into()?; + let input_report = PackedInputDataReport::unpack(sized_buf)?; + + // Print input report for debugging + log::debug!("--- Input report ---"); + log::debug!("{input_report}"); + log::debug!("---- End Report ----"); + } + ReportType::McuInputReport => todo!(), + ReportType::AttachmentInputReport => todo!(), + ReportType::Unused1 => todo!(), + ReportType::GenericInputReport => todo!(), + ReportType::OtaEnableFwuReport => todo!(), + ReportType::OtaSetupReadReport => todo!(), + ReportType::OtaReadReport => todo!(), + ReportType::OtaWriteReport => todo!(), + ReportType::OtaEraseReport => todo!(), + ReportType::OtaLaunchReport => todo!(), + ReportType::ExtGripOutputReport => todo!(), + ReportType::ExtGripInputReport => todo!(), + ReportType::Unused2 => todo!(), + } + + // Update the state + //let old_state = self.update_state(input_report); + + // Translate the state into a stream of input events + //let events = self.translate(old_state); + + Ok(vec![]) + } +} diff --git a/src/drivers/switch/event.rs b/src/drivers/switch/event.rs new file mode 100644 index 0000000..5f3d473 --- /dev/null +++ b/src/drivers/switch/event.rs @@ -0,0 +1 @@ +pub struct Event {} diff --git a/src/drivers/switch/hid_report.rs b/src/drivers/switch/hid_report.rs new file mode 100644 index 0000000..aa0fc5d --- /dev/null +++ b/src/drivers/switch/hid_report.rs @@ -0,0 +1,205 @@ +//! Sources: +//! - https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/bluetooth_hid_notes.md +//! - https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c +//! - https://switchbrew.org/w/index.php?title=Joy-Con +use packed_struct::prelude::*; + +#[derive(PrimitiveEnum_u8, Clone, Copy, PartialEq, Debug)] +pub enum ReportType { + CommandOutputReport = 0x01, + McuUpdateOutputReport = 0x03, + BasicOutputReport = 0x10, + McuOutputReport = 0x11, + AttachmentOutputReport = 0x12, + CommandInputReport = 0x21, + McuUpdateInputReport = 0x23, + BasicInputReport = 0x30, + McuInputReport = 0x31, + AttachmentInputReport = 0x32, + Unused1 = 0x33, + GenericInputReport = 0x3F, + OtaEnableFwuReport = 0x70, + OtaSetupReadReport = 0x71, + OtaReadReport = 0x72, + OtaWriteReport = 0x73, + OtaEraseReport = 0x74, + OtaLaunchReport = 0x75, + ExtGripOutputReport = 0x80, + ExtGripInputReport = 0x81, + Unused2 = 0x82, +} + +impl TryFrom for ReportType { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(Self::CommandOutputReport), + 0x03 => Ok(Self::McuUpdateOutputReport), + 0x10 => Ok(Self::BasicOutputReport), + 0x11 => Ok(Self::McuOutputReport), + 0x12 => Ok(Self::AttachmentOutputReport), + 0x21 => Ok(Self::CommandInputReport), + 0x23 => Ok(Self::McuUpdateInputReport), + 0x30 => Ok(Self::BasicInputReport), + 0x31 => Ok(Self::McuInputReport), + 0x32 => Ok(Self::AttachmentInputReport), + 0x33 => Ok(Self::Unused1), + 0x3F => Ok(Self::GenericInputReport), + 0x70 => Ok(Self::OtaEnableFwuReport), + 0x71 => Ok(Self::OtaSetupReadReport), + 0x72 => Ok(Self::OtaReadReport), + 0x73 => Ok(Self::OtaWriteReport), + 0x74 => Ok(Self::OtaEraseReport), + 0x75 => Ok(Self::OtaLaunchReport), + 0x80 => Ok(Self::ExtGripOutputReport), + 0x81 => Ok(Self::ExtGripInputReport), + 0x82 => Ok(Self::Unused2), + _ => Err("Invalid report type"), + } + } +} + +#[derive(PrimitiveEnum_u8, Clone, Copy, PartialEq, Debug)] +pub enum BatteryLevel { + Empty = 0, + Critical = 1, + Low = 2, + Medium = 3, + Full = 4, +} + +#[derive(PackedStruct, Debug, Copy, Clone, PartialEq)] +#[packed_struct(bit_numbering = "msb0", size_bytes = "1")] +pub struct BatteryConnection { + /// Battery level. 8=full, 6=medium, 4=low, 2=critical, 0=empty. LSB=Charging. + #[packed_field(bits = "0..=2", ty = "enum")] + pub battery_level: BatteryLevel, + #[packed_field(bits = "3")] + pub charging: bool, + /// Connection info. (con_info >> 1) & 3 - 3=JC, 0=Pro/ChrGrip. con_info & 1 - 1=Switch/USB powered. + #[packed_field(bits = "4..=7")] + pub conn_info: u8, +} + +#[derive(PackedStruct, Debug, Copy, Clone, PartialEq)] +#[packed_struct(bit_numbering = "msb0", size_bytes = "3")] +pub struct ButtonStatus { + // byte 0 (Right) + #[packed_field(bits = "7")] + pub y: bool, + #[packed_field(bits = "6")] + pub x: bool, + #[packed_field(bits = "5")] + pub b: bool, + #[packed_field(bits = "4")] + pub a: bool, + #[packed_field(bits = "3")] + pub sr_right: bool, + #[packed_field(bits = "2")] + pub sl_right: bool, + #[packed_field(bits = "1")] + pub r: bool, + #[packed_field(bits = "0")] + pub zr: bool, + + // byte 1 (Shared) + #[packed_field(bits = "15")] + pub minus: bool, + #[packed_field(bits = "14")] + pub plus: bool, + #[packed_field(bits = "13")] + pub r_stick: bool, + #[packed_field(bits = "12")] + pub l_stick: bool, + #[packed_field(bits = "11")] + pub home: bool, + #[packed_field(bits = "10")] + pub capture: bool, + #[packed_field(bits = "9")] + pub _unused: bool, + #[packed_field(bits = "8")] + pub charging_grip: bool, + + // byte 2 (Left) + #[packed_field(bits = "23")] + pub down: bool, + #[packed_field(bits = "22")] + pub up: bool, + #[packed_field(bits = "21")] + pub right: bool, + #[packed_field(bits = "20")] + pub left: bool, + #[packed_field(bits = "19")] + pub sr_left: bool, + #[packed_field(bits = "18")] + pub sl_left: bool, + #[packed_field(bits = "17")] + pub l: bool, + #[packed_field(bits = "16")] + pub zl: bool, +} + +#[derive(PackedStruct, Debug, Copy, Clone, PartialEq)] +#[packed_struct(bit_numbering = "msb0", size_bytes = "3")] +pub struct StickData { + /// Analog stick X-axis + #[packed_field(bits = "0..=11", endian = "msb")] + pub y: Integer>, + /// Analog stick Y-axis + #[packed_field(bits = "12..=23", endian = "msb")] + pub x: Integer>, +} + +/// The 6-Axis data is repeated 3 times. On Joy-con with a 15ms packet push, +/// this is translated to 5ms difference sampling. E.g. 1st sample 0ms, 2nd 5ms, +/// 3rd 10ms. Using all 3 samples let you have a 5ms precision instead of 15ms. +#[derive(PackedStruct, Debug, Copy, Clone, PartialEq)] +#[packed_struct(bit_numbering = "msb0", size_bytes = "12")] +pub struct ImuData { + #[packed_field(bytes = "0..=1", endian = "lsb")] + pub accel_x: Integer>, + #[packed_field(bytes = "2..=3", endian = "lsb")] + pub accel_y: Integer>, + #[packed_field(bytes = "4..=5", endian = "lsb")] + pub accel_z: Integer>, + #[packed_field(bytes = "6..=7", endian = "lsb")] + pub gyro_x: Integer>, + #[packed_field(bytes = "8..=9", endian = "lsb")] + pub gyro_y: Integer>, + #[packed_field(bytes = "10..=11", endian = "lsb")] + pub gyro_z: Integer>, +} + +#[derive(PackedStruct, Debug, Copy, Clone, PartialEq)] +#[packed_struct(bit_numbering = "msb0", size_bytes = "64")] +pub struct PackedInputDataReport { + // byte 0-2 + /// Input report ID + #[packed_field(bytes = "0", ty = "enum")] + pub id: ReportType, + /// Timer. Increments very fast. Can be used to estimate excess Bluetooth latency. + #[packed_field(bytes = "1")] + pub timer: u8, + /// Battery and connection information + #[packed_field(bytes = "2")] + pub info: BatteryConnection, + + // byte 3-5 + /// Button status + #[packed_field(bytes = "3..=5")] + pub buttons: ButtonStatus, + + // byte 6-11 + /// Left analog stick + #[packed_field(bytes = "6..=8")] + pub left_stick: StickData, + /// Right analog stick + #[packed_field(bytes = "9..=11")] + pub right_stick: StickData, + + // byte 12 + /// Vibrator input report. Decides if next vibration pattern should be sent. + #[packed_field(bytes = "12")] + pub vibrator_report: u8, +} diff --git a/src/drivers/switch/mod.rs b/src/drivers/switch/mod.rs new file mode 100644 index 0000000..10b2e90 --- /dev/null +++ b/src/drivers/switch/mod.rs @@ -0,0 +1,4 @@ +pub mod driver; +pub mod event; +pub mod hid_report; +pub mod report_descriptor; diff --git a/src/drivers/switch/report_descriptor.rs b/src/drivers/switch/report_descriptor.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/input/source/hidraw.rs b/src/input/source/hidraw.rs index 78e7604..6d798bf 100644 --- a/src/input/source/hidraw.rs +++ b/src/input/source/hidraw.rs @@ -3,6 +3,7 @@ pub mod fts3528; pub mod lego; pub mod opineo; pub mod steam_deck; +pub mod switch; use std::{error::Error, time::Duration}; @@ -13,7 +14,7 @@ use crate::{ use self::{ dualsense::DualSenseController, fts3528::Fts3528Touchscreen, lego::LegionController, - opineo::OrangePiNeoTouchpad, steam_deck::DeckController, + opineo::OrangePiNeoTouchpad, steam_deck::DeckController, switch::SwitchController, }; use super::{SourceDriver, SourceDriverOptions}; @@ -26,6 +27,7 @@ enum DriverType { LegionGo, OrangePiNeo, Fts3528Touchscreen, + SwitchProController, } /// [HidRawDevice] represents an input device using the hidraw subsystem. @@ -36,6 +38,7 @@ pub enum HidRawDevice { LegionGo(SourceDriver), OrangePiNeo(SourceDriver), Fts3528Touchscreen(SourceDriver), + SwitchProController(SourceDriver), } impl HidRawDevice { @@ -85,6 +88,11 @@ impl HidRawDevice { let source_device = SourceDriver::new(composite_device, device, device_info); Ok(Self::Fts3528Touchscreen(source_device)) } + DriverType::SwitchProController => { + let device = SwitchController::new(device_info.clone())?; + let source_device = SourceDriver::new(composite_device, device, device_info); + Ok(Self::SwitchProController(source_device)) + } } } @@ -125,6 +133,12 @@ impl HidRawDevice { return DriverType::Fts3528Touchscreen; } + // Nintendo Switch Pro Controller + if vid == drivers::switch::driver::VID && pid == drivers::switch::driver::PID { + log::info!("Detected Nintendo Switch Controller"); + return DriverType::SwitchProController; + } + // Unknown log::warn!("No driver for hidraw interface found. VID: {vid}, PID: {pid}"); DriverType::Unknown diff --git a/src/input/source/hidraw/switch.rs b/src/input/source/hidraw/switch.rs new file mode 100644 index 0000000..7c19b7d --- /dev/null +++ b/src/input/source/hidraw/switch.rs @@ -0,0 +1,50 @@ +use std::{error::Error, fmt::Debug}; + +use crate::{ + drivers::switch::driver::Driver, + input::{ + capability::Capability, + event::native::NativeEvent, + source::{InputError, SourceInputDevice, SourceOutputDevice}, + }, + udev::device::UdevDevice, +}; + +pub struct SwitchController { + driver: Driver, + device_info: UdevDevice, +} + +impl SwitchController { + /// Create a new Switch Controller source device with the given udev + /// device information + pub fn new(device_info: UdevDevice) -> Result> { + let driver = Driver::new(device_info.devnode())?; + + Ok(Self { + driver, + device_info, + }) + } +} + +impl SourceInputDevice for SwitchController { + fn poll(&mut self) -> Result, InputError> { + let _ = self.driver.poll()?; + Ok(vec![]) + } + + fn get_capabilities(&self) -> Result, InputError> { + Ok(vec![]) + } +} + +impl SourceOutputDevice for SwitchController {} + +impl Debug for SwitchController { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SwitchController") + .field("device_info", &self.device_info) + .finish() + } +} diff --git a/src/input/source/mod.rs b/src/input/source/mod.rs index 459cafd..e3de3cb 100644 --- a/src/input/source/mod.rs +++ b/src/input/source/mod.rs @@ -37,7 +37,7 @@ const POLL_RATE: Duration = Duration::from_micros(2500); /// Possible errors for a source device client #[derive(Error, Debug)] pub enum InputError { - #[error("error occurred running device")] + #[error("error occurred running device: `{0}`")] DeviceError(String), } @@ -70,7 +70,7 @@ impl From> for InputError { pub enum OutputError { #[error("behavior is not implemented")] NotImplemented, - #[error("error occurred running device")] + #[error("error occurred running device: `{0}`")] DeviceError(String), } @@ -373,6 +373,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.info(), HidRawDevice::OrangePiNeo(device) => device.info(), HidRawDevice::Fts3528Touchscreen(device) => device.info(), + HidRawDevice::SwitchProController(device) => device.info(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.info(), @@ -394,6 +395,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.info_ref(), HidRawDevice::OrangePiNeo(device) => device.info_ref(), HidRawDevice::Fts3528Touchscreen(device) => device.info_ref(), + HidRawDevice::SwitchProController(device) => device.info_ref(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.info_ref(), @@ -415,6 +417,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.get_id(), HidRawDevice::OrangePiNeo(device) => device.get_id(), HidRawDevice::Fts3528Touchscreen(device) => device.get_id(), + HidRawDevice::SwitchProController(device) => device.get_id(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.get_id(), @@ -436,6 +439,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.client(), HidRawDevice::OrangePiNeo(device) => device.client(), HidRawDevice::Fts3528Touchscreen(device) => device.client(), + HidRawDevice::SwitchProController(device) => device.client(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.client(), @@ -457,6 +461,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.run().await, HidRawDevice::OrangePiNeo(device) => device.run().await, HidRawDevice::Fts3528Touchscreen(device) => device.run().await, + HidRawDevice::SwitchProController(device) => device.run().await, }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.run().await, @@ -478,6 +483,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.get_capabilities(), HidRawDevice::OrangePiNeo(device) => device.get_capabilities(), HidRawDevice::Fts3528Touchscreen(device) => device.get_capabilities(), + HidRawDevice::SwitchProController(device) => device.get_capabilities(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.get_capabilities(), @@ -499,6 +505,7 @@ impl SourceDevice { HidRawDevice::LegionGo(device) => device.get_device_path(), HidRawDevice::OrangePiNeo(device) => device.get_device_path(), HidRawDevice::Fts3528Touchscreen(device) => device.get_device_path(), + HidRawDevice::SwitchProController(device) => device.get_device_path(), }, SourceDevice::Iio(device) => match device { IioDevice::BmiImu(device) => device.get_device_path(), diff --git a/src/input/target/mod.rs b/src/input/target/mod.rs index e544bcf..b1b263a 100644 --- a/src/input/target/mod.rs +++ b/src/input/target/mod.rs @@ -51,7 +51,7 @@ pub mod xbox_series; /// Possible errors for a target device client #[derive(Error, Debug)] pub enum InputError { - #[error("error occurred running device")] + #[error("error occurred running device: `{0}`")] DeviceError(String), } @@ -91,7 +91,7 @@ pub enum OutputError { #[allow(dead_code)] #[error("behavior is not implemented")] NotImplemented, - #[error("error occurred running device")] + #[error("error occurred running device: `{0}`")] DeviceError(String), }