Skip to content
Merged
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
3 changes: 3 additions & 0 deletions compat/libusb-1.0/libusb.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ static inline libusb_device_handle *libusb_open_device_with_vid_pid(
static inline int libusb_detach_kernel_driver(libusb_device_handle *h, int i) {
(void)h; (void)i; return -1;
}
static inline int libusb_attach_kernel_driver(libusb_device_handle *h, int i) {
(void)h; (void)i; return -1;
}
static inline int libusb_claim_interface(libusb_device_handle *h, int i) {
(void)h; (void)i; return -1;
}
Expand Down
23 changes: 19 additions & 4 deletions devices/flydigi/vader5.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,28 @@
name = "Flydigi Vader 5 Pro"
vid = 0x37d7
pid = 0x2401
block_kernel_drivers = ["xpad"]
block_kernel_drivers = ["xpad", "hid_generic", "usbhid"]

# Only IF1 is used. IF0 is vendor-class (bInterfaceClass=255) with no
# hidraw node; the reference driver also uses IF1 exclusively.
# The pad exposes three HID interfaces (IF1 vendor 0xffa0, IF2 Mouse, IF3
# vendor 0xffee), each producing a hidraw node via hid-generic. padctl claims
# all three so the kernel exposes no hidraw/evdev node for the physical pad;
# Steam reads hidraw directly, so any kernel-owned node would still surface
# the pad as a generic Xbox controller. IF1 is read for input + rumble + init;
# IF2/IF3 are claimed suppress-only — never read or written, only evicted from
# hid-generic so their hidraw nodes vanish.
[[device.interface]]
id = 1
class = "hid" # hidraw; EP2 IN 32B extended input, EP6 OUT 32B config commands
class = "vendor" # EP2 IN 32B extended input, EP6 OUT 32B config commands
ep_in = 0x82
ep_out = 0x06

[[device.interface]]
id = 2
class = "suppress" # Mouse HID

[[device.interface]]
id = 3
class = "suppress" # vendor 0xffee HID

[device.init]
interface = 1
Expand Down
268 changes: 266 additions & 2 deletions src/config/device.zig
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,83 @@ fn fieldTypeSize(type_str: []const u8) ?i64 {
return null;
}

pub fn isSuppressClass(class: []const u8) bool {
return std.mem.eql(u8, class, "suppress");
}

fn isSuppressInterface(cfg: *const DeviceConfig, iface_id: i64) bool {
for (cfg.device.interface) |iface| {
if (iface.id == iface_id) return isSuppressClass(iface.class);
}
return false;
}

/// Number of interfaces opened into the devices[] array (everything except
/// suppress-class interfaces). Suppress interfaces are claimed separately and
/// consume no DeviceIO slot.
pub fn openedInterfaceCount(cfg: *const DeviceConfig) usize {
var n: usize = 0;
for (cfg.device.interface) |iface| {
if (!isSuppressClass(iface.class)) n += 1;
}
return n;
}

/// Map a USB interface id to its index in the devices[] array, counting only
/// non-suppress interfaces. Returns null when the id is unknown or suppressed.
pub fn deviceIndexForInterface(cfg: *const DeviceConfig, iface_id: i64) ?usize {
var idx: usize = 0;
for (cfg.device.interface) |iface| {
if (isSuppressClass(iface.class)) continue;
if (iface.id == iface_id) return idx;
idx += 1;
}
return null;
}

/// Inverse of deviceIndexForInterface: map a devices[] index back to its
/// InterfaceConfig, skipping suppress interfaces. Returns null when out of range.
pub fn interfaceForDeviceIndex(cfg: *const DeviceConfig, dev_idx: usize) ?*const InterfaceConfig {
var idx: usize = 0;
for (cfg.device.interface) |*iface| {
if (isSuppressClass(iface.class)) continue;
if (idx == dev_idx) return iface;
idx += 1;
}
return null;
}

pub fn validate(cfg: *const DeviceConfig) !void {
for (cfg.device.interface) |iface| {
const is_hid = std.mem.eql(u8, iface.class, "hid");
const is_vendor = std.mem.eql(u8, iface.class, "vendor");
const is_suppress = std.mem.eql(u8, iface.class, "suppress");
if (!is_hid and !is_vendor and !is_suppress) return error.InvalidConfig;
if (is_suppress and (iface.ep_in != null or iface.ep_out != null))
return error.InvalidConfig;
}

// An all-suppress config opens no read fd, so it can never be observed
// for liveness; require at least one readable (hid/vendor) interface.
if (openedInterfaceCount(cfg) == 0) return error.InvalidConfig;

// A suppress interface is claimed only to evict the kernel driver; it is
// never read or written, so no report/command/init may reference it.
for (cfg.report) |report| {
if (isSuppressInterface(cfg, report.interface)) return error.InvalidConfig;
}
if (cfg.commands) |cmds| {
var it = cmds.map.iterator();
while (it.next()) |entry| {
if (isSuppressInterface(cfg, entry.value_ptr.interface)) return error.InvalidConfig;
}
}
if (cfg.device.init) |init_cfg| {
if (init_cfg.interface) |iface_id| {
if (isSuppressInterface(cfg, iface_id)) return error.InvalidConfig;
}
}

for (cfg.report) |report| {
if (report.fields) |fields| {
var seen_buf: [64][]const u8 = undefined;
Expand Down Expand Up @@ -529,6 +605,194 @@ test "device: load flydigi/vader5.toml succeeds" {
try std.testing.expectEqualStrings("extended", cfg.report[0].name);
}

test "device: vader5 IF1 is claimed via libusb (vendor transport)" {
const allocator = std.testing.allocator;
const result = try parseFile(allocator, "devices/flydigi/vader5.toml");
defer result.deinit();

const cfg = result.value;
// IF1 read transport + IF2/IF3 suppress-only claims.
try std.testing.expectEqual(@as(usize, 3), cfg.device.interface.len);
try std.testing.expectEqual(@as(usize, 1), openedInterfaceCount(&cfg));
const if1 = cfg.device.interface[0];
try std.testing.expectEqual(@as(i64, 1), if1.id);
try std.testing.expectEqualStrings("vendor", if1.class);
try std.testing.expectEqual(@as(i64, 0x82), if1.ep_in orelse return error.MissingEpIn);
try std.testing.expectEqual(@as(i64, 0x06), if1.ep_out orelse return error.MissingEpOut);

try std.testing.expectEqualStrings("suppress", cfg.device.interface[1].class);
try std.testing.expectEqual(@as(i64, 2), cfg.device.interface[1].id);
try std.testing.expect(cfg.device.interface[1].ep_in == null);
try std.testing.expectEqualStrings("suppress", cfg.device.interface[2].class);
try std.testing.expectEqual(@as(i64, 3), cfg.device.interface[2].id);

const init_cfg = cfg.device.init orelse return error.MissingInit;
try std.testing.expectEqual(@as(i64, 1), init_cfg.interface orelse return error.MissingInterface);
try std.testing.expect(init_cfg.commands != null);
try std.testing.expect(init_cfg.enable != null);
}

fn suppressIndexToml(comptime suppress_first: bool) []const u8 {
const report_block =
\\[[device.interface]]
\\id = 5
\\class = "hid"
\\
;
const suppress_block =
\\[[device.interface]]
\\id = 9
\\class = "suppress"
\\
;
const head =
\\[device]
\\name = "Mixed"
\\vid = 0x1234
\\pid = 0x5678
\\
;
const tail =
\\
\\[[report]]
\\name = "main"
\\interface = 5
\\size = 16
\\
\\[report.match]
\\offset = 0
\\expect = [0x00]
\\
\\[report.fields]
\\left_x = { offset = 6, type = "i16le" }
\\
;
return if (suppress_first)
head ++ suppress_block ++ report_block ++ tail
else
head ++ report_block ++ suppress_block ++ tail;
}

test "device: suppress interface excluded from devices[] index regardless of order" {
const allocator = std.testing.allocator;

inline for (.{ true, false }) |suppress_first| {
const result = try parseString(allocator, suppressIndexToml(suppress_first));
defer result.deinit();
const cfg = result.value;

try validate(&cfg);
try std.testing.expectEqual(@as(usize, 2), cfg.device.interface.len);
// Only the hid interface gets a devices[] slot.
try std.testing.expectEqual(@as(usize, 1), openedInterfaceCount(&cfg));
// The report interface (id 5) always resolves to devices[0] whether the
// suppress interface (id 9) precedes or follows it.
try std.testing.expectEqual(@as(?usize, 0), deviceIndexForInterface(&cfg, 5));
try std.testing.expectEqual(@as(?usize, null), deviceIndexForInterface(&cfg, 9));
// Inverse mapping yields the report interface, never the suppress one.
const iface0 = interfaceForDeviceIndex(&cfg, 0) orelse return error.MissingInterface;
try std.testing.expectEqual(@as(i64, 5), iface0.id);
try std.testing.expectEqual(@as(?*const InterfaceConfig, null), interfaceForDeviceIndex(&cfg, 1));
}
}

test "device: validate rejects suppress interface with endpoints" {
const allocator = std.testing.allocator;
const bad =
\\[device]
\\name = "Bad"
\\vid = 0x1234
\\pid = 0x5678
\\
\\[[device.interface]]
\\id = 1
\\class = "suppress"
\\ep_in = 0x81
\\
\\[[report]]
\\name = "main"
\\interface = 1
\\size = 8
\\
\\[report.match]
\\offset = 0
\\expect = [0x00]
;
try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad));
}

test "device: validate rejects report referencing a suppress interface" {
const allocator = std.testing.allocator;
const bad =
\\[device]
\\name = "Bad"
\\vid = 0x1234
\\pid = 0x5678
\\
\\[[device.interface]]
\\id = 1
\\class = "suppress"
\\
\\[[report]]
\\name = "main"
\\interface = 1
\\size = 8
\\
\\[report.match]
\\offset = 0
\\expect = [0x00]
;
try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad));
}

