Watch
1
0
Fork
You've already forked zig-hidapi
0
A pure-Zig library for talking to USB and Bluetooth HID devices on Linux through the kernel's hidraw interface.
  • Zig 98%
  • Nix 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie e5e463b5a4
give each optimize mode its own ci job
A codeberg-small runner is capped at five minutes, and the test job ran
all three optimize modes in sequence: the run before this one took about
five minutes wall clock, so the next thing added to the suite would have
pushed it over. The job is now a matrix over Debug, ReleaseSafe and
ReleaseFast, so each mode gets its own budget and the three run
concurrently. They appear as `test (Debug)` and so on, and `needs: test`
on the docs job waits for all three, so a red mode still keeps the
documentation from being republished.

The cost is three compiler downloads rather than one. setup-zig caches
the tarball between runs, so that falls mostly on the first run after a
Zig version change.

The comment on the test step now also records that only the Debug job
runs the docs server's tests, since build.zig leaves them out of the
other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JRTRSt3F4KkHY7ip2MgEk
2026-08-27 23:31:19 -05:00
.forgejo/workflows give each optimize mode its own ci job 2026-08-27 23:31:19 -05:00
LICENSES updates for 0.16 2026-02-08 23:10:23 -06:00
src link the udev rule from the iterator documentation 2026-08-27 22:29:44 -05:00
tools serve the api documentation from a build step 2026-08-27 23:08:33 -05:00
.gitignore updates for 0.16 2026-02-08 23:10:23 -06:00
build.zig run the docs server tests only in debug 2026-08-27 23:31:19 -05:00
build.zig.zon serve the api documentation from a build step 2026-08-27 23:08:33 -05:00
flake.lock update nixpkgs 2026-08-27 20:58:20 -05:00
flake.nix document Radicle checkout and add the rad CLI to the devshell 2026-08-23 15:31:43 -05:00
README.md serve the api documentation from a build step 2026-08-27 23:08:33 -05:00
REUSE.toml updates for 0.16 2026-02-08 23:10:23 -06:00
typos.toml fix licenses 2026-08-23 15:03:54 -05:00

zig-hidapi

A pure-Zig library for talking to USB and Bluetooth HID devices on Linux through the kernel's hidraw interface.

Unlike bindings to the C hidapi library, this package has no C dependency at all. It issues the HIDIOC* ioctls directly against /dev/hidraw* and is built on Zig 0.16's std.Io interface, so every blocking operation is dispatched through the caller's I/O implementation rather than blocking a thread outright.

Requirements

  • Zig 0.16
  • Linux with the hidraw driver (CONFIG_HIDRAW), i.e. /dev/hidraw* present

Only Linux is supported. There is no Windows, macOS, or BSD backend, and none is planned in the current design.

Installation

Fetch the package into your build.zig.zon:

zig fetch --save git+https://codeberg.org/jcollie/zig-hidapi.git

Then wire the module up in build.zig:

const hidapi = b.dependency("hidapi", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("hidapi", hidapi.module("hidapi"));

Usage

Every call takes a std.Io as its first argument. Declaring main with a std.process.Init parameter is the easiest way to get one: the runtime builds an Io implementation appropriate for the target and hands it over as init.io. Constructing one yourself (std.Io.Threaded, std.Io.Uring) works just as well, and is what you need when the caller is a library rather than main.

Enumerating devices

const std = @import("std");
const hidapi = @import("hidapi");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var name_buf: [256]u8 = undefined;
    var uniq_buf: [64]u8 = undefined;
    var phys_buf: [256]u8 = undefined;
    var it: hidapi.DeviceInfoIterator = .init;

    while (try it.next(io)) |info| {
        defer info.device.close(io);

        const name = try info.device.getRawName(io, &name_buf) orelse "(unnamed)";
        const uniq = try info.device.getRawUniq(io, &uniq_buf) orelse "(none)";
        const phys = try info.device.getPhysicalLocation(io, &phys_buf) orelse "(unknown)";
        std.debug.print("{x:0>4}:{x:0>4} [{t}] {s} ({s}) at {s}\n", .{
            info.vendor,
            info.product,
            info.bustype,
            name,
            uniq,
            phys,
        });
    }
}

The iterator walks /dev/hidraw0 through /dev/hidraw63, silently skipping any node that does not exist or cannot be opened. Devices it yields are already open; the caller owns them and is responsible for closing them.

getRawName, getRawUniq, and getPhysicalLocation return an optional, null for a device that reports nothing at all, which is why the example supplies a placeholder. A null uniq is the common case rather than an oddity: usbhid only fills it in when the device carries a serial number string, while the Bluetooth transports always seed it from the hardware address. The physical location is the path through the USB controller, hubs and ports for a USB device, and the hardware address for a Bluetooth one, so it stays the same across replugs of whatever is in that port while uniq follows the device itself. The string they do return is NUL terminated and aliases the buffer passed in, so the example uses a separate buffer for each rather than letting a later call overwrite an earlier result.

Opening a device directly

const device = try hidapi.Device.open(io, 0); // /dev/hidraw0
defer device.close(io);

open reports error.HIDDeviceDoesNotExist when the node is missing and error.HIDDeviceNoAccess when permissions deny it.

Reading and writing reports

// Write an output report. The first byte is the report ID; use 0 for
// devices that do not use numbered reports.
var out: [17]u8 = @splat(0);
out[0] = 0x00;
out[1] = 0x42;
_ = try device.write(io, &out);

// Read an input report.
var in: [64]u8 = undefined;
const report = try device.read(io, &in);
std.debug.print("read {d} bytes\n", .{report.len});

Feature reports

// Send a feature report (first byte is the report ID).
_ = try device.sendFeatureReport(io, &.{ 0x02, 0xff, 0x00 });

// Request a feature report; set the first byte to the report ID you want.
var feature: [32]u8 = undefined;
feature[0] = 0x02;
const got = try device.getFeatureReport(io, &feature);
std.debug.print("feature report: {x}\n", .{got});

Report descriptors

const size = try device.getReportDescriptorSize(io);

var descriptor: [4096]u8 = undefined;
const bytes = try device.getReportDescriptor(io, descriptor[0..size]);
std.debug.print("descriptor: {d} bytes\n", .{bytes.len});

API overview

The generated reference, covering every declaration with its doc comments, is published at jcollie.codeberg.page/zig-hidapi. CI rebuilds it whenever main goes green. What follows is a summary.

hidapi.Device

An open hidraw file descriptor.

Function Description
open(io, minor) Open /dev/hidraw{minor} read-write
close(io) Close the descriptor
read(io, buf) Read an input report from the interrupt IN endpoint
write(io, buf) Write an output report (first byte is the report ID)
getInputReport(io, buf) Request an input report over the control endpoint
getFeatureReport(io, buf) Request a feature report over the control endpoint
sendFeatureReport(io, data) Send a feature report over the control endpoint
getReportDescriptorSize(io) Size of the HID report descriptor
getReportDescriptor(io, buf) Copy the HID report descriptor into buf
getRawName(io, buf) Vendor and product strings, UTF-8, or null
getRawUniq(io, buf) Per-device identifier (serial number or MAC), or null
getPhysicalLocation(io, buf) USB physical path or Bluetooth MAC address, or null
getDeviceInfo(io) Bus type, vendor ID, and product ID as a DeviceInfo
getBusType(io) Bus type only
getVendorID(io) Vendor ID only
getProductID(io) Product ID only

For every call that takes a report buffer, the first byte is the report ID — 0x00 for devices that do not use numbered reports — so the buffer must be one byte longer than the report itself.

hidapi.DeviceInfo

The device it was read from, plus the device's bustype (a BUS enum covering USB, BLUETOOTH, I2C, and the rest of the kernel's bus types), vendor, and product IDs.

hidapi.DeviceInfoIterator

init then next(io) to walk the available hidraw nodes, as shown above.

Permissions

/dev/hidraw* nodes are normally root-only, so Device.open will fail with error.HIDDeviceNoAccess for an unprivileged process. Grant access with a udev rule rather than running as root — for example, in /etc/udev/rules.d/70-hidraw.rules:

KERNEL=="hidraw*", ATTRS{idVendor}=="1234", ATTRS{idProduct}=="5678", MODE="0660", GROUP="plugdev"

Then udevadm control --reload-rules && udevadm trigger, and make sure your user is in the group you named.

Cloning with Radicle

This repository is also published on Radicle, a peer-to-peer code collaboration network. Its Repository ID is:

rad:z2XSKZUPc81eR9a7RLZrJbZpkS4su

With the rad CLI installed and a local identity created (rad auth --alias <name>), start your node and clone:

rad node start
rad clone rad:z2XSKZUPc81eR9a7RLZrJbZpkS4su

rad clone finds seeds seeding the repository through your node's routing table, so the node needs to be running and connected. If discovery fails because no seed has been found yet, name one directly:

rad clone rad:z2XSKZUPc81eR9a7RLZrJbZpkS4su --seed <NID>

The clone checks out the default branch (main) and leaves you seeding the repository, so your node will serve it to other peers. rad sync pulls later changes.

To publish work back, push to the rad remote and open a patch:

git push rad HEAD:refs/heads/my-change
rad patch open

The repository is delegated to a single key, did:key:z6MkoM8gqRFf1hARf3cSX2hhe7kgTfKSQpNksR9uKErWotKq, which is what authorizes changes to main.

Development

A Nix flake provides the toolchain (Zig 0.16, reuse, pinact, and the rad CLI):

nix develop

Build and test:

zig build
zig build test
zig build docs        # API reference into zig-out/docs
zig build docs-serve  # ...and serve it at http://127.0.0.1:8000/

The documentation is a WebAssembly viewer that fetches sources.tar, so it has to be served over HTTP; opening zig-out/docs/index.html from the filesystem shows an empty page, which is why there is a step that serves it and why zig std works the same way. -Ddocs-port=N chooses another port. CI publishes the same output to the address above.

The enumerate test opens real devices on the host, so its results depend on what hardware is attached and on the permissions described above.

This repository follows the REUSE specification for licensing metadata and uses typos for spell checking:

reuse lint
typos

License

MIT — see LICENSES/MIT.txt.

Copyright © 2024 Jeffrey C. Ollie.