Skip to content

feat(Hardware Support): add OXP HID driver for OneXPlayer X1 and APEX#567

Open
honjow wants to merge 12 commits intoShadowBlip:mainfrom
honjow:feat/oxp-upstream
Open

feat(Hardware Support): add OXP HID driver for OneXPlayer X1 and APEX#567
honjow wants to merge 12 commits intoShadowBlip:mainfrom
honjow:feat/oxp-upstream

Conversation

@honjow
Copy link
Copy Markdown
Contributor

@honjow honjow commented Mar 27, 2026

Add a vendor HID driver for OneXPlayer devices (VID 1a86, PID fe00, interface 2) to support back paddles (M1/M2), Keyboard, and Guide buttons through the proprietary report mode.

The HID protocol reverse engineering was largely based on work by @srsholmes in srsholmes/onexplayer-apex-bazzite-fixes#8, where the B4 key mapping, B2 report mode, and OXP key encoding were captured and documented via HID sniffing on Windows. Thanks to srsholmes for the thorough protocol analysis.

How it works

The OXP MCU exposes a HID interface that can be configured and polled over hidraw:

  • B4 configures button-to-keycode mappings (M1→F14, M2→F13). Note that our input parser reads the button's own identifier from the report, not the configured keycode — the B4 mapping only ensures that these buttons produce HID events independently of the Xbox gamepad.
  • B2 ENABLE→DISABLE cycle activates the vendor report mode. This appears to be required only on the APEX; on X1 Mini the report mode works without it, but the cycle is harmless there.
  • B3 sets vibration intensity to max (5). The MCU does not persist this across reboots, so it needs to be sent on every initialization. This appears to be necessary only on X1 Mini — the APEX works without it, but the command is harmless on devices that don't require it.

Suspend/resume handling

On X1 Mini, after s2idle resume the MCU resets its volatile settings (B3, B4) and emits a B8 status packet with byte3=0xFE roughly 6 seconds after wake. The driver detects this and schedules a full re-initialization on the next poll cycle. This was observed consistently on X1 Mini (CH32 MCU) but may not occur on all models — the detection is safe: if the device never sends B8 0xFE, nothing happens, and the re-init sequence is idempotent. Whether this should remain default behavior or be behind a per-device flag is open for discussion.

Device support

  • X1 series (X1 A / X1 i / X1 mini): device config, IMU mount matrix correction, external/docked controller support, HID Keyboard→QuickAccess mapping. The IMU mount matrix was only verified on X1 Mini — it may need adjustment for other X1 variants.
  • APEX: device config with back paddle swap (physical left/right are reversed compared to X1), and special button mappings (Turbo, KB, Orange).

Testing status

Tested on X1 Mini and APEX — basic functionality (back paddles, special buttons, vibration) works correctly on both. APEX gyroscope/IMU configuration has not been verified yet and may need a follow-up.

honjow added 8 commits March 28, 2026 02:30
…al keys

Add a vendor HID driver for OneXPlayer devices (VID:1a86 PID:fe00)
that reports back paddles (M1/M2), Keyboard, and Guide buttons via
proprietary report mode.

- B4 command configures M1→F14, M2→F13 keyboard keycodes for
  independent back paddle HID reports
- B2 ENABLE→DISABLE cycle activates vendor report mode
- Input parser with 0x3F frame validation and button debouncing
- SourceInputDevice adapter translating events to NativeEvent
- Add hidraw source device for OXP HID driver (VID:1a86 PID:fe00)
- Add IMU variant (BOSC0260) and correct mount matrix
- Add external/docked controller support (fe02 receiver)
- Enable auto_manage and switch target to xbox-elite
- Map HID Keyboard button to QuickAccess in capability map
Send B3 command during initialization to set vibration intensity to
max (5). This must be sent after the B2 report mode cycle because
B2 ENABLE resets vibration settings. The MCU does not persist this
across reboots.
Detect MCU re-initialization via B8 status packet with byte3=0xFE,
which the MCU emits ~6s after s2idle resume. This resets volatile
settings (B3 vibration, B4 mappings), so schedule a full
re-initialization on the next poll cycle.

