Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,17 @@ padctl is a userspace daemon that maps vendor-specific USB/HID gamepad reports t

## Supported Devices

Ships with production configs for **12 supported device families** across 8
vendors. `devices/` also contains a Flydigi DInput/2.4G compatibility variant
Ships device configs for **14 model families** across 9 vendors, including
the experimental GameSir route described below. `devices/` also contains a Vader 4 Pro DInput/2.4G compatibility variant
plus example/test fixtures, so the raw TOML file count is higher:

**Sony** (3) · **Nintendo** (1) · **Microsoft** (1) · **Valve** (1) · **8BitDo** (1) · **Flydigi** (2) · **HORI** (1) · **Lenovo** (2)
**Sony** (3) · **Nintendo** (1) · **Microsoft** (1) · **Valve** (1) · **8BitDo** (1) · **Flydigi** (3) · **HORI** (1) · **Lenovo** (2) · **GameSir** (1)

The GameSir G7 Pro Amazon wired config (`3537:10ba`) is an experimental
telemetry route, pending hardware validation. It requires libusb and explicit
`--config devices/gamesir/g7-pro-amazon-wired.toml`; automatic daemon discovery
currently requires a hidraw anchor this identity lacks. Other editions and
receiver identities are not covered; this config does not enable rumble or IMU.

[Full device list with feature matrix →](https://bananasjim.github.io/padctl/devices/)

Expand Down
98 changes: 98 additions & 0 deletions devices/gamesir/g7-pro-amazon-wired.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# GameSir G7 Pro Amazon edition, wired 3537:10ba (author-tested fw 2.36).
# Protocol source:
# https://github.com/broroeror/gamesir-linux-tools/blob/771b28ae75b0a614a6a7fec9aa606606a27b3b8d/vendors/gamesir/models/g7pro/protocol.py
# Heartbeat-only session maintenance follows reader.py at the same commit.
# Sequence source: vendors/gamesir/control.py at the same commit.
# No onboard profile writes or identity-switch handshake are sent.
# The reference also queries profile/firmware every 2s; this F2-only route
# has not established heartbeat necessity or sufficiency on hardware.
# Prototype: no local hardware validation. Use --config explicitly; daemon
# auto-discovery currently requires a hidraw anchor this identity lacks.
# Other editions, receiver identities and G7 Pro 8K are not covered.

[device]
name = "GameSir G7 Pro Amazon (wired)"
vid = 0x3537
pid = 0x10ba

[[device.interface]]
id = 0
class = "vendor"
ep_in = 0x82
ep_out = 0x02

[device.keepalive]
interface = 0
packet = "0f000002f200"
report_size = 64
period_ms = 450
sequence_offset = 2
sequence_start = 1
# Reference control.py sequencer emits 1..255, never zero.
sequence_skip_zero = true
# Three priming packets with a 50ms interval after each; the first regular
# packet follows at 150ms, subsequent intervals are 450ms (plus write time).
startup_count = 3
startup_interval_ms = 50

[[report]]
name = "telemetry"
interface = 0
size = 64

[report.match]
offset = 3
expect = [0x3c, 0xe0]

[report.fields]
# Processed stick axes rest at 128, range 0..255. This existing linear
# normalization maps 128 to 128 in signed output; no inferred Y inversion
# or physical calibration. Confirm direction and centering on hardware.
left_x = { offset = 5, type = "u8", transform = "scale(-32768, 32767)" }
left_y = { offset = 6, type = "u8", transform = "scale(-32768, 32767)" }
right_x = { offset = 7, type = "u8", transform = "scale(-32768, 32767)" }
right_y = { offset = 8, type = "u8", transform = "scale(-32768, 32767)" }
lt = { offset = 12, type = "u8" }
rt = { offset = 13, type = "u8" }
# Author's physical hat: 0..7 clockwise; both 8 and 15 are neutral.
dpad = { bits = [55, 0, 4] }

# Pre-remap buttons: processed bytes 9/10 lose independent paddle identity.
# Byte 60 is analog RT, never a paddle bitfield.
[report.button_group]
source = { offset = 55, size = 4 }
map = { X = 4, A = 5, B = 6, Y = 7, LB = 8, RB = 9, Select = 12, Start = 13, LS = 14, RS = 15, Home = 16, Capture = 17, M1 = 19, M2 = 20, M3 = 24, M4 = 25 }

[output]
name = "Xbox Elite Series 2 GameSir"
vid = 0x045e
pid = 0x0b00

[output.axes]
left_x = { code = "ABS_X", min = -32768, max = 32767, fuzz = 16, flat = 128 }
left_y = { code = "ABS_Y", min = -32768, max = 32767, fuzz = 16, flat = 128 }
right_x = { code = "ABS_RX", min = -32768, max = 32767, fuzz = 16, flat = 128 }
right_y = { code = "ABS_RY", min = -32768, max = 32767, fuzz = 16, flat = 128 }
lt = { code = "ABS_Z", min = 0, max = 255 }
rt = { code = "ABS_RZ", min = 0, max = 255 }

[output.buttons]
A = "BTN_SOUTH"
B = "BTN_EAST"
X = "BTN_NORTH"
Y = "BTN_WEST"
LB = "BTN_TL"
RB = "BTN_TR"
Select = "BTN_SELECT"
Start = "BTN_START"
LS = "BTN_THUMBL"
RS = "BTN_THUMBR"
Home = "BTN_MODE"
Capture = "BTN_MISC"
M1 = "BTN_TRIGGER_HAPPY1"
M2 = "BTN_TRIGGER_HAPPY2"
M3 = "BTN_TRIGGER_HAPPY3"
M4 = "BTN_TRIGGER_HAPPY4"

[output.dpad]
type = "hat"
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- [8BitDo Ultimate](devices/8bitdo-8bitdo-ultimate-controller.md)
- [Flydigi Vader 4 Pro](devices/flydigi-flydigi-vader-4-pro.md)
- [Flydigi Vader 5 Pro](devices/flydigi-flydigi-vader-5-pro.md)
- [GameSir G7 Pro Amazon (wired, experimental)](devices/gamesir-gamesir-g7-pro-amazon-wired.md)
- [HORI Horipad Steam](devices/hori-hori-horipad-steam.md)
- [Lenovo Legion Go](devices/lenovo-lenovo-legion-go.md)
- [Lenovo Legion Go S](devices/lenovo-lenovo-legion-go-s.md)
Expand Down
32 changes: 32 additions & 0 deletions docs/src/device-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,38 @@ Optional initialization sequence sent after device open.
| `require_response` | bool | no | When `true`, `commands` or `enable` and at least one ACK criterion are required; failure to receive a response matching every configured criterion fails init/re-init instead of continuing. Use for devices whose input reports are valid only after an acknowledged mode switch. |
| `feature_report` | integer[] | no | HID feature report sent via `HIDIOCSFEATURE` immediately after `commands`. Encoded as a list of byte values (0–255); `byte[0]` is the report ID. |

## `[device.keepalive]`

Opt-in periodic physical session maintenance, sent on the existing physical
writer after bounded input processing. Use only a protocol-confirmed keepalive
packet. The scheduler sends only the configured bytes. The GameSir example
uses a heartbeat and configures no onboard profile writes or identity-switch
handshake. Whether heartbeat-only maintenance is necessary and sufficient for
this device has not been verified on hardware here.

| Field | Type | Description |
|-------|------|-------------|
| `interface` | integer | Declared, non-suppress interface receiving the packet |
| `packet` | string | Static hex bytes, with optional spaces; no placeholders |
| `report_size` | integer | Zero-padded packet length, 1–64 bytes |
| `period_ms` | integer | Interval after regular write completions, 10–60000 ms |
| `sequence_offset` | integer | Byte to overwrite with the sequence; must be within the supplied hex bytes |
| `sequence_start` | integer | First sequence value, 0–255; default 1 |
| `sequence_skip_zero` | bool | Skip zero after 255, cycling 1–255; default false (modulo 256). When true, `sequence_start` must be nonzero |
| `startup_count` | integer | First 0–32 completions that use the shorter interval; default 0 |
| `startup_interval_ms` | integer | Startup completion-to-next-write interval, 10–60000 ms; default 50 |

For `startup_count=3`, `startup_interval_ms=50`, `period_ms=450`, immediate
writes occur at approximately 0, 50, 100, 150, 600 ms, plus transport write time.
Only one maintenance packet can be in flight. Missed periods do not create a
backlog. Three consecutive write errors end the session. Mapping restarts retain
the sequence; a physical rebind starts a fresh sequence and priming phase.
This sequence belongs to maintenance packets only; it is not injected into
other command templates or shared with rumble commands. Keepalive cannot be
combined with UHID PID force-feedback passthrough, including in output profiles:
that legacy path writes directly to the physical fd outside the shared writer.
Queued rumble output remains compatible.

## `[[report]]`

Describes one incoming HID report.
Expand Down
9 changes: 9 additions & 0 deletions docs/src/devices/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,12 @@ find devices -type f -name '*.toml' -print | sort | while IFS= read -r f; do
./zig-out/bin/padctl --doc-gen --config "$f" --output docs/src/devices
done
```

The GameSir G7 Pro Amazon wired (`3537:10ba`) telemetry configuration is
experimental and has not been tested on hardware here. It requires libusb and
an explicit `--config` invocation: this identity lacks the hidraw anchor needed
by automatic discovery. Other variants, receiver modes, rumble and IMU output
are outside this configuration. Confirm stick direction/centering and button
mapping on the exact hardware before relying on it. The reference reader also
queries profile and firmware every two seconds; this candidate sends only the
F2 heartbeat. Its necessity and sufficiency for telemetry remain unverified.
93 changes: 93 additions & 0 deletions docs/src/devices/gamesir-gamesir-g7-pro-amazon-wired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# GameSir G7 Pro Amazon (wired)

**VID:PID** `0x3537:0x10ba`

**Vendor** gamesir

## Interfaces

| ID | Class | EP IN | EP OUT |
|----|-------|-------|--------|
| 0 | vendor | 130 | 2 |

## Session Maintenance

Interface 0; `0f000002f200` padded to 64 bytes; sequence byte 2 starts at 1 and cycles 1–255 (zero skipped).

The first 3 completed packets use a 50 ms interval before the next packet; subsequent intervals are 450 ms. Timing starts from write completion, without catch-up bursts. Three consecutive write failures end the session.

## Report: `telemetry` (64 bytes, interface 0)

Match: byte[3] = `0x3c`, `0xe0`

### Fields

| Name | Offset | Type | Transform |
|------|--------|------|-----------|
| `dpad` | bits[55,0,4] | `unsigned` | — |
| `right_y` | 8 | `u8` | scale(-32768, 32767) |
| `left_x` | 5 | `u8` | scale(-32768, 32767) |
| `rt` | 13 | `u8` | — |
| `right_x` | 7 | `u8` | scale(-32768, 32767) |
| `lt` | 12 | `u8` | — |
| `left_y` | 6 | `u8` | scale(-32768, 32767) |

### Button Map

Source: offset 55, size 4 byte(s)

| Button | Bit Index |
|--------|-----------|
| `Capture` | 17 |
| `M2` | 20 |
| `M1` | 19 |
| `B` | 6 |
| `LS` | 14 |
| `RS` | 15 |
| `X` | 4 |
| `LB` | 8 |
| `RB` | 9 |
| `A` | 5 |
| `Select` | 12 |
| `Home` | 16 |
| `Start` | 13 |
| `Y` | 7 |
| `M3` | 24 |
| `M4` | 25 |

## Output Capabilities

uinput device name: **Xbox Elite Series 2 GameSir** | VID `0x045e` | PID `0x0b00`

### Axes

| Field | Code | Min | Max | Fuzz | Flat |
|-------|------|-----|-----|------|------|
| `lt` | `ABS_Z` | 0 | 255 | 0 | 0 |
| `left_x` | `ABS_X` | -32768 | 32767 | 16 | 128 |
| `rt` | `ABS_RZ` | 0 | 255 | 0 | 0 |
| `right_x` | `ABS_RX` | -32768 | 32767 | 16 | 128 |
| `left_y` | `ABS_Y` | -32768 | 32767 | 16 | 128 |
| `right_y` | `ABS_RY` | -32768 | 32767 | 16 | 128 |

### Buttons

| Button | Event Code |
|--------|------------|
| `Capture` | `BTN_MISC` |
| `M2` | `BTN_TRIGGER_HAPPY2` |
| `M1` | `BTN_TRIGGER_HAPPY1` |
| `B` | `BTN_EAST` |
| `LS` | `BTN_THUMBL` |
| `RS` | `BTN_THUMBR` |
| `X` | `BTN_NORTH` |
| `LB` | `BTN_TL` |
| `RB` | `BTN_TR` |
| `A` | `BTN_SOUTH` |
| `Select` | `BTN_SELECT` |
| `Home` | `BTN_MODE` |
| `Start` | `BTN_START` |
| `Y` | `BTN_WEST` |
| `M3` | `BTN_TRIGGER_HAPPY3` |
| `M4` | `BTN_TRIGGER_HAPPY4` |

39 changes: 39 additions & 0 deletions src/config/device.zig
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,27 @@ pub const InitConfig = struct {
feature_report: ?[]const i64 = null,
};

/// Opt-in physical session maintenance. Only the configured packet is sent;
/// callers must establish its protocol purpose before enabling this section.
pub const KeepaliveConfig = struct {
interface: i64,
packet: []const u8,
report_size: i64,
period_ms: i64,
sequence_offset: i64,
sequence_start: i64 = 1,
sequence_skip_zero: bool = false,
startup_count: i64 = 0,
startup_interval_ms: i64 = 50,
};

pub const DeviceInfo = struct {
name: []const u8,
vid: i64,
pid: i64,
interface: []const InterfaceConfig,
init: ?InitConfig = null,
keepalive: ?KeepaliveConfig = null,
mode: ?[]const u8 = null,
block_kernel_drivers: ?[]const []const u8 = null,
};
Expand Down Expand Up @@ -520,6 +535,24 @@ pub fn validate(cfg: *const DeviceConfig) !void {
}
}
}
if (cfg.device.keepalive) |ka| {
if (!interfaceExists(cfg, ka.interface) or isSuppressInterface(cfg, ka.interface)) return error.InvalidConfig;
if (ka.report_size < 1 or ka.report_size > 64 or
ka.period_ms < 10 or ka.period_ms > 60000 or
ka.startup_count < 0 or ka.startup_count > 32 or
ka.startup_interval_ms < 10 or ka.startup_interval_ms > 60000 or
ka.sequence_start < 0 or ka.sequence_start > 255 or
(ka.sequence_skip_zero and ka.sequence_start == 0) or
ka.sequence_offset < 0 or ka.sequence_offset >= ka.report_size) return error.InvalidConfig;
var nibble_count: usize = 0;
for (ka.packet) |ch| {
if (ch == ' ') continue;
_ = std.fmt.charToDigit(ch, 16) catch return error.InvalidConfig;
nibble_count += 1;
}
if (nibble_count == 0 or nibble_count % 2 != 0 or nibble_count / 2 > ka.report_size or
ka.sequence_offset >= nibble_count / 2) return error.InvalidConfig;
}
if (cfg.device.init) |init_cfg| {
if (init_cfg.interface) |iface_id| {
if (!interfaceExists(cfg, iface_id)) return error.InvalidConfig;
Expand Down Expand Up @@ -895,6 +928,10 @@ fn validateOutputConfig(cfg: *const DeviceConfig, out: *const OutputConfig) !voi

// uhid+pid requires [output.imu] as the UHID routing gate.
if (is_uhid and is_pid) {
if (cfg.device.keepalive != null) {
std.log.warn("device '{s}': keepalive cannot be combined with UHID PID passthrough; disable keepalive or select queued rumble output", .{cfg.device.name});
return error.InvalidConfig;
}
if (out.imu == null) return error.InvalidConfig;
}

Expand Down Expand Up @@ -958,6 +995,8 @@ fn lintAllowlistFor(header: []const u8) ?[]const []const u8 {
std.mem.eql(u8, child, "aux.buttons")) return null; // free-form
}

if (std.mem.eql(u8, header, "device.keepalive")) return sf(KeepaliveConfig);

if (std.mem.eql(u8, header, "wasm")) return sf(WasmConfig);
if (std.mem.eql(u8, header, "wasm.overrides")) return sf(WasmOverridesConfig);

Expand Down
Loading
Loading