项目需要,需接入操纵杆、油门外部控制设备,以支持太空漫游相关控制操作。

0 概览

设备为罗技的X56 H.O.T.A.S.。分为油门和操纵杆两个设备。

1 尝试

之前并未接触过这类设备,首先查到的方向是JoyStick。因为仿真服务是C++,但我对C++并不是太熟。故找了一下Python相关的读取代码。

然后发现pygame有封装好的相关模块,名称即为joystick 。示例代码: https://github.com/pygame/pygame/blob/main/examples/joystick.py

不过在此之前,找到了另外一个测试代码:https://github.com/denilsonsa/pygame-joystick-test

最右侧设备为之前找的一个模拟器,仓库地址:https://github.com/Cleric-K/vJoySerialFeeder

但实际使用起来,总感觉有那么一点点的延迟。所以想着在找找其他的。

2 继续尝试

然后找到了Rust的 https://crates.io/crates/gilrs 库。连接设备,可以接收到事件,但有个很大的问题,就是这个库主要目标应该是为游戏手柄,而不是这种操作杆。

而且示例代码无法读取gamepad信息,不过x56应该不能算是游戏手柄。这就导致无法识别设备,应该要区分油门操纵杆,代码中没找到相应的接口去获取设备ID等数据。

3 再次尝试

然后就发现了。SDL https://www.libsdl.org/

Simple DirectMedia Layer is a cross-platform development library designed to provide low level access to audio, keyboard, mouse, joystick, and graphics hardware via OpenGL and Direct3D.

这个项目是C写的。通过静态库编译或者直接调用DLL应该都可以实现。但对C++不太熟,后续找到了 https://github.com/Rust-SDL2/rust-sdl2

4 一些基础知识

4.1 USB设备的VID和PID

  • VID: VID 是供应商识别码(Vendor ID)的缩写,它是由 USB 实施者论坛(USB Implementers Forum,简称 USB-IF)分配给 USB 设备制造商的唯一标识符。
  • PID: PID 是产品识别码(Product ID)的缩写,它是由设备制造商分配给其特定产品的唯一标识符。

4.2 SDL2中 GUID

SDL2 中的 GUID 是一个全局唯一标识符,用于标识游戏手柄。它是一个 128 位的数字,通常以十六进制表示。

示例GUID: 030017173807000021a2000000000000 其中 VID: 0738 PID: 2221 。由此我们可以区分出哪个设备发出的信号(事件Event)

4.3 轴、球、苦力帽、按钮

  • 轴: Axis 通常表示在一定范围内可连续变化的输入维度。
  • 球: Ball 一般在一些老式的游戏设备上会有轨迹球。(老式鼠标?)
  • 苦力帽: Hat 也称为方向键或者POV(Point Of View) 通常可以向八个方向推动的开关。上下左右,以及相邻的两个方向的组合。
  • 按钮: 最常见的输入设备。提供离散输入,通常只有按下和未按下两种状态。

X56设备中,很多类似苦力帽的按钮,被识别为,其实就是两个轴组成了一个苦力帽。可用有八个方向上的输入变化。

4.4 SDL中 游戏设备相关事件

// SDL2-2.30.10/include/SDL_events.h

/* Joystick events */
SDL_JOYAXISMOTION  = 0x600, /**< Joystick axis motion */
SDL_JOYBALLMOTION,          /**< Joystick trackball motion */
SDL_JOYHATMOTION,           /**< Joystick hat position change */
SDL_JOYBUTTONDOWN,          /**< Joystick button pressed */
SDL_JOYBUTTONUP,            /**< Joystick button released */
SDL_JOYDEVICEADDED,         /**< A new joystick has been inserted into the system */
SDL_JOYDEVICEREMOVED,       /**< An opened joystick has been removed */
SDL_JOYBATTERYUPDATED,      /**< Joystick battery level change */

4.5 如何区分设备及事件来源

在事件Event中,能获取到哪个设备ID发出的事件,在不同的事件类型中也会有相应的按钮ID。

设备ID在SDL_JOYDEVICEADDED设备添加时就能获取到,根据该ID可以读取设备的GUID等信息。

而按钮的ID,根据实际操作,看响应即可定位。

5 完整流程

初始化SDL → 监听事件 → 一般事件 进行响应处理
        设备添加/移除事件
        设备 添加/移除 集合

5.1 GUID解析

/*
 * 解析GUID 获取VID和PID
 *
 * GUID: 030017173807000021a2000000000000
 * VID: 0738
 * PID: A221
 * 返回值: (VID, PID) 大写
 */
pub fn parse_guid(guid_str: &str) -> (String, String) {
    let vid = format!("{}{}", &guid_str[10..12], &guid_str[8..10]);
    let pid = format!("{}{}", &guid_str[18..20], &guid_str[16..18]);

    (vid.to_uppercase(), pid.to_uppercase())
}

#[cfg(test)]
mod test {

    #[test]
    fn test_parse_guid() {
        let guid = super::parse_guid("03003187380700002122000000000000");
        assert_eq!(guid, ("0738".to_string(), "2221".to_string()));
    }
}

5.2 完整流程

use sdl2::joystick::{Guid, Joystick};
use sdl2::JoystickSubsystem;

mod utils;

pub struct JoyDevice {
    pub id: u32,
    pub name: String,
    pub guid: Guid,
    pub joystick: Joystick,
}

impl JoyDevice {
    pub fn new(joystick: Joystick) -> Self {
        Self {
            id: joystick.instance_id(),
            name: joystick.name(),
            guid: joystick.guid(),
            joystick,
        }
    }
}

// 读取游戏设备
fn read_joy_device(ins_id: u32, js: &JoystickSubsystem) -> JoyDevice {
    if let Ok(joystick) = js.open(ins_id) {
        JoyDevice::new(joystick)
    } else {
        panic!("Failed to open joystick ID {}", ins_id);
    }
}

// 设备GUID
const GUIDS: &[&str] = &[
    "03003187380700002122000000000000", // 摇杆
    "030017173807000021a2000000000000", // 油门
];

fn main() -> Result<(), String> {
    let sdl_context = sdl2::init()?;

    let joystick_subsystem = sdl_context.joystick()?;

    // 存储所有的游戏设备
    let mut joysticks: Vec<JoyDevice> = Vec::new();

    let mut event_pump = sdl_context.event_pump()?;

    for event in event_pump.wait_iter() {
        use sdl2::event::Event;

        match event {
            Event::JoyAxisMotion {
                which,
                axis_idx,
                value,
                ..
            } => {
                println!("Device {}: Axis {} moved to {}", which, axis_idx, value);
            }
            Event::JoyButtonDown {
                which, button_idx, ..
            } => {
                println!("Device {}: Button {} pressed", which, button_idx);
            }
            Event::JoyButtonUp {
                which, button_idx, ..
            } => {
                println!("Device {}: Button {} released", which, button_idx);
            }
            Event::JoyHatMotion {
                which,
                hat_idx,
                state,
                ..
            } => {
                println!("Device {}: Hat {} moved to {:?}", which, hat_idx, state);
            }
            Event::JoyDeviceRemoved { which, .. } => {
                joysticks.iter().for_each(|joy| {
                    if joy.id == which {
                        println!("Device ({}){} removed", joy.id, joy.name);
                    }
                });
                joysticks.retain(|joy: &JoyDevice| joy.id != which);
            }
            Event::JoyDeviceAdded { which, .. } => {
                let joy_device = read_joy_device(which, &joystick_subsystem);
                if GUIDS.contains(&joy_device.guid.to_string().as_str()) {
                    println!("Device ({}){} added", joy_device.id, joy_device.name);
                    joysticks.push(joy_device);
                }
            }
            Event::Quit { .. } => break,

            // 其他事件
            oe => {
                println!("Other event: {:?}", oe);
            }
        }
    }

    Ok(())
}

6 后续

既然Rust能写出来,C++应该也不是问题。本打算将代码封装成一个服务,但后续做三维开发的同事说他们来接入,然后就没我啥活了。给他们发了一下SDL C++的代码。

7 Gist

https://gist.github.com/lpe234/9c5a2118a7fa0349b69d03e87683df46