Current section

Files

Jump to
mob lib mob vendor_usb.ex
Raw

lib/mob/vendor_usb.ex

defmodule Mob.VendorUsb do
@moduledoc """
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).
"""
@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()
@max_write_bytes 16 * 1024
@doc """
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 list_devices(Mob.Socket.t(), keyword()) :: Mob.Socket.t()
def list_devices(socket, opts \\ []) do
filter =
%{}
|> maybe_put_filter("vendor_id", Keyword.get(opts, :vendor_id))
|> maybe_put_filter("product_id", Keyword.get(opts, :product_id))
json = :json.encode(filter)
:mob_nif.vendor_usb_list_devices(json)
socket
end
defp maybe_put_filter(map, _key, nil), do: map
defp maybe_put_filter(map, key, val), do: Map.put(map, key, val)
@doc """
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 request_permission(Mob.Socket.t(), device()) :: Mob.Socket.t()
def request_permission(socket, %{ref: ref} = _device) when is_binary(ref) do
:mob_nif.vendor_usb_request_permission(ref)
socket
end
@doc """
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`
"""
@spec open(Mob.Socket.t(), device(), keyword()) :: Mob.Socket.t()
def open(socket, %{ref: ref}, opts \\ []) when is_binary(ref) do
fields =
%{"ref" => ref, "interface" => Keyword.get(opts, :interface, 0)}
|> maybe_put_filter("endpoint_in", Keyword.get(opts, :endpoint_in))
|> maybe_put_filter("endpoint_out", Keyword.get(opts, :endpoint_out))
json = :json.encode(fields)
:mob_nif.vendor_usb_open(json)
socket
end
@doc """
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: #{@max_write_bytes} bytes.
Options:
* `:timeout_ms` — write timeout (default `1000`)
Result:
* `{:peripheral, :vendor_usb, :write_complete, session, %{bytes: n}}`
* `{:peripheral, :vendor_usb, :error, session, reason}`
"""
@spec bulk_write(Mob.Socket.t(), session(), iodata(), keyword()) :: Mob.Socket.t()
def bulk_write(socket, session, data, opts \\ []) when is_integer(session) do
bin = IO.iodata_to_binary(data)
cond do
byte_size(bin) == 0 ->
socket
byte_size(bin) > @max_write_bytes ->
send(self(), {:peripheral, :vendor_usb, :error, session, :payload_too_large})
socket
true ->
timeout = Keyword.get(opts, :timeout_ms, 1000)
:mob_nif.vendor_usb_bulk_write(session, bin, timeout)
socket
end
end
@doc """
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.
"""
@spec start_reading(Mob.Socket.t(), session(), keyword()) :: Mob.Socket.t()
def start_reading(socket, session, opts \\ []) when is_integer(session) do
chunk = Keyword.get(opts, :read_chunk_bytes, 4096)
:mob_nif.vendor_usb_start_reading(session, chunk)
socket
end
@doc "Stop the read loop started by `start_reading/3`."
@spec stop_reading(Mob.Socket.t(), session()) :: Mob.Socket.t()
def stop_reading(socket, session) when is_integer(session) do
:mob_nif.vendor_usb_stop_reading(session)
socket
end
@doc """
Close a device session, releasing the interface and freeing the file
descriptor. Idempotent. Always emits
`{:peripheral, :vendor_usb, :closed, session, :ok}`.
"""
@spec close(Mob.Socket.t(), session()) :: Mob.Socket.t()
def close(socket, session) when is_integer(session) do
:mob_nif.vendor_usb_close(session)
socket
end
# ── Event normalization ────────────────────────────────────────────────
#
# The Android NIF delivers a few high-cardinality events with their
# payloads as JSON binaries (`:devices_json`, `:permission_granted_json`,
# `:permission_denied_json`, `:opened_json`) to keep the C/JNI side
# simple. `Mob.Screen` calls `normalize_message/1` once before the
# screen's `handle_info/2` runs, so user code only sees the public event
# shape documented at the top of this module.
@doc false
@spec normalize_message(term()) :: term()
def normalize_message({:peripheral, :vendor_usb, :devices_json, _, json})
when is_binary(json) do
devices = json |> :json.decode() |> Enum.map(&device_from_map/1)
{:peripheral, :vendor_usb, :devices, nil, devices}
end
def normalize_message({:peripheral, :vendor_usb, :permission_granted_json, _, json}) do
{:peripheral, :vendor_usb, :permission_granted, nil, device_from_map(:json.decode(json))}
end
def normalize_message({:peripheral, :vendor_usb, :permission_denied_json, _, json}) do
{:peripheral, :vendor_usb, :permission_denied, nil, device_from_map(:json.decode(json))}
end
def normalize_message({:peripheral, :vendor_usb, :opened_json, session, json}) do
{:peripheral, :vendor_usb, :opened, session, device_from_map(:json.decode(json))}
end
def normalize_message(other), do: other
defp device_from_map(map) when is_map(map) do
%{
vendor_id: Map.get(map, "vendor_id"),
product_id: Map.get(map, "product_id"),
manufacturer: Map.get(map, "manufacturer"),
product: Map.get(map, "product"),
serial: Map.get(map, "serial"),
ref: Map.get(map, "ref")
}
end
end