Mob.VendorUsb (mob v0.6.0)

Copy Markdown View Source

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
end

Framing 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

device()

@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()
}

session()

@type session() :: integer()

Functions

bulk_write(socket, session, data, opts \\ [])

@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 (default 1000)

Result:

  • {:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}
  • {:peripheral, :vendor_usb, :error, session, reason}

close(socket, session)

@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}.

list_devices(socket, opts \\ [])

@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.

open(socket, map, opts \\ [])

@spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t()

Open a permitted device and claim an interface.

Options:

  • :interface — interface number (default 0)
  • :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

request_permission(socket, device)

@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.

start_reading(socket, session, opts \\ [])

@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 (default 4096). Smaller values reduce latency; larger reduce overhead.

Idempotent: calling twice is a no-op.

stop_reading(socket, session)

@spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t()

Stop the read loop started by start_reading/3.