Packages
mob
0.6.1
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
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