Safe for devices that do not emit this packet (e.g. Apex): the
condition never triggers, and re-initialization is idempotent.
Add device profile and capability map for ONEXPLAYER APEX, including
back paddle swap (physical left/right are reversed compared to X1)
and special button mappings (Turbo, KB, Orange).
Downgrade per-command, per-ACK, per-packet and per-button log lines
from info to debug. Keep info level only for key lifecycle events:
device open, initialization start/complete, and MCU reset detection.
Format B4 button mapping arrays with one entry per line and annotate
each with the physical button name and mapping type (gamepad/keyboard).
Add DMI match pattern for ONEXPLAYER APEX so InputPlumber
auto-starts on that device.
@srsholmes
Copy link
Copy Markdown

Please can you credit the orignal PR that was done in the PR description in order for this to exist and work. Thank you! 👍🏼

@honjow
Copy link
Copy Markdown
Contributor Author

honjow commented Mar 28, 2026

Updated — added credit and link to your PR. Thanks for the protocol work! @srsholmes

Copy link
Copy Markdown
Contributor

@pastaq pastaq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extra RE has been useful for adding some of the missing bits to the WIP kernel driver, so thank you for those efforts. The driver will be added to the OGC kernel and the Valve kernel, so all major gaming distros will be able to handle the HID writes without the userspace implementation.

Comment on lines +26 to +119
// B4 button mapping commands: configure M1→F14, M2→F13 keyboard keycodes
// to get independent back paddle HID reports without Xbox gamepad mirroring.
//
// Each page has a 5-byte header followed by up to 9 entries of 6 bytes:
// Header: [0x02, 0x38, 0x20, page, 0x01]
// Gamepad: [btn_id, 0x01, gamepad_code, 0x00, 0x00, 0x00]
// Keyboard:[btn_id, 0x02, 0x01, oxp_keycode, 0x00, 0x00]
// OXP key encoding: F(n) = 0x59 + n, so F13=0x66, F14=0x67.
const INIT_CMD_1: [u8; PACKET_SIZE] = gen_cmd(
0xB4,
&[
0x02, 0x38, 0x20, 0x01, 0x01, // page 1 header
0x01, 0x01, 0x01, 0x00, 0x00, 0x00, // A → gamepad A (identity)
0x02, 0x01, 0x02, 0x00, 0x00, 0x00, // B → gamepad B (identity)
0x03, 0x01, 0x03, 0x00, 0x00, 0x00, // X → gamepad X (identity)
0x04, 0x01, 0x04, 0x00, 0x00, 0x00, // Y → gamepad Y (identity)
0x05, 0x01, 0x05, 0x00, 0x00, 0x00, // LB → gamepad LB (identity)
0x06, 0x01, 0x06, 0x00, 0x00, 0x00, // RB → gamepad RB (identity)
0x07, 0x01, 0x07, 0x00, 0x00, 0x00, // LT → gamepad LT (identity)
0x08, 0x01, 0x08, 0x00, 0x00, 0x00, // RT → gamepad RT (identity)
0x09, 0x01, 0x09, 0x00, 0x00, 0x00, // START → gamepad START (identity)
],
);

const INIT_CMD_2: [u8; PACKET_SIZE] = gen_cmd(
0xB4,
&[
0x02, 0x38, 0x20, 0x02, 0x01, // page 2 header
0x0a, 0x01, 0x0a, 0x00, 0x00, 0x00, // BACK → gamepad BACK (identity)
0x0b, 0x01, 0x0b, 0x00, 0x00, 0x00, // L3 → gamepad L3 (identity)
0x0c, 0x01, 0x0c, 0x00, 0x00, 0x00, // R3 → gamepad R3 (identity)
0x0d, 0x01, 0x0d, 0x00, 0x00, 0x00, // D-Up → gamepad D-Up (identity)
0x0e, 0x01, 0x0e, 0x00, 0x00, 0x00, // D-Down → gamepad D-Down (identity)
0x0f, 0x01, 0x0f, 0x00, 0x00, 0x00, // D-Left → gamepad D-Left (identity)
0x10, 0x01, 0x10, 0x00, 0x00, 0x00, // D-Right → gamepad D-Right (identity)
0x22, 0x02, 0x01, 0x67, 0x00, 0x00, // M1 → keyboard F14
0x23, 0x02, 0x01, 0x66, 0x00, 0x00, // M2 → keyboard F13
],
);

