From ea0a35840dbdace39e8881aa850e304b4abc8dcb Mon Sep 17 00:00:00 2001 From: Oscar Molnar Date: Fri, 3 Oct 2025 23:55:43 +0100 Subject: [PATCH] Add vendor control transfer support for UNI HUB SL v1.2 --- Cargo.lock | 33 ++++++- Cargo.toml | 1 + src/devices/control_transfer.rs | 114 +++++++++++++++++++++++ src/devices/mod.rs | 159 ++++++++++++++++++++++++-------- 4 files changed, 264 insertions(+), 43 deletions(-) create mode 100644 src/devices/control_transfer.rs diff --git a/Cargo.lock b/Cargo.lock index aeb2ef1..289d73f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -182,6 +182,18 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -312,6 +324,16 @@ dependencies = [ "serde", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -424,10 +446,11 @@ checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "uni-sync" -version = "0.2.0" +version = "0.3.1" dependencies = [ "config", "hidapi", + "rusb", "serde", "serde_derive", "serde_json", @@ -439,6 +462,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 75b0751..4c4fd8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT" [dependencies] config = "0.13.3" hidapi = "1.4.1-3" +rusb = "0.9.4" serde = "1.0.171" serde_derive = "1.0.174" serde_json = "1.0.103" diff --git a/src/devices/control_transfer.rs b/src/devices/control_transfer.rs new file mode 100644 index 0000000..273d51d --- /dev/null +++ b/src/devices/control_transfer.rs @@ -0,0 +1,114 @@ +use rusb::{Context, DeviceHandle, UsbContext}; +use std::{thread, time::Duration}; + +const VENDOR_REQUEST_TYPE: u8 = 0x40; +const VENDOR_REQUEST: u8 = 128; +const TIMEOUT_MS: u64 = 1000; +const INTER_CMD_DELAY_MS: u64 = 100; + +pub struct VendorDevice { + handle: DeviceHandle, +} + +impl VendorDevice { + pub fn open(vid: u16, pid: u16, serial: &str) -> Result { + let context = Context::new()?; + + for device in context.devices()?.iter() { + let device_desc = device.device_descriptor()?; + + if device_desc.vendor_id() == vid && device_desc.product_id() == pid { + let handle = device.open()?; + + if let Ok(sn) = handle.read_serial_number_string_ascii(&device_desc) { + if sn == serial { + handle.set_auto_detach_kernel_driver(true).ok(); + + if let Err(e) = handle.claim_interface(0) { + eprintln!("Failed to claim interface 0: {:?}", e); + return Err(e); + } + + return Ok(VendorDevice { handle }); + } + } + } + } + + Err(rusb::Error::NoDevice) + } + + pub fn setup_channel(&self, channel_id: u8) -> Result<(), rusb::Error> { + let channel_mask: u16 = 0x10 << channel_id; + + let mut payload = [0u8; 16]; + payload[2] = (channel_mask & 0xFF) as u8; + payload[3] = ((channel_mask >> 8) & 0xFF) as u8; + payload[15] = 0x01; + + self.handle.write_control( + VENDOR_REQUEST_TYPE, + VENDOR_REQUEST, + 0, + 0xe020, + &payload, + Duration::from_millis(TIMEOUT_MS) + )?; + + Ok(()) + } + + pub fn set_channel_speed(&self, channel_id: u8, rpm: u16) -> Result<(), rusb::Error> { + let windex: u16 = 0xd8a0 + (channel_id as u16 * 2); + + let payload: [u8; 2] = [ + (rpm & 0xFF) as u8, + ((rpm >> 8) & 0xFF) as u8 + ]; + + self.handle.write_control( + VENDOR_REQUEST_TYPE, + VENDOR_REQUEST, + 0, + windex, + &payload, + Duration::from_millis(TIMEOUT_MS) + )?; + + Ok(()) + } + + pub fn commit_channel(&self, channel_id: u8) -> Result<(), rusb::Error> { + let windex: u16 = 0xd890 + channel_id as u16; + let payload: [u8; 1] = [0x01]; + + self.handle.write_control( + VENDOR_REQUEST_TYPE, + VENDOR_REQUEST, + 0, + windex, + &payload, + Duration::from_millis(TIMEOUT_MS) + )?; + + Ok(()) + } + + pub fn set_speed_with_delays(&self, channel_id: u8, rpm: u16) -> Result<(), rusb::Error> { + self.setup_channel(channel_id)?; + thread::sleep(Duration::from_millis(INTER_CMD_DELAY_MS)); + + self.set_channel_speed(channel_id, rpm)?; + thread::sleep(Duration::from_millis(INTER_CMD_DELAY_MS)); + + self.commit_channel(channel_id)?; + + Ok(()) + } +} + +impl Drop for VendorDevice { + fn drop(&mut self) { + let _ = self.handle.release_interface(0); + } +} diff --git a/src/devices/mod.rs b/src/devices/mod.rs index 2f10c88..dc0ee40 100644 --- a/src/devices/mod.rs +++ b/src/devices/mod.rs @@ -2,6 +2,8 @@ use serde_derive::{Deserialize, Serialize}; use hidapi::{self, HidDevice}; use std::{thread, time}; +mod control_transfer; + #[derive(Serialize, Deserialize, Clone)] pub struct Configs { pub configs: Vec @@ -23,6 +25,36 @@ pub struct Channel { const VENDOR_IDS: [u16; 1] = [ 0x0cf2 ]; const PRODUCT_IDS: [u16; 7] = [ 0x7750, 0xa100, 0xa101, 0xa102, 0xa103, 0xa104, 0xa105 ]; +const V1_2_PRODUCT_IDS: [u16; 2] = [ 0x7750, 0xa100 ]; + +enum TransportMode { + HID, + VendorControl, +} + +fn percent_to_rpm(pct: u8, product_id: u16) -> u16 { + let pct = pct.min(100) as u32; + + let (min_rpm, max_rpm) = match product_id { + 0xa100 => (500u32, 1500u32), + 0x7750 => (800u32, 1900u32), + 0xa101 => (800u32, 1900u32), + 0xa102 => (200u32, 2100u32), + 0xa103 | 0xa105 | 0xa104 => (250u32, 2000u32), + _ => (800u32, 1900u32), + }; + + (min_rpm + ((max_rpm - min_rpm) * pct / 100)) as u16 +} + +fn detect_transport_mode(product_id: u16) -> TransportMode { + if V1_2_PRODUCT_IDS.contains(&product_id) { + TransportMode::VendorControl + } else { + TransportMode::HID + } +} + pub fn run(mut existing_configs: Configs) -> Configs { let mut default_channels: Vec = Vec::new(); @@ -40,7 +72,6 @@ pub fn run(mut existing_configs: Configs) -> Configs { }; for hiddevice in api.device_list() { - if VENDOR_IDS.contains(&hiddevice.vendor_id()) && PRODUCT_IDS.contains(&hiddevice.product_id()) { let serial_number: &str = match hiddevice.serial_number() { @@ -96,56 +127,102 @@ pub fn run(mut existing_configs: Configs) -> Configs { thread::sleep(time::Duration::from_millis(200)); - for x in 0..channels.len() { - - // Disable Sync to fan header - let mut channel_byte = 0x10 << x; - - if channels[x].mode == "PWM" { - channel_byte = channel_byte | 0x1 << x; + let transport_mode = detect_transport_mode(hiddevice.product_id()); + + match transport_mode { + TransportMode::VendorControl => { + apply_vendor_control_settings( + hiddevice.vendor_id(), + hiddevice.product_id(), + serial_number, + &channels, + &hid, + &hiddevice + ); + } + TransportMode::HID => { + println!("Using HID mode for device"); + apply_hid_settings(&hid, &hiddevice, &channels); } + } + } + } + return existing_configs; +} - let _ = match &hiddevice.product_id() { - 0xa100|0x7750 => hid.write(&[224, 16, 49, channel_byte]), // SL - 0xa101 => hid.write(&[224, 16, 66, channel_byte]), // AL - 0xa102 => hid.write(&[224, 16, 98, channel_byte]), // SLI - 0xa103|0xa105 => hid.write(&[224, 16, 98, channel_byte]), // SLv2 - 0xa104 => hid.write(&[224, 16, 98, channel_byte]), // ALv2 - _ => hid.write(&[224, 16, 49, channel_byte]), // SL - }; +fn apply_vendor_control_settings( + vendor_id: u16, + product_id: u16, + serial_number: &str, + channels: &Vec, + hid: &HidDevice, + hiddevice: &hidapi::DeviceInfo +) { + println!("Using VendorControl mode for device"); + + if let Ok(vendor_device) = control_transfer::VendorDevice::open( + vendor_id, + product_id, + serial_number + ) { + for x in 0..channels.len() { + if channels[x].mode == "Manual" { + let speed_pct = channels[x].speed.min(100) as u8; + let rpm = percent_to_rpm(speed_pct, product_id); + + if let Err(e) = vendor_device.set_speed_with_delays(x as u8, rpm) { + eprintln!("Failed to set channel {} speed: {:?}", x, e); + } else { + println!("Set channel {} to {}% ({} RPM)", x, speed_pct, rpm); + } + } + } + } else { + eprintln!("Failed to open device via VendorControl, falling back to HID"); + apply_hid_settings(hid, hiddevice, channels); + } +} - // Avoid Race Condition - thread::sleep(time::Duration::from_millis(200)); +fn apply_hid_settings(hid: &HidDevice, hiddevice: &hidapi::DeviceInfo, channels: &Vec) { + for x in 0..channels.len() { + let mut channel_byte = 0x10 << x; - // Set Channel Speed - if channels[x].mode == "Manual" { + if channels[x].mode == "PWM" { + channel_byte = channel_byte | 0x1 << x; + } - let mut speed = channels[x].speed as f64; - if speed > 100.0 { speed = 100.0 } + let _ = match &hiddevice.product_id() { + 0xa100|0x7750 => hid.write(&[224, 16, 49, channel_byte]), // SL + 0xa101 => hid.write(&[224, 16, 66, channel_byte]), // AL + 0xa102 => hid.write(&[224, 16, 98, channel_byte]), // SLI + 0xa103|0xa105 => hid.write(&[224, 16, 98, channel_byte]), // SLv2 + 0xa104 => hid.write(&[224, 16, 98, channel_byte]), // ALv2 + _ => hid.write(&[224, 16, 49, channel_byte]), // SL + }; - let speed_800_1900: u8 = ((800.0 + (11.0 * speed)) as usize / 19).try_into().unwrap(); - let speed_250_2000: u8 = ((250.0 + (17.5 * speed)) as usize / 20).try_into().unwrap(); - let speed_200_2100: u8 = ((200.0 + (19.0 * speed)) as usize / 21).try_into().unwrap(); + // Avoid Race Condition + thread::sleep(time::Duration::from_millis(200)); - let _ = match &hiddevice.product_id() { - 0xa100|0x7750 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // SL - 0xa101 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // AL - 0xa102 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_200_2100]), // SLI - 0xa103|0xa105 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_250_2000]), // SLv2 - 0xa104 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_250_2000]), // ALv2 - _ => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // SL - }; + // Set Channel Speed + if channels[x].mode == "Manual" { + let mut speed = channels[x].speed as f64; + if speed > 100.0 { speed = 100.0 } - // Avoid Race Condition - thread::sleep(time::Duration::from_millis(100)); + let speed_800_1900: u8 = ((800.0 + (11.0 * speed)) as usize / 19).try_into().unwrap(); + let speed_250_2000: u8 = ((250.0 + (17.5 * speed)) as usize / 20).try_into().unwrap(); + let speed_200_2100: u8 = ((200.0 + (19.0 * speed)) as usize / 21).try_into().unwrap(); - } - } + let _ = match &hiddevice.product_id() { + 0xa100|0x7750 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // SL + 0xa101 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // AL + 0xa102 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_200_2100]), // SLI + 0xa103|0xa105 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_250_2000]), // SLv2 + 0xa104 => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_250_2000]), // ALv2 + _ => hid.write(&[224, (x+32).try_into().unwrap(), 0, speed_800_1900]), // SL + }; + // Avoid Race Condition + thread::sleep(time::Duration::from_millis(100)); } - } - - return existing_configs; - }