Raw USB host access via vendor bulk endpoints. Android only.
No permission required at the OS-permission level, but Android prompts the
user to grant per-device access via the system dialog when you call
request_permission/2. The grant is per app + device + session; granting
"always" only sticks if the user ticks the checkbox.
iOS calls return the socket unchanged and emit
{:peripheral, :vendor_usb, :error, nil, :unsupported}. See
Mob.Ble for iOS-friendly equivalent transports.
the (forthcoming) Mob.Midi or Mob.Ble.
Lifecycle
list_devices/1 → {:peripheral, :vendor_usb, :devices, _, [device, …]}
request_permission/2 → {:peripheral, :vendor_usb, :permission_granted, _, device}
{:peripheral, :vendor_usb, :permission_denied, _, device}
open/2 → {:peripheral, :vendor_usb, :opened, session, device}
{:peripheral, :vendor_usb, :error, nil, reason}
bulk_write/4 → {:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}
(or :error for failures)
start_reading/3 → {:peripheral, :vendor_usb, :data, session, binary}
(delivered repeatedly; use stop_reading/2 to halt)
stop_reading/2
close/2 → {:peripheral, :vendor_usb, :closed, session, reason}Any unsolicited {:peripheral, :vendor_usb, :disconnected, session, reason}
may arrive at any time (cable unplug, device removed). After
:disconnected, the session handle is dead — drop your reference and call
list_devices/1 again to reacquire.
Example: a USB echo demo
This shape works for any USB device that exposes bulk IN/OUT endpoints. Substitute the VID/PID and frame format for your device.
defmodule MyApp.UsbScreen do
use Mob.Screen
alias Mob.VendorUsb
@my_vid 0x1234
@my_pid 0x5678
def mount(_p, _s, socket) do
{:ok,
socket
|> Mob.Socket.assign(:devices, [])
|> Mob.Socket.assign(:session, nil)
|> VendorUsb.list_devices(vendor_id: @my_vid)}
end
def handle_info({:peripheral, :vendor_usb, :devices, _, devices}, socket) do
{:noreply, Mob.Socket.assign(socket, :devices, devices)}
end
def handle_info({:peripheral, :vendor_usb, :permission_granted, _, dev}, socket) do
{:noreply, VendorUsb.open(socket, dev, interface: 0)}
end
def handle_info({:peripheral, :vendor_usb, :opened, session, _dev}, socket) do
socket =
socket
|> Mob.Socket.assign(:session, session)
|> VendorUsb.start_reading(session)
|> VendorUsb.bulk_write(session, "hello")
{:noreply, socket}
end
def handle_info({:peripheral, :vendor_usb, :data, _session, binary}, socket) do
IO.inspect(binary, label: "from device")
{:noreply, socket}
end
def handle_info({:peripheral, :vendor_usb, :disconnected, _, _}, socket) do
{:noreply, Mob.Socket.assign(socket, :session, nil)}
end
endFraming is your problem
This module is byte-level. USB bulk endpoints do not preserve message
boundaries — the bytes you wrote in one bulk_write/4 call may arrive
on the other end split across multiple chunks, or coalesced with later
writes. Likewise, :data events deliver whatever the OS happens to
hand back from a read; do not assume one event corresponds to one
logical message.
If your device uses a framed protocol (length-prefix, COBS, SLIP,
delimiters, fixed-size records), implement the framer in a layer
above this one. A reasonable pattern is a GenServer that owns the
session, accumulates incoming chunks into a buffer, and drains
complete frames out for higher-level consumers.
Device shape
Devices arrive as maps:
%{
vendor_id: 0x1234,
product_id: 0x5678,
manufacturer: "Acme Inc.",
product: "Widget 9000",
serial: "SN-000001",
# opaque handle the OS uses to refer to this device. Treat as a
# binary; do not parse. Pass back to `request_permission/2` etc.
ref: "/dev/bus/usb/001/002"
}Session handles
open/2 delivers an integer session handle. Session handles are valid
until :disconnected or close/2. They are not persistent across app
restarts — re-enumerate after launch.
Buffer ownership
Binaries you pass to bulk_write/4 are copied into a native-side buffer
before the NIF returns. Binaries delivered via :data are owned by the
BEAM — they will outlive the underlying USB read buffer.
Limits
Maximum write size per call: 16 KiB. Larger writes are rejected with
{:error, :payload_too_large}. Read chunks are bounded by the USB max
packet size for the endpoint (typically 64 B Full Speed, 512 B High
Speed); the native read loop coalesces packets into BEAM-side binaries
bounded by :read_chunk_bytes (default 4 KiB).
Summary
Functions
Send bytes to the device's bulk OUT endpoint.
Close a device session, releasing the interface and freeing the file
descriptor. Idempotent. Always emits
{:peripheral, :vendor_usb, :closed, session, :ok}.
Enumerate connected USB devices.
Open a permitted device and claim an interface.
Ask the OS to prompt the user to grant access to a specific device.
Start a continuous read loop on the bulk IN endpoint.
Stop the read loop started by start_reading/3.
Types
@type device() :: %{ vendor_id: non_neg_integer(), product_id: non_neg_integer(), manufacturer: String.t() | nil, product: String.t() | nil, serial: String.t() | nil, ref: String.t() }
@type session() :: integer()
Functions
@spec bulk_write(Mob.Socket.t(), session(), iodata(), keyword()) :: Mob.Socket.t()
Send bytes to the device's bulk OUT endpoint.
data may be a binary or iolist; it is flattened and copied native-side
before the NIF returns. Maximum size: 16384 bytes.
Options:
:timeout_ms— write timeout (default1000)
Result:
{:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}{:peripheral, :vendor_usb, :error, session, reason}
@spec close(Mob.Socket.t(), session()) :: Mob.Socket.t()
Close a device session, releasing the interface and freeing the file
descriptor. Idempotent. Always emits
{:peripheral, :vendor_usb, :closed, session, :ok}.
@spec list_devices( Mob.Socket.t(), keyword() ) :: Mob.Socket.t()
Enumerate connected USB devices.
Result: {:peripheral, :vendor_usb, :devices, nil, [device, …]}
Options:
:vendor_id— filter to a single VID:product_id— filter to a single PID (only meaningful with VID)
Filtering happens native-side; an empty result is a real "no matching device", not a permission/availability issue.
@spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t()
Open a permitted device and claim an interface.
Options:
:interface— interface number (default0):endpoint_in— bulk IN endpoint address (e.g.0x81); if omitted, the first bulk IN endpoint on the interface is auto-selected:endpoint_out— bulk OUT endpoint address (e.g.0x01); if omitted, the first bulk OUT endpoint on the interface is auto-selected
Result:
{:peripheral, :vendor_usb, :opened, session, device}{:peripheral, :vendor_usb, :error, nil, reason}— common reasons::no_permission,:device_gone,:interface_busy,:no_bulk_endpoints
@spec request_permission(Mob.Socket.t(), device()) :: Mob.Socket.t()
Ask the OS to prompt the user to grant access to a specific device.
device is the map returned by list_devices/1. Only the :ref field
is consulted, but it is convenient to pass the whole map.
Result:
{:peripheral, :vendor_usb, :permission_granted, nil, device}{:peripheral, :vendor_usb, :permission_denied, nil, device}
Idempotent. If the user has already granted access, the granted message fires immediately without showing a dialog.
@spec start_reading(Mob.Socket.t(), session(), keyword()) :: Mob.Socket.t()
Start a continuous read loop on the bulk IN endpoint.
After this call, every chunk read native-side is delivered as
{:peripheral, :vendor_usb, :data, session, binary} to the calling
process. Stop with stop_reading/2.
Options:
:read_chunk_bytes— soft cap on per-message coalescing (default4096). Smaller values reduce latency; larger reduce overhead.
Idempotent: calling twice is a no-op.
@spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t()
Stop the read loop started by start_reading/3.