/// Generate a command with 0x3F framing: [cid, 0x3F, 0x01, ...data, 0x3F, cid]
const fn gen_cmd(cid: u8, data: &[u8]) -> [u8; PACKET_SIZE] {
let mut buf = [0u8; PACKET_SIZE];
buf[0] = cid;
buf[1] = 0x3F;
buf[2] = 0x01;

let mut i = 0;
while i < data.len() && (i + 3) < PACKET_SIZE - 2 {
buf[i + 3] = data[i];
i += 1;
}

buf[PACKET_SIZE - 2] = 0x3F;
buf[PACKET_SIZE - 1] = cid;
buf
}

// B3 vibration intensity: set to max (5) so Xbox gamepad rumble works.
// MCU does not persist this across reboots, so it must be sent every init.
// Payload: 15-byte header + 35 zero padding + 9-byte tail = 59 bytes.
const B3_VIBRATION: [u8; PACKET_SIZE] = gen_cmd(
0xB3,
&[
0x02, 0x38, 0x02, 0xE3, 0x39, 0xE3, 0x39, 0xE3, 0x39, 0x01, 0x05, 0x05,
0xE3, 0x39, 0xE3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x39, 0xE3, 0x39, 0xE3, 0xE3, 0x02, 0x04, 0x39, 0x39,
],
);

// B2 report mode activation: ENABLE then DISABLE cycle.
// Required on Apex; harmless on X1 Mini (tested: both phases produce events).
const B2_ENABLE: [u8; PACKET_SIZE] = gen_cmd(CMD_BUTTON, &[0x03, 0x01, 0x02]);
const B2_DISABLE: [u8; PACKET_SIZE] = gen_cmd(CMD_BUTTON, &[0x00, 0x01, 0x02]);

// MCU status notification (CID 0xB8). The MCU emits these unsolicited during
// normal operation (touchpad, sensor events, etc.) and after resume from
// suspend.
//
// Observed on X1 Mini (VID:1a86 PID:fe00, CH32 MCU): after every s2idle
// resume the MCU sends a consistent three-packet burst ~6s after wake:
// B8 [... fd ... 03 ...]
// B8 [... fe ... 00 ...] ← byte3=0xFE, only appears during MCU init
// B8 [... fd 04 ... 04 ...]
// The 0xFE packet signals that the MCU has completed its own re-initialization
// and has reset volatile settings (B3 vibration intensity, B4 button mappings).
// Without re-initialization, vibration stops working after resume.
//
// This detection is safe for devices that do not emit B8 0xFE (e.g. Apex):
// the condition simply never triggers, and re-initialization is idempotent.
const CMD_MCU_STATUS: u8 = 0xB8;
const MCU_INIT_COMPLETE: u8 = 0xFE;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These will be covered by a kernel driver.

Comment on lines +159 to +219
/// Drain ACK responses from the device, logging each one.
fn drain_responses(&self, phase: &str, buf: &mut [u8]) -> Result<u32, Box<dyn Error + Send + Sync>> {
let mut count = 0u32;
for _ in 0..10 {
let n = self.device.read_timeout(buf, 50)?;
if n == 0 {
break;
}
count += 1;
let cid = buf[0];
log::debug!(
"OXP HID: {phase} ACK #{count}: CID=0x{cid:02X} ({n}B) [{}]",
hex_prefix(buf, 16)
);
}
if count == 0 {
log::warn!("OXP HID: {phase} — no ACK received");
}
Ok(count)
}

