Host-side USB for Elixir on Linux, over a usbfs-scoped syscall NIF.
Each open device is one BodgeUSB process that owns the usbfs fd and runs
an asynchronous URB engine (submit -> enif_select readiness -> reap): no
scheduler thread is ever blocked on a transfer, and one process can
pipeline many transfers at once.
Example
{:ok, dev} = BodgeUSB.open(0x0525, 0xA4A0)
BodgeUSB.detach_driver(dev, 0)
:ok = BodgeUSB.claim_interface(dev, 0)
{:ok, data} = BodgeUSB.bulk_in(dev, 0x81, 512)
{:ok, _n} = BodgeUSB.bulk_out(dev, 0x02, data)
BodgeUSB.close(dev)The async primitive
submit/3 hands a transfer to the engine and returns {:ok, ref}
immediately; the completion arrives as a message (see result/0):
{:bodge_usb, ref, {:ok, result} | {:error, reason}}cancel/2 discards an in-flight transfer (its completion then arrives as
{:error, :cancelled}) and await/3 blocks on one completion. The
blocking calls (bulk_in/4 and friends) are submit + await, nothing more:
they block only the caller, never the engine.
Watch devices come and go with watch_hotplug/1.
Caveats
- Everything is Linux-only and needs access to
/dev/bus/usb(root or udev rules); hotplug additionally needs the netlink uevent socket (root). - If the process that should receive a completion dies first, the engine discards the transfer and drops the completion.
- The engine traps exits, so a linked owner crashing,
close/1, or an ordinary exit still runsterminate/2, which closes the fd. A:killexit is the exception: it bypassesterminate/2, and while a select is armed the fd and its URB memory stay pinned by the NIF resource until the VM shuts down, because the GC destructor cannot run with an outstanding select. Close withclose/1; do not brutal-kill.
Summary
Types
An open device (an engine process).
Per-packet isochronous outcome: {actual_length, status}.
A transfer request for submit/3. Direction is bit 7 of the endpoint
address (bulk/interrupt/iso) or of request_type (control): IN variants
take a byte count to read, OUT variants take iodata to send.
A completed transfer's result, as delivered in {:bodge_usb, ref, result}:
the received binary (IN), the byte count written (OUT), or
{:iso, data_or_bytes, packets} for isochronous. Iso IN data is the
received packets' bytes concatenated in order; split it with the
per-packet actual_length list.
Functions
Reattach the kernel driver to an interface.
Block until the completion for ref arrives and return its result.
Bulk IN transfer on an IN endpoint address (bit 7 set). Blocks the caller
until it completes, times out, or fails.
Returns {:ok, binary} / {:error, :timeout} / {:error, errno_atom}.
Bulk OUT transfer on an OUT endpoint address (bit 7 clear). Blocks the
caller until it completes, times out, or fails. opts[:zero_packet]
appends a terminating zero-length packet.
Returns {:ok, bytes_written} / {:error, :timeout} / {:error, errno_atom}.
Cancel an in-flight transfer by its submit/3 ref. The completion is still
delivered, as {:error, :cancelled} (or the real result if it had already
finished when the cancel arrived).
Returns a specification to start this module under a supervisor.
Claim an interface so its endpoints can be used for transfers.
Clear an endpoint's halt/stall (recovery after an :epipe transfer).
Close a device: stops its engine, releasing the fd.
Standard device-recipient control IN. See control_transfer/7.
Standard device-recipient control OUT. See control_transfer/7.
Control transfer with an explicit request_type (bmRequestType), for
class/vendor and non-device-recipient requests. Direction is bit 7 of
request_type (IN takes a length and returns {:ok, binary}, OUT takes
iodata and returns {:ok, bytes_written}).
Detach the kernel driver from an interface so it can be claimed.
Find the first device matching vendor_id/product_id, or nil.
Name of the kernel driver bound to an interface (or {:error, :enodata}).
Interrupt IN transfer on an IN endpoint address. Blocks like bulk_in/4.
Interrupt OUT transfer on an OUT endpoint address. Blocks like bulk_out/5.
List all USB devices under /dev/bus/usb with parsed descriptors.
Open a device and start its engine. Accepts a BodgeUSB.DeviceRef.t/0, a
usbfs path, or a vendor_id, product_id pair. Returns {:ok, device}.
Release a claimed interface.
Reset the device. The device re-enumerates, so the handle may be stale afterwards.
Select an interface's alternate setting.
Start an engine directly, for supervision trees. Options
Read and decode the device's string descriptor index (1..255). Returns
{:error, :no_string} for index 0 (the LANGID list) or a device with no
such string.
Submit a transfer asynchronously. Returns {:ok, ref} at once; the
completion arrives later as {:bodge_usb, ref, result} (see result/0).
Start watching for hotplug events over the kernel netlink uevent socket
(usually needs root). opts[:notify] (a pid or list of pids, default: the
caller) receives device-level add/remove events as {:usb_hotplug, event},
where event is a map
Types
@type device() :: GenServer.server()
An open device (an engine process).
@type iso_packet() :: {non_neg_integer(), :ok | atom()}
Per-packet isochronous outcome: {actual_length, status}.
@type request() :: {:bulk_in, 0..255, non_neg_integer()} | {:bulk_out, 0..255, iodata()} | {:interrupt_in, 0..255, non_neg_integer()} | {:interrupt_out, 0..255, iodata()} | {:control, 0..255, 0..255, 0..65535, 0..65535, iodata() | non_neg_integer()} | {:iso_in, 0..255, [0..65535]} | {:iso_out, 0..255, [0..65535], iodata()}
A transfer request for submit/3. Direction is bit 7 of the endpoint
address (bulk/interrupt/iso) or of request_type (control): IN variants
take a byte count to read, OUT variants take iodata to send.
@type result() :: {:ok, binary() | non_neg_integer() | {:iso, binary() | non_neg_integer(), [iso_packet()]}} | {:error, atom()}
A completed transfer's result, as delivered in {:bodge_usb, ref, result}:
the received binary (IN), the byte count written (OUT), or
{:iso, data_or_bytes, packets} for isochronous. Iso IN data is the
received packets' bytes concatenated in order; split it with the
per-packet actual_length list.
Functions
@spec attach_driver(device(), non_neg_integer()) :: :ok | {:error, atom()}
Reattach the kernel driver to an interface.
Block until the completion for ref arrives and return its result.
timeout here bounds the wait, not the transfer (use submit/3's
:timeout for that). Exits like GenServer.call/3 if the engine dies or
the wait times out, except that a gracefully closed engine delivers
{:error, :closed} first.
@spec bulk_in(device(), 0..255, non_neg_integer(), timeout()) :: {:ok, binary()} | {:error, atom()}
Bulk IN transfer on an IN endpoint address (bit 7 set). Blocks the caller
until it completes, times out, or fails.
Returns {:ok, binary} / {:error, :timeout} / {:error, errno_atom}.
@spec bulk_out(device(), 0..255, iodata(), timeout(), keyword()) :: {:ok, non_neg_integer()} | {:error, atom()}
Bulk OUT transfer on an OUT endpoint address (bit 7 clear). Blocks the
caller until it completes, times out, or fails. opts[:zero_packet]
appends a terminating zero-length packet.
Returns {:ok, bytes_written} / {:error, :timeout} / {:error, errno_atom}.
Cancel an in-flight transfer by its submit/3 ref. The completion is still
delivered, as {:error, :cancelled} (or the real result if it had already
finished when the cancel arrived).
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec claim_interface(device(), non_neg_integer()) :: :ok | {:error, atom()}
Claim an interface so its endpoints can be used for transfers.
Clear an endpoint's halt/stall (recovery after an :epipe transfer).
@spec close(device()) :: :ok
Close a device: stops its engine, releasing the fd.
@spec control_in(device(), 0..255, 0..65535, 0..65535, non_neg_integer(), timeout()) :: {:ok, binary()} | {:error, atom()}
Standard device-recipient control IN. See control_transfer/7.
@spec control_out(device(), 0..255, 0..65535, 0..65535, iodata(), timeout()) :: {:ok, non_neg_integer()} | {:error, atom()}
Standard device-recipient control OUT. See control_transfer/7.
@spec control_transfer( device(), 0..255, 0..255, 0..65535, 0..65535, iodata() | non_neg_integer(), timeout() ) :: {:ok, binary()} | {:ok, non_neg_integer()} | {:error, atom()}
Control transfer with an explicit request_type (bmRequestType), for
class/vendor and non-device-recipient requests. Direction is bit 7 of
request_type (IN takes a length and returns {:ok, binary}, OUT takes
iodata and returns {:ok, bytes_written}).
@spec detach_driver(device(), non_neg_integer()) :: :ok | {:error, atom()}
Detach the kernel driver from an interface so it can be claimed.
@spec find_device(0..65535, 0..65535) :: BodgeUSB.DeviceRef.t() | nil
Find the first device matching vendor_id/product_id, or nil.
@spec get_driver(device(), non_neg_integer()) :: {:ok, binary()} | {:error, atom()}
Name of the kernel driver bound to an interface (or {:error, :enodata}).
@spec interrupt_in(device(), 0..255, non_neg_integer(), timeout()) :: {:ok, binary()} | {:error, atom()}
Interrupt IN transfer on an IN endpoint address. Blocks like bulk_in/4.
@spec interrupt_out(device(), 0..255, iodata(), timeout(), keyword()) :: {:ok, non_neg_integer()} | {:error, atom()}
Interrupt OUT transfer on an OUT endpoint address. Blocks like bulk_out/5.
@spec list_devices() :: [BodgeUSB.DeviceRef.t()]
List all USB devices under /dev/bus/usb with parsed descriptors.
@spec open(BodgeUSB.DeviceRef.t() | Path.t()) :: GenServer.on_start()
Open a device and start its engine. Accepts a BodgeUSB.DeviceRef.t/0, a
usbfs path, or a vendor_id, product_id pair. Returns {:ok, device}.
The engine is linked to the caller, so the fd is released if the caller
dies. The fd is opened before the engine is started, so a device that
cannot be opened (permissions, unplugged, busy) returns {:error, reason}
without starting a process to crash a non-trapping caller through the
link. (Contrast start_link/1 with a :node, which opens inside init
and so exits the caller on failure; that is the right shape under a
supervisor.)
@spec open(0..65535, 0..65535) :: GenServer.on_start() | {:error, :not_found}
@spec release_interface(device(), non_neg_integer()) :: :ok | {:error, atom()}
Release a claimed interface.
Reset the device. The device re-enumerates, so the handle may be stale afterwards.
@spec set_interface(device(), non_neg_integer(), non_neg_integer()) :: :ok | {:error, atom()}
Select an interface's alternate setting.
@spec start_link(keyword()) :: GenServer.on_start()
Start an engine directly, for supervision trees. Options:
:node- usbfs path to open (O_RDWR) insideinit(a failed open exits the caller through the link; preferopen/1outside a supervisor), or:handle- an already-open NIF handle to adopt:name- optional GenServer name
Read and decode the device's string descriptor index (1..255). Returns
{:error, :no_string} for index 0 (the LANGID list) or a device with no
such string.
Submit a transfer asynchronously. Returns {:ok, ref} at once; the
completion arrives later as {:bodge_usb, ref, result} (see result/0).
Options:
:timeout- ms until the engine cancels the transfer and delivers{:error, :timeout};:infinity(the default) arms no timer.:reply_to- pid to receive the completion (default: the caller). If it dies while the transfer is in flight, the engine discards the transfer.:zero_packet- append a terminating zero-length packet (OUT bulk and interrupt only).
A malformed request (unknown tuple, direction and payload disagreeing,
out-of-range fields) raises ArgumentError in the caller. Returns
{:error, reason} if the kernel refuses the submission (nothing is in
flight and no message will arrive).
@spec watch_hotplug(keyword()) :: GenServer.on_start()
Start watching for hotplug events over the kernel netlink uevent socket
(usually needs root). opts[:notify] (a pid or list of pids, default: the
caller) receives device-level add/remove events as {:usb_hotplug, event},
where event is a map:
%{action: :add | :remove | :bind | :unbind | :change | :unknown,
busnum: integer | nil, devnum: integer | nil,
devname: "/dev/bus/usb/BBB/DDD" | nil, devpath: String.t(),
product: String.t() | nil}The socket is opened before the watcher starts, so a failed open returns
{:error, reason} without disturbing a non-trapping caller. Stop it with
GenServer.stop/1.