test "device: validate rejects report->suppress reference even with a readable interface" {
const allocator = std.testing.allocator;
// A readable hid interface (id 5) keeps openedInterfaceCount >= 1 so the
// all-suppress guard does NOT fire; the report targets the suppress
// interface (id 9), so only the report->suppress check can reject it.
const bad =
\\[device]
\\name = "Mixed"
\\vid = 0x1234
\\pid = 0x5678
\\
\\[[device.interface]]
\\id = 5
\\class = "hid"
\\
\\[[device.interface]]
\\id = 9
\\class = "suppress"
\\
\\[[report]]
\\name = "main"
\\interface = 9
\\size = 8
\\
\\[report.match]
\\offset = 0
\\expect = [0x00]
;
try std.testing.expectError(error.InvalidConfig, parseString(allocator, bad));
}

test "device: validate rejects an all-suppress config with no readable interface" {
const ifaces = [_]InterfaceConfig{
.{ .id = 1, .class = "suppress" },
.{ .id = 2, .class = "suppress" },
};
const cfg = DeviceConfig{
.device = .{
.name = "AllSuppress",
.vid = 0x1234,
.pid = 0x5678,
.interface = &ifaces,
},
.report = &.{},
};
try std.testing.expectError(error.InvalidConfig, validate(&cfg));
}

test "device: force_feedback.auto_stop defaults to true when unspecified" {
const allocator = std.testing.allocator;
const result = try parseString(allocator, test_toml);
Expand Down Expand Up @@ -614,7 +878,7 @@ test "device: offset out of bounds returns error" {
try std.testing.expectError(error.OffsetOutOfBounds, parseString(allocator, bad));
}

test "device: duplicate field name returns error" {
test "device: validate rejects a config with no interface at all" {
const cfg = DeviceConfig{
.device = .{
.name = "test",
Expand All @@ -624,7 +888,7 @@ test "device: duplicate field name returns error" {
},
.report = &.{},
};
try validate(&cfg);
try std.testing.expectError(error.InvalidConfig, validate(&cfg));
}

test "device: invalid transform returns error" {
Expand Down
Loading
Loading