/// Send initialization commands: B4 button mapping → B2 report mode → B3 vibration.
/// B3 must be sent AFTER the B2 cycle because B2 ENABLE resets vibration intensity.
fn initialize(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
log::info!("OXP HID: starting initialization sequence");
let mut drain_buf = [0u8; PACKET_SIZE];

// Phase 1: B4 button mappings
let w1 = self.device.write(&INIT_CMD_1)?;
log::debug!("OXP HID: B4 page1 sent ({w1}B)");
std::thread::sleep(std::time::Duration::from_millis(50));

let w2 = self.device.write(&INIT_CMD_2)?;
log::debug!("OXP HID: B4 page2 sent ({w2}B) — M1→F14(0x67), M2→F13(0x66)");
std::thread::sleep(std::time::Duration::from_millis(50));

self.drain_responses("B4", &mut drain_buf)?;

// Phase 2: B2 report mode ENABLE→DISABLE cycle
let w3 = self.device.write(&B2_ENABLE)?;
log::debug!("OXP HID: B2 ENABLE sent ({w3}B)");
std::thread::sleep(std::time::Duration::from_millis(200));
self.drain_responses("B2-EN", &mut drain_buf)?;

let w4 = self.device.write(&B2_DISABLE)?;
log::debug!("OXP HID: B2 DISABLE sent ({w4}B)");
std::thread::sleep(std::time::Duration::from_millis(100));
self.drain_responses("B2-DIS", &mut drain_buf)?;

// Phase 3: B3 vibration (must be AFTER B2 cycle)
let w5 = self.device.write(&B3_VIBRATION)?;
log::debug!("OXP HID: B3 vibration sent ({w5}B) — intensity=5");
std::thread::sleep(std::time::Duration::from_millis(50));
self.drain_responses("B3", &mut drain_buf)?;

log::info!(
"OXP HID: initialization complete — B4({w1}+{w2}B) B2({w3}+{w4}B) B3({w5}B)"
);
self.initialized = true;
Ok(())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for these.

Comment on lines +223 to +224
if !self.initialized {
self.initialize()?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be removed as well.

Comment on lines +255 to +268
if cid != CMD_BUTTON {
if cid == CMD_MCU_STATUS && buf[3] == MCU_INIT_COMPLETE {
log::info!(
"OXP HID: MCU init-complete (B8 0xFE) detected, scheduling re-initialization"
);
self.initialized = false;
} else {
log::debug!(
"OXP HID: non-B2 packet CID=0x{cid:02X}: [{}]",
hex_prefix(&buf, 16)
);
}
return Ok(Vec::new());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check can just return here instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modified

Comment on lines +270 to +274
let pkt_type = buf[3];
let flag = buf[5];
let btn = buf[6];
let func_code = buf[7];
let pressed = buf[12] == 1;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can all be handled automatically if you use PackedStruct. For the values with known ranges (i.e. button) you can assign the bytes to an enum. For simple boolean logic, you can assign the byte to bool and it will handle this conversion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to PackedStruct method

BTN_KEYBOARD => ButtonEvent::Keyboard(BinaryInput { pressed }),
BTN_GUIDE => ButtonEvent::Guide(BinaryInput { pressed }),
0x00 => return Ok(Vec::new()),
_ => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Combine this with the 0x00 case and drop the unnecessary warnings

Comment on lines +294 to +299
if let Some(prev) = self.btn_state.get_mut(btn as usize) {
if *prev == pressed {
return Ok(Vec::new());
}
*prev = pressed;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can do this first and avoid generating events to then throw away.

honjow added 3 commits April 1, 2026 09:19
- Flatten nested cid check into two separate guard clauses
- Merge 0x00 and unknown button branches, drop unnecessary warn log
- Move debounce check before match to avoid generating events to discard
Add hid_report.rs with InputDataReport (64-byte PackedStruct) and
ButtonId enum covering all B4-mapped button IDs. Replace manual buf[N]
indexing in poll() with typed struct field access and enum matching.
Remove B4/B2/B3 initialization commands, drain_responses(), initialize(),
MCU re-init detection, and the initialized flag. These will be handled
by the kernel driver.
@honjow
Copy link
Copy Markdown
Contributor Author

honjow commented Apr 1, 2026

Already processed and submitted according to the modification suggestions

@srsholmes
Copy link
Copy Markdown

Are all requested changes done on this now? Can we merge it ? It would be great to give input plumber a go on bazzite with the other kernal fixes.

I dont mind building from source, but it would be good to know that this is not going to change in the meantime.

@pastaq
Copy link
Copy Markdown
Contributor

pastaq commented Apr 4, 2026

Are all requested changes done on this now? Can we merge it ? It would be great to give input plumber a go on bazzite with the other kernal fixes.

I dont mind building from source, but it would be good to know that this is not going to change in the meantime.

I have some feedback form the latest version of the WIP kernel driver to incorporate. Once that is done I'll submit the v2 patch and PR it to the OGC kernel. This implementation relies on that driver so I want to wait for it to be merged before this is merged.

- Pre-filter packets by raw bytes before unpack to avoid PackingError
  from non-button packets
- Change press_state from bool to u8 to handle keyboard-mode release
  value (0x02) that bool rejects
@honjow honjow force-pushed the feat/oxp-upstream branch from e77c6d0 to 3a0e96c Compare April 8, 2026 03:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants