本文完整拆解 Windows XInput 手柄协议:API 结构体、按键位掩码、摇杆/扳机取值范围、Guide 键的隐藏调用方式(ordinal 100),以及实测验证的 XInput 状态 → 16 字节 Xbox BLE HID 报文 映射关系。全部代码基于 Python + ctypes,无需第三方库,已在 Windows 11 + Python 3.14 + Flydigi Direwolf 4(Xbox 360 兼容模式,USB VID=0x045E PID=0x028E)上验证通过。
目录
- 协议总览
- XInput API 层
- XINPUT_GAMEPAD 结构体详解
- 按键位掩码表(wButtons)
- 摇杆与扳机的取值规则
- Guide 键的隐藏入口:XInputGetStateEx
- 16 字节 BLE HID 报文协议(实测验证)
- 完整映射实现代码
- 示例输出
- 验证代码
- 完整 GUI 验证程序
- 参考文献与项目
- 附录:AI 调用协议专用 Prompt
1. 协议总览
Windows 上读取 Xbox 手柄有三条路径,各有边界:
| 读取方式 | 可获得的数据 | Guide 键 | 依赖 |
|---|---|---|---|
| XInput API(本文主线) | 16 个按键 + 2 扳机 + 2 摇杆 | ✅(需 ordinal 100) | 系统自带 DLL |
| Raw Input (WM_INPUT) | 原始 HID 报文字节 | 依赖报文定义 | 无 |
| hidapi / HID 直读 | 原始 HID 报文 | 依赖报文定义 | 常被系统驱动独占而失败 |
关键结论:Windows 把蓝牙 HID 手柄的按键集合绑定给键盘/鼠标类系统驱动后,hidapi read() 直接抛 OSError(实测复现),Raw Input 可以绕过但拿到的是被拆分进 HID 集合的字节流。XInput 是唯一稳定、完整、带 Guide 键的方案,所有上层协议转换都应以 XInput 状态为源。
XInput 状态 → 目标报文的两级结构:
物理按键 → XInput (wButtons / b*Trigger / sThumb*) → 16 字节 BLE HID 报文
2. XInput API 层
XInput 是 Windows 自带的 Xbox 手柄 API,运行时位于 XInput1_4.dll(Win8+)等。最多支持 4 个手柄槽位(索引 0~3)。
2.1 加载与查找手柄
import ctypes
class XINPUT_GAMEPAD(ctypes.Structure):
_fields_ = [
("wButtons", ctypes.c_ushort),
("bLeftTrigger", ctypes.c_ubyte),
("bRightTrigger", ctypes.c_ubyte),
("sThumbLX", ctypes.c_short),
("sThumbLY", ctypes.c_short),
("sThumbRX", ctypes.c_short),
("sThumbRY", ctypes.c_short),
]
class XINPUT_STATE(ctypes.Structure):
_fields_ = [
("dwPacketNumber", ctypes.c_ulong), # 每次状态变化 +1
("Gamepad", XINPUT_GAMEPAD),
]
def load_xinput():
for name in ("XInput1_4.dll", "XInput1_3.dll", "XInput9_1_0.dll"):
try:
return ctypes.WinDLL(name)
except OSError:
continue
raise SystemExit("No XInput DLL found")
XINPUT = load_xinput()
GET_STATE = XINPUT.XInputGetState
GET_STATE.restype = ctypes.c_ulong # ERROR_SUCCESS = 0
GET_STATE.argtypes = [ctypes.c_ulong, ctypes.POINTER(XINPUT_STATE)]
def find_controller():
state = XINPUT_STATE()
for i in range(4):
if GET_STATE(i, ctypes.byref(state)) == 0:
return i
return None
返回值为 Win32 错误码:0(ERRORSUCCESS)成功;1167(ERRORDEVICENOTCONNECTED)无手柄。
2.2 轮询模型
XInput 没有事件回调,标准用法是忙轮询(推荐 5~10ms 间隔,约 100~200Hz)。dwPacketNumber 只在状态变化时递增,可用于判断"有没有新数据",但每次都应调用 XInputGetState 保持连接状态检测。
3. XINPUT_GAMEPAD 结构体详解
| 字段 | C 类型 | 范围 | 含义 |
|---|---|---|---|
wButtons | WORD (u16) | 位掩码 | 所有按键/十字键状态 |
bLeftTrigger | BYTE (u8) | 0~255 | 左扳机模拟量,0=松开 |
bRightTrigger | BYTE (u8) | 0~255 | 右扳机模拟量 |
sThumbLX | SHORT (i16) | -32768~32767 | 左摇杆 X,0=居中 |
sThumbLY | SHORT (i16) | -32768~32767 | 左摇杆 Y,向上为正 |
sThumbRX / sThumbRY | SHORT (i16) | -32768~32767 | 右摇杆 |
注意:sThumb* 理论中心值为 0,但物理摇杆静止时常见 ±256 以内的偏置(实测 LY 静止值 = -256),这是 XInput 固件层量化误差,应用层需用死区过滤。
4. 按键位掩码表(wButtons)
这是协议的核心常量表,与 xinput.h 逐位一致:
XINPUT_GAMEPAD_DPAD_UP = 0x0001
XINPUT_GAMEPAD_DPAD_DOWN = 0x0002
XINPUT_GAMEPAD_DPAD_LEFT = 0x0004
XINPUT_GAMEPAD_DPAD_RIGHT = 0x0008
XINPUT_GAMEPAD_START = 0x0010
XINPUT_GAMEPAD_BACK = 0x0020
XINPUT_GAMEPAD_LEFT_THUMB = 0x0040 # 左摇杆按下 L3
XINPUT_GAMEPAD_RIGHT_THUMB = 0x0080 # 右摇杆按下 R3
XINPUT_GAMEPAD_LEFT_SHOULDER = 0x0100 # LB
XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0200 # RB
XINPUT_GAMEPAD_GUIDE = 0x0400 # Guide/Xbox/Home 键 (仅 Ex 版可读)
XINPUT_GAMEPAD_A = 0x1000
XINPUT_GAMEPAD_B = 0x2000
XINPUT_GAMEPAD_X = 0x4000
XINPUT_GAMEPAD_Y = 0x8000
易错点(本项目实测踩过):XInput 位掩码与 16 字节 HID 协议的位掩码完全不同。例如 XInput 里 A=0x1000,HID 协议里 A=0x01。两套编码绝不能混用。
三个死区/阈值常量(xinput.h)
XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE = 7849 # 左摇杆死区半径
XINPUT_GAMEPAD_RIGHT_THUMB_DEADZONE = 8689 # 右摇杆死区半径
XINPUT_GAMEPAD_TRIGGER_THRESHOLD = 30 # 扳机判定按下的阈值
5. 摇杆与扳机的取值规则
- 摇杆:i16 原始值,X 右为正、Y 上为正;死区内应视为 0
- 扳机:u8 原始值 0~255;某些手柄在未按下时会给 1~5 的底噪,判定"按下"用阈值 30
- 死区处理的标准做法(径向死区,来自 XInput 官方文档):
import math
def apply_deadzone(x, y, dz):
mag = math.hypot(x, y)
if mag <= dz:
return 0, 0, 0.0
clamped = min(mag, 32767)
norm = (clamped - dz) / (32767 - dz) # 0.0 ~ 1.0
return int(x / mag * norm * 32767), int(y / mag * norm * 32767), norm
6. Guide 键的隐藏入口:XInputGetStateEx
XInputGetState 的公开文档不包含 Guide(Xbox/Home)键——wButtons 位 0x0400 永远读不到。真实存在一个未公开导出:XInputGetStateEx,位于 XInput1_4.dll 的 ordinal 100,结构与 XInputGetState 完全一致,但能读到 Guide 键。
def load_get_state_ex(xinput):
try:
fn = xinput[100] # ordinal 100 = XInputGetStateEx
fn.restype = ctypes.c_ulong
fn.argtypes = [ctypes.c_ulong, ctypes.POINTER(XINPUT_STATE)]
return fn, True # True = 支持 Guide 键
except (AttributeError, OSError):
return xinput.XInputGetState, False
实测:本环境 XInput1_4.dll ordinal 100 可用,按住 Home 键时 wButtons 中 0x0400 置位。
7. 16 字节 BLE HID 报文协议(实测验证)
Xbox 手柄在 BLE 模式下通过 HID over GATT(Report UUID 0x2A4D)上报 16 字节输入报告。Windows 将其解析为 XInput 状态;反向映射(XInput → 报文)即 ESP32 等设备直连 GATT 时收到的原始字节。全部 16 字节均为小端序。
7.1 完整字节布局
| 偏移 | 长度 | 字段 | 编码 | 中值/默认 |
|---|---|---|---|---|
| 0–1 | u16 LE | joyLHori 左摇杆 X | u16,中值 0x8000 | 0x8000 |
| 2–3 | u16 LE | joyLVert 左摇杆 Y | u16,中值 0x8000 | 0x8000 |
| 4–5 | u16 LE | joyRHori 右摇杆 X | u16 | 0x8000 |
| 6–7 | u16 LE | joyRVert 右摇杆 Y | u16 | 0x8000 |
| 8–9 | u16 LE | trigLT 左扳机 | 10 位,0~1023,0=松开 | 0 |
| 10–11 | u16 LE | trigRT 右扳机 | 10 位 | 0 |
| 12 | u8 | 十字键帽子值 | 0中/1上/2右上/3右/4右下/5下/6左下/7左/8左上 | 0 |
| 13 | u8 | 动作键位掩码 | A=0x01 B=0x02 X=0x08 Y=0x10 LB=0x40 RB=0x80 | 0 |
| 14 | u8 | 系统键位掩码 | View=0x04 Menu=0x08 Xbox=0x10 LS=0x20 RS=0x40 | 0 |
| 15 | u8 | 保留 | Share=0x01(原装 Xbox Series 手柄) | 0 |
7.2 帽子值(hat)编码
十字键不是位掩码而是 8 方向 + 中值的枚举(顺时针递增):
1
8 2
7 3
6 4
5
同时按下两个方向(如上+右)时帽子值取中间值(上+右=2)。
7.3 XInput → 16 字节映射规则
| XInput 值 | 协议值 | 转换 |
|---|---|---|
sThumb* (i16) | u16 LE | value & 0xFFFF(即 +0x8000 环绕:0 → 0x8000) |
bLeftTrigger (0~255) | 10-bit | min(1023, value * 4) |
| DPAD 组合 | hat | 1上 3右 5下 7左,斜向 2/4/6/8 |
| A/B/X/Y/LB/RB | byte[13] | A|B<<1|X<<3|Y<<4|LB<<6|RB<<7 |
| Back/Start/Guide/L3/R3 | byte[14] | View<<2 |Menu<<3|Xbox<<4|L3<<5|R3<<6 |
| Share | byte[15] | XInput 不暴露,恒 0 |
实测差异提醒:手柄静止时 sThumbLY 常为 -256(固件量化偏置),映射后为 0xFF 0xFF 而非 0x00 0x80,属正常现象,上层需死区归零。
8. 完整映射实现代码
以下代码为本项目实测通过的完整转换函数:
# XInput 常量
XINPUT_GAMEPAD_DPAD_UP, XINPUT_GAMEPAD_DPAD_DOWN = 0x0001, 0x0002
XINPUT_GAMEPAD_DPAD_LEFT, XINPUT_GAMEPAD_DPAD_RIGHT = 0x0004, 0x0008
XINPUT_GAMEPAD_START, XINPUT_GAMEPAD_BACK = 0x0010, 0x0020
XINPUT_GAMEPAD_LEFT_THUMB, XINPUT_GAMEPAD_RIGHT_THUMB = 0x0040, 0x0080
XINPUT_GAMEPAD_LEFT_SHOULDER, XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0100, 0x0200
XINPUT_GAMEPAD_GUIDE = 0x0400
XINPUT_GAMEPAD_A, XINPUT_GAMEPAD_B = 0x1000, 0x2000
XINPUT_GAMEPAD_X, XINPUT_GAMEPAD_Y = 0x4000, 0x8000
def xinput_to_protocol(g):
"""XInput 状态 -> 16 字节 Xbox BLE HID 报文 (小端)"""
lx, ly, rx, ry = (g.sThumbLX & 0xFFFF, g.sThumbLY & 0xFFFF,
g.sThumbRX & 0xFFFF, g.sThumbRY & 0xFFFF)
lt = min(1023, g.bLeftTrigger * 4)
rt = min(1023, g.bRightTrigger * 4)
w = g.wButtons
up, down = w & XINPUT_GAMEPAD_DPAD_UP, w & XINPUT_GAMEPAD_DPAD_DOWN
left, right = w & XINPUT_GAMEPAD_DPAD_LEFT, w & XINPUT_GAMEPAD_DPAD_RIGHT
if up and right: hat = 2
elif right and down: hat = 4
elif down and left: hat = 6
elif left and up: hat = 8
elif up: hat = 1
elif right: hat = 3
elif down: hat = 5
elif left: hat = 7
else: hat = 0
b13 = ((w & XINPUT_GAMEPAD_A) and 0x01) | ((w & XINPUT_GAMEPAD_B) and 0x02) \
| ((w & XINPUT_GAMEPAD_X) and 0x08) | ((w & XINPUT_GAMEPAD_Y) and 0x10) \
| ((w & XINPUT_GAMEPAD_LEFT_SHOULDER) and 0x40) \
| ((w & XINPUT_GAMEPAD_RIGHT_SHOULDER) and 0x80)
b14 = ((w & XINPUT_GAMEPAD_BACK) and 0x04) | ((w & XINPUT_GAMEPAD_START) and 0x08) \
| ((w & XINPUT_GAMEPAD_GUIDE) and 0x10) \
| ((w & XINPUT_GAMEPAD_LEFT_THUMB) and 0x20) \
| ((w & XINPUT_GAMEPAD_RIGHT_THUMB) and 0x40)
return bytes([lx & 0xFF, lx >> 8, ly & 0xFF, ly >> 8,
rx & 0xFF, rx >> 8, ry & 0xFF, ry >> 8,
lt & 0xFF, lt >> 8, rt & 0xFF, rt >> 8,
hat, b13, b14, 0x00])
9. 示例输出
9.1 静止状态(实测)
slot: 0
state: 0 wButtons=0000 LT=0 RT=0 LX=0 LY=-256
proto: 00 00 00 FF 00 00 00 FF 00 00 00 00 00 00 00 00
注意 LY 静止偏置 -256 → 报文 [2:4] = FF FF(即 0xFFFF,距中值 0x8000 偏 -1 刻度),上层需死区处理。
9.2 按住 A 键 + 左摇杆推右
wButtons=0x1000 (A)
LT=0 RT=0 LX=21504 LY=-256 RX=0 RY=0
proto: C0 54 00 FF 00 00 00 FF 00 00 00 00 00 01 00 00
└ LX=0x54C0=21700 └ hat=0 └ byte[13]=0x01(A)
9.3 按住十字键右上
wButtons=0x0009 (D-Pad Up + D-Pad Right)
proto: ... 00 00 00 00 02 00 00 00
└ hat=2 (右上)
9.4 按住 Home(Guide) 键(仅 Ex 版)
wButtons=0x0400 (Guide)
proto: ... 00 00 00 00 00 10 00 00
└ byte[14]=0x10 (Xbox)
10. 验证代码
逐键验证脚本:提示一次按一个键,比对 wButtons 与 16 字节报文的对应位是否一致。
# verify_xinput_protocol.py
import ctypes, time
from xinput_reader import * # 上文第 2/6/8 节代码合并为模块
# (XInput 位, 协议字节偏移, 协议位) 对照表
CHECKS = [
(XINPUT_GAMEPAD_A, 13, 0x01, "A"),
(XINPUT_GAMEPAD_B, 13, 0x02, "B"),
(XINPUT_GAMEPAD_X, 13, 0x08, "X"),
(XINPUT_GAMEPAD_Y, 13, 0x10, "Y"),
(XINPUT_GAMEPAD_LEFT_SHOULDER, 13, 0x40, "LB"),
(XINPUT_GAMEPAD_RIGHT_SHOULDER, 13, 0x80, "RB"),
(XINPUT_GAMEPAD_BACK, 14, 0x04, "View"),
(XINPUT_GAMEPAD_START, 14, 0x08, "Menu"),
(XINPUT_GAMEPAD_GUIDE, 14, 0x10, "Xbox"),
(XINPUT_GAMEPAD_LEFT_THUMB, 14, 0x20, "LS"),
(XINPUT_GAMEPAD_RIGHT_THUMB, 14, 0x40, "RS"),
]
def main():
xinput = load_xinput()
get_ex, guide_ok = load_get_state_ex(xinput)
idx = find_controller()
print(f"Controller slot {idx}, Guide support: {guide_ok}")
print("Press ONE button at a time; Ctrl+C to stop.\n")
state = XINPUT_STATE()
while True:
if get_ex(idx, ctypes.byref(state)) != 0:
time.sleep(0.05); continue
g = state.Gamepad
proto = xinput_to_protocol(g)
for xbit, off, pbit, name in CHECKS:
if g.wButtons & xbit: # 该键按下
ok = proto[off] & pbit
tag = "OK " if ok else "MISMATCH"
print(f"{tag} {name:5s} wButtons=0x{g.wButtons:04X} "
f"-> proto[{off}]&0x{pbit:02X} = {ok}")
time.sleep(0.01)
if __name__ == "__main__":
main()
预期输出(按 B 键时):
Controller slot 0, Guide support: True
OK B wButtons=0x2000 -> proto[13]&0x02 = 2
11. 完整 GUI 验证程序

image-20260919131422647xinput_gui.py 功能一览:
- 按键灯:12 键实时显示(用 XInput 位判断,不是协议位)
- 摇杆:两个 170×170 画布,红点实时映射(Y 轴屏幕取反)
- 扳机:LT/RT 进度条 0~255
- 十字键:显示帽子值与方向名
- 协议视图:实时 16 字节 hex + 各字段分解值
- 状态栏:手柄槽位、帧率、XInput 包号、Guide 键支持情况
核心设计要点:
- tkinter
after(8)非阻塞轮询(~100Hz),不用线程即可不卡 UI XInputGetStateEx(ordinal 100)启动时探测,决定 Guide 键是否可用- 按键灯与协议视图双编码并存:灯用 XInput 位(
LAMPS13/LAMPS14),协议视图用 HID 位(xinput_to_protocol()),两套掩码绝不混用
完整源码(实测通过版):
# -*- coding: utf-8 -*-
"""
Xbox 手柄 XInput GUI 验证程序
数据源: XInputGetStateEx ( ordinal 100, 支持 Guide 键 )
用途: 验证 16 字节 BLE HID 协议映射
运行: python xinput_gui.py
"""
import ctypes
import time
import tkinter as tk
from tkinter import ttk
# ---------------- XInput ----------------
XINPUT_GAMEPAD_DPAD_UP = 0x0001
XINPUT_GAMEPAD_DPAD_DOWN = 0x0002
XINPUT_GAMEPAD_DPAD_LEFT = 0x0004
XINPUT_GAMEPAD_DPAD_RIGHT = 0x0008
XINPUT_GAMEPAD_START = 0x0010
XINPUT_GAMEPAD_BACK = 0x0020
XINPUT_GAMEPAD_LEFT_THUMB = 0x0040
XINPUT_GAMEPAD_RIGHT_THUMB = 0x0080
XINPUT_GAMEPAD_LEFT_SHOULDER = 0x0100
XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0200
XINPUT_GAMEPAD_GUIDE = 0x0400
XINPUT_GAMEPAD_A = 0x1000
XINPUT_GAMEPAD_B = 0x2000
XINPUT_GAMEPAD_X = 0x4000
XINPUT_GAMEPAD_Y = 0x8000
class XINPUT_GAMEPAD(ctypes.Structure):
_fields_ = [("wButtons", ctypes.c_ushort),
("bLeftTrigger", ctypes.c_ubyte),
("bRightTrigger", ctypes.c_ubyte),
("sThumbLX", ctypes.c_short),
("sThumbLY", ctypes.c_short),
("sThumbRX", ctypes.c_short),
("sThumbRY", ctypes.c_short)]
class XINPUT_STATE(ctypes.Structure):
_fields_ = [("dwPacketNumber", ctypes.c_ulong),
("Gamepad", XINPUT_GAMEPAD)]
def load_xinput():
for name in ("XInput1_4.dll", "XInput1_3.dll", "XInput9_1_0.dll"):
try:
return ctypes.WinDLL(name)
except OSError:
continue
raise SystemExit("No XInput DLL found")
XINPUT = load_xinput()
try:
GET_STATE = XINPUT[100] # XInputGetStateEx, 支持 Guide
GUIDE_OK = True
except (AttributeError, OSError):
GET_STATE = XINPUT.XInputGetState
GUIDE_OK = False
GET_STATE.restype = ctypes.c_ulong
GET_STATE.argtypes = [ctypes.c_ulong, ctypes.POINTER(XINPUT_STATE)]
def find_controller():
state = XINPUT_STATE()
for i in range(4):
if GET_STATE(i, ctypes.byref(state)) == 0:
return i
return None
# ---------------- 协议映射 (16 字节 BLE HID 报文) ----------------
def xinput_to_protocol(g):
"""XInput 状态 -> 16 字节 BLE HID 报文"""
lx = g.sThumbLX & 0xFFFF
ly = g.sThumbLY & 0xFFFF
rx = g.sThumbRX & 0xFFFF
ry = g.sThumbRY & 0xFFFF
lt = min(1023, g.bLeftTrigger * 4)
rt = min(1023, g.bRightTrigger * 4)
w = g.wButtons
up = w & XINPUT_GAMEPAD_DPAD_UP
down = w & XINPUT_GAMEPAD_DPAD_DOWN
left = w & XINPUT_GAMEPAD_DPAD_LEFT
right = w & XINPUT_GAMEPAD_DPAD_RIGHT
if up and right: hat = 2
elif right and down: hat = 4
elif down and left: hat = 6
elif left and up: hat = 8
elif up: hat = 1
elif right: hat = 3
elif down: hat = 5
elif left: hat = 7
else: hat = 0
b13 = 0
if w & XINPUT_GAMEPAD_A: b13 |= 0x01
if w & XINPUT_GAMEPAD_B: b13 |= 0x02
if w & XINPUT_GAMEPAD_X: b13 |= 0x08
if w & XINPUT_GAMEPAD_Y: b13 |= 0x10
if w & XINPUT_GAMEPAD_LEFT_SHOULDER: b13 |= 0x40
if w & XINPUT_GAMEPAD_RIGHT_SHOULDER: b13 |= 0x80
b14 = 0
if w & XINPUT_GAMEPAD_BACK: b14 |= 0x04
if w & XINPUT_GAMEPAD_START: b14 |= 0x08
if w & XINPUT_GAMEPAD_GUIDE: b14 |= 0x10
if w & XINPUT_GAMEPAD_LEFT_THUMB: b14 |= 0x20
if w & XINPUT_GAMEPAD_RIGHT_THUMB: b14 |= 0x40
return bytes([lx & 0xFF, lx >> 8, ly & 0xFF, ly >> 8,
rx & 0xFF, rx >> 8, ry & 0xFF, ry >> 8,
lt & 0xFF, lt >> 8, rt & 0xFF, rt >> 8,
hat, b13, b14, 0x00])
# 按键灯用 XInput 的 wButtons 位判断
LAMPS13 = [("A", XINPUT_GAMEPAD_A), ("B", XINPUT_GAMEPAD_B),
("X", XINPUT_GAMEPAD_X), ("Y", XINPUT_GAMEPAD_Y),
("LB", XINPUT_GAMEPAD_LEFT_SHOULDER), ("RB", XINPUT_GAMEPAD_RIGHT_SHOULDER)]
LAMPS14 = [("View", XINPUT_GAMEPAD_BACK), ("Menu", XINPUT_GAMEPAD_START),
("Xbox", XINPUT_GAMEPAD_GUIDE),
("LS", XINPUT_GAMEPAD_LEFT_THUMB), ("RS", XINPUT_GAMEPAD_RIGHT_THUMB)]
# 下方为 16 字节协议视图的位掩码
MASK13 = [("A", 0x01), ("B", 0x02), ("X", 0x08), ("Y", 0x10), ("LB", 0x40), ("RB", 0x80)]
HAT = {0: "中位", 1: "上", 2: "右上", 3: "右", 4: "右下", 5: "下",
6: "左下", 7: "左", 8: "左上"}
class App:
def __init__(self, root):
self.root = root
root.title("Xbox 手柄协议验证 (XInput)")
self.state = XINPUT_STATE()
self.idx = find_controller()
self.guide_ok = GUIDE_OK
top = tk.Frame(root)
top.pack(fill="x", padx=8, pady=6)
self.conn = tk.Label(top, anchor="w", font=("Microsoft YaHei", 10, "bold"))
self.conn.pack(side="left")
tk.Label(top, fg="gray", anchor="e",
text=f"Guide键: {'支持' if GUIDE_OK else '不支持(旧DLL)'} 轮询: ~100Hz").pack(side="right")
btnf = ttk.LabelFrame(root, text="按键", padding=6)
btnf.pack(fill="x", padx=8, pady=4)
self.lamps = {}
for i, n in enumerate([x for x, _ in LAMPS13 + LAMPS14] + ["Share"]):
lamp = tk.Label(btnf, text=n, width=6, relief="groove", bg="#e8e8e8", fg="#333")
lamp.grid(row=i // 8, column=i % 8, padx=3, pady=3, sticky="nsew")
self.lamps[n] = lamp
midf = ttk.Frame(root)
midf.pack(fill="both", expand=True, padx=8, pady=4)
self.sticks = {}
for j, name in enumerate(("左摇杆", "右摇杆")):
f = ttk.LabelFrame(midf, text=name, padding=2)
f.grid(row=0, column=j, padx=6, sticky="nsew")
cv = tk.Canvas(f, width=170, height=170, bg="white")
cv.pack()
cv.create_line(10, 85, 160, 85, fill="#ccc")
cv.create_line(85, 10, 85, 160, fill="#ccc")
self.sticks[name] = (cv, cv.create_oval(0, 0, 0, 0, fill="red", outline="darkred"))
trigf = ttk.LabelFrame(midf, text="扳机 0~255", padding=6)
trigf.grid(row=0, column=2, padx=6, sticky="nsew")
self.trigs = {}
for j, name in enumerate(("LT", "RT")):
ttk.Label(trigf, text=name).grid(row=0, column=j)
cv = tk.Canvas(trigf, width=50, height=170, bg="white")
cv.grid(row=1, column=j, padx=4)
self.trigs[name] = (cv, cv.create_rectangle(5, 170, 45, 170, fill="#2a7f2a"),
cv.create_text(25, 12, text="0"))
midf.columnconfigure((0, 1, 2), weight=1)
self.dp_lbl = tk.Label(root, text="十字键: --", anchor="w", font=("Microsoft YaHei", 10))
self.dp_lbl.pack(fill="x", padx=8, pady=2)
proff = ttk.LabelFrame(root, text="协议视图 — 16 字节 BLE HID 报文", padding=6)
proff.pack(fill="x", padx=8, pady=(4, 8))
self.proto_hex = tk.Label(proff, font=("Consolas", 12), anchor="w")
self.proto_hex.pack(fill="x")
self.proto_detail = tk.Label(proff, font=("Consolas", 9), anchor="w", fg="#444")
self.proto_detail.pack(fill="x")
self.last = None
self.rate = 0
self.t0 = time.time()
self.frames = 0
self.root.after(8, self.poll)
def poll(self):
if GET_STATE(self.idx if self.idx is not None else 0, ctypes.byref(self.state)) == 0:
g = self.state.Gamepad
self.render(g)
self.frames += 1
else:
self.conn.config(text="未连接手柄", fg="#c22")
dt = time.time() - self.t0
if dt >= 1:
self.rate = self.frames / dt
self.frames = 0
self.t0 = time.time()
self.root.after(8, self.poll)
def render(self, g):
w = g.wButtons
for n, _ in LAMPS13 + LAMPS14:
self.lamps[n].config(bg="#e8e8e8", fg="#333")
self.lamps["Share"].config(bg="#e8e8e8", fg="#333")
for n, m in LAMPS13:
if w & m:
self.lamps[n].config(bg="#d22", fg="white")
for n, m in LAMPS14:
if w & m:
self.lamps[n].config(bg="#d22", fg="white")
for (name, x, y) in (("左摇杆", g.sThumbLX, -g.sThumbLY),
("右摇杆", g.sThumbRX, -g.sThumbRY)):
cv, dot = self.sticks[name]
cx, cy = 87, 87
cv.coords(dot, cx + x / 32768 * 75 - 6, cy + y / 32768 * 75 - 6,
cx + x / 32768 * 75 + 6, cy + y / 32768 * 75 + 6)
for tname, val in (("LT", g.bLeftTrigger), ("RT", g.bRightTrigger)):
cv, bar, txt = self.trigs[tname]
h = int(val / 255 * 160)
cv.coords(bar, 5, 170 - h, 45, 170)
cv.itemconfig(txt, text=str(val))
hat = 0
up = w & XINPUT_GAMEPAD_DPAD_UP; down = w & XINPUT_GAMEPAD_DPAD_DOWN
left = w & XINPUT_GAMEPAD_DPAD_LEFT; right = w & XINPUT_GAMEPAD_DPAD_RIGHT
if up and right: hat = 2
elif right and down: hat = 4
elif down and left: hat = 6
elif left and up: hat = 8
elif up: hat = 1
elif right: hat = 3
elif down: hat = 5
elif left: hat = 7
self.dp_lbl.config(text=f"十字键帽子值: {hat} {HAT[hat]} "
f"L3={bool(w & XINPUT_GAMEPAD_LEFT_THUMB)} "
f"R3={bool(w & XINPUT_GAMEPAD_RIGHT_THUMB)}")
proto = xinput_to_protocol(g)
self.proto_hex.config(text=" ".join(f"{b:02X}" for b in proto))
lx = int.from_bytes(proto[0:2], "little"); ly = int.from_bytes(proto[2:4], "little")
rx = int.from_bytes(proto[4:6], "little"); ry = int.from_bytes(proto[6:8], "little")
lt = int.from_bytes(proto[8:10], "little"); rt = int.from_bytes(proto[10:12], "little")
self.proto_detail.config(
text=f"LX={lx:5d} LY={ly:5d} RX={rx:5d} RY={ry:5d} "
f"(中值0x8000) LT={lt:4d} RT={rt:4d} (10bit, 中值0) "
f"[12]hat={proto[12]} [13]btn=0x{proto[13]:02X} "
f"[14]sys=0x{proto[14]:02X} [15]share=0x{proto[15]:02X}")
self.conn.config(text=f"手柄已连接 (slot {self.idx}) 帧率: {self.rate:.0f} 帧/秒 包号: {self.state.dwPacketNumber}",
fg="#1a1")
if __name__ == "__main__":
root = tk.Tk()
App(root)
root.mainloop()
12. 参考文献与项目
官方文档
- XInput 概述 — Microsoft Learn https://learn.microsoft.com/en-us/windows/win32/xinput/introduction-to-xinput
- XInputGetState — Microsoft Learn https://learn.microsoft.com/en-us/windows/win32/xinput/xinputgetstate
- XINPUT_GAMEPAD structure — Microsoft Learn(含死区常量定义) https://learn.microsoft.com/en-us/windows/win32/api/xinput/ns-xinput-xinput_gamepad
- XInput Controllers, DirectInput, and XUSB Devices(Guide 键/映射表权威说明) https://learn.microsoft.com/en-us/windows/win32/xinput/xinput-and-directinput
- HID Usage Tables(十字键帽子值 Hat Switch 定义,Generic Desktop Usage 0x39) https://usb.org/sites/default/files/hut1_41.pdf
关键非公开知识
- XInputGetStateEx = ordinal 100:
XInput1_4.dll未公开导出,结构与XInputGetState一致,多读出 Guide 键(0x0400)。社区佐证:x360ce 源码xinput1_4/Forwarder.cpp、 pygameXInputPython移植笔记。 - 16 字节 BLE HID 报文布局:Xbox Wireless Controller BLE HID Input Report(0x2A4D,Report ID 0x01),社区逆向项目对照:
xpadLinux 内核驱动 (drivers/input/joystick/xpad.c) — USB/BLE 报文处理- ESP32 NimBLE Xbox controller 库(如
StackSizes/XboxControllerNotificationParser)中onNotify的字节偏移定义与本文第 7 节一致
本项目产出物
| 文件 | 说明 |
|---|---|
read_xinput.py | 命令行原始读数(变化打印) |
xinput_gui.py | GUI 协议验证程序(本文第 11 节) |
ble_protocol_gui.py | Raw Input 版验证(备用方案,hidapi 被系统独占时不可用) |
13. 附录:AI 调用协议专用 Prompt
将以下内容直接作为系统提示词/上下文交给 AI,即可让其正确生成操作该手柄协议的代码。
你将编写读取 Xbox 手柄并转换为标准 16 字节 BLE HID 报文的 Python 代码。
严格遵守以下协议规范:
【数据源层:XInput】
- 通过 ctypes 调用 XInput1_4.dll;必须优先尝试 ordinal 100 (XInputGetStateEx)
以获得 Guide 键支持,失败时回退 XInputGetState。
- 结构体 XINPUT_STATE = { dwPacketNumber: c_ulong; Gamepad: XINPUT_GAMEPAD };
XINPUT_GAMEPAD = { wButtons: c_ushort; bLeftTrigger: c_ubyte; bRightTrigger: c_ubyte;
sThumbLX/sThumbLY/sThumbRX/sThumbRY: c_short }。
- XInputGetState 返回 0 表示成功,1167 表示手柄未连接。
- 轮询间隔 5~10ms;dwPacketNumber 用于检测状态变化。
【wButtons 位掩码 (XInput 编码,勿与协议编码混淆)】
DPAD_UP=0x0001 DPAD_DOWN=0x0002 DPAD_LEFT=0x0004 DPAD_RIGHT=0x0008
START=0x0010 BACK=0x0020 LS=0x0040 RS=0x0080 LB=0x0100 RB=0x0200
GUIDE=0x0400 A=0x1000 B=0x2000 X=0x4000 Y=0x8000
【输出层:16 字节 BLE HID 报文,全部小端序】
byte[0:2] 左摇杆X u16 LE,中值 0x8000:直接 value & 0xFFFF
byte[2:4] 左摇杆Y u16 LE(Y 向上为正)
byte[4:6] 右摇杆X u16 LE
byte[6:8] 右摇杆Y u16 LE
byte[8:10] 左扳机 u16 LE,10 位 0~1023 = min(1023, bLeftTrigger * 4)
byte[10:12] 右扳机 同上
byte[12] 十字键帽子值: 0=中 1=上 2=右上 3=右 4=右下 5=下 6=左下 7=左 8=左上
(两方向同按时取对应枚举,如 上+右=2)
byte[13] 动作键: A=0x01 B=0x02 X=0x08 Y=0x10 LB=0x40 RB=0x80 (可按位或)
byte[14] 系统键: View(Back)=0x04 Menu(Start)=0x08 Xbox(Guide)=0x10 LS=0x20 RS=0x40
byte[15] 保留,Share=0x01;XInput 读不到 Share,恒填 0x00
【已知硬件实测偏置】
- 静止时 sThumbLY/sThumbRY 常为 -256,转换后 byte 为 FF FF 而非 00 80,
属正常固件量化误差;输出前应应用死区:
XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE=7849, RIGHT=8689,
XINPUT_GAMEPAD_TRIGGER_THRESHOLD=30。
【代码要求】
1. 单文件、仅依赖 ctypes 与标准库;
2. 提供 load_xinput()/find_controller()/xinput_to_protocol(g) 三个函数;
3. xinput_to_protocol 输入为 XINPUT_GAMEPAD,返回 16 字节 bytes;
4. 不做 GUI;异常时返回 None 并打印错误码。
协议验证日期:2026-09-19;环境:Windows 11 26200 / Python 3.14.6 / Flydigi Direwolf 4 (Xbox 兼容模式)。