Current section
Files
Jump to
Current section
Files
lib/mob_bluetooth.ex
defmodule MobBluetooth do
@moduledoc """
Bluetooth Classic (BR/EDR) — device-level discovery, pairing, and
cross-profile session management.
Profile-specific operations live in submodules:
* `MobBluetooth.Hfp` — Hands-Free Profile (audio + vendor AT commands).
Use this for headsets, PTT-equipped earpieces, etc.
* `MobBluetooth.Spp` — Serial Port Profile (RFCOMM byte streams).
Use this for legacy serial-over-Bluetooth devices (Arduino HC-05,
OBD-II readers, marine GPS, industrial sensors).
(HID is not supported on Android — receiving HID input requires
input-method/HID-host privileges Android denies ordinary apps.)
## API style
Same as the rest of Mob: callbacks return `socket` unchanged, results
arrive in `handle_info/2` as messages. There are two families, and they
do **not** share a uniform arity:
**Device-level events** are tagged `:bt` and carry no session id:
{:bt, :discovery_started} # 2-tuple, no payload
{:bt, :discovery_finished}
{:bt, :discovery_cancelled}
{:bt, :discovered, device} # 3-tuple, device map
{:bt, :paired, device}
{:bt, :pair_failed, %{address: addr, reason: atom}}
{:bt, :unpaired, device}
{:bt, :paired_list, [device]}
{:bt, :error, payload}
**Profile events** are tagged by profile (`:bt_hfp`, `:bt_spp`) — not
`:bt`. Once a session exists they carry its integer `session_id` as the
third element; pre-session failures omit it:
{:bt_hfp, :connected, session_id, payload} # 4-tuple, has session
{:bt_hfp, :connect_failed, %{address: addr, reason: atom}} # 3-tuple, no session yet
{:bt_hfp, :disconnected, session_id, reason}
The profile submodules document their own event sets.
## Permissions
Bluetooth requires runtime permissions on Android 12+ (API 31+):
* `:bluetooth_scan` — for `start_discovery/1`
* `:bluetooth_connect` — for `pair/2`, `connect/*`, `disconnect/2`
Request via `Mob.Permissions.request/2` before calling MobBluetooth functions.
## iOS
Bluetooth Classic on iOS requires Apple's MFi (Made for iPhone)
certification — a paid, NDA-gated program. MobBluetooth is **Android-only**.
All functions return `{:error, :unsupported}` synchronously on iOS.
For iOS-equivalent custom-hardware connectivity, use `Mob.Ble`.
## Pairing flow
Two pairing modes, auto-selected by whether `:pin` is given:
# System UI flow — Android shows a system pairing dialog
socket = MobBluetooth.pair(socket, device)
# Programmatic — PIN supplied via API, no UI
socket = MobBluetooth.pair(socket, device, pin: "0000")
If the programmatic PIN fails or the device requires UI confirmation
(e.g. numeric comparison), Android falls back to the system UI
automatically.
## Disconnect
One canonical disconnect for any profile session:
MobBluetooth.disconnect(socket, session_id)
The framework looks up which profile owns the session_id and routes
to the right profile-disconnect internally. Emits a profile-specific
disconnect event (`{:bt_hfp, :disconnected, session_id, reason}` etc).
"""
@typedoc "An opaque session identifier for an active profile connection."
@type session_id :: pos_integer()
@typedoc "A discovered or paired Bluetooth device."
@type device :: %{
required(:address) => String.t(),
required(:name) => String.t(),
optional(:bond_state) => :none | :bonding | :bonded,
optional(:device_class) => non_neg_integer(),
optional(:uuids) => [String.t()]
}
# ─────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────
@doc """
List currently paired (bonded) Bluetooth devices.
Result arrives as `{:bt, :paired_list, [device]}`.
"""
@spec list_paired(socket :: term()) :: term()
def list_paired(socket) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
:mob_bluetooth_nif.bt_list_paired()
socket
end
end
@doc """
Begin Bluetooth Classic discovery. Discovered devices arrive as
individual `{:bt, :discovered, device}` messages, terminated by
`{:bt, :discovery_finished}`.
Discovery typically runs ~12 seconds on Android.
"""
@spec start_discovery(socket :: term()) :: term()
def start_discovery(socket) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
:mob_bluetooth_nif.bt_start_discovery()
socket
end
end
@doc """
Cancel an in-progress discovery.
"""
@spec cancel_discovery(socket :: term()) :: term()
def cancel_discovery(socket) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
:mob_bluetooth_nif.bt_cancel_discovery()
socket
end
end
@doc """
Pair (bond) with a Bluetooth device.
Without `:pin`, Android shows the system pairing dialog (user enters
PIN). With `:pin`, attempts programmatic pairing using the supplied
PIN; falls back to system UI if the device demands user confirmation.
Result arrives as one of:
* `{:bt, :paired, device}`
* `{:bt, :pair_failed, %{address: String.t(), reason: atom()}}`
"""
@spec pair(socket :: term(), device(), keyword()) :: term()
def pair(socket, device, opts \\ []) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
pin = Keyword.get(opts, :pin)
json = encode_pair(device, pin)
:mob_bluetooth_nif.bt_pair(json)
socket
end
end
@doc """
Remove an existing pairing (bond).
Result: `{:bt, :unpaired, device}`.
"""
@spec unpair(socket :: term(), device()) :: term()
def unpair(socket, device) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
json = encode_device(device)
:mob_bluetooth_nif.bt_unpair(json)
socket
end
end
@doc """
Disconnect a profile session by `session_id`.
Works for any profile (`MobBluetooth.Hfp`, `MobBluetooth.Spp`) — the
framework dispatches internally based on which profile owns the session.
Emits a profile-specific disconnect event:
* `{:bt_hfp, :disconnected, session_id, reason}`
* `{:bt_spp, :disconnected, session_id, reason}`
"""
@spec disconnect(socket :: term(), session_id()) :: term()
def disconnect(socket, session_id) when is_integer(session_id) do
if MobBluetooth.Platform.unsupported?(MobBluetooth.Platform.current()) do
{:error, :unsupported}
else
:mob_bluetooth_nif.bt_disconnect(session_id)
socket
end
end
# Internal JSON helpers, exposed `@doc false` so the test suite can
# exercise the encoded shape directly (the public functions all dead-end
# in a NIF call). Nil-safe per the VendorUsb playbook.
# ─────────────────────────────────────────────────────────────
@doc false
@spec encode_pair(device(), String.t() | nil) :: binary()
def encode_pair(device, nil), do: encode_device(device)
def encode_pair(device, pin) when is_binary(pin) do
device |> Map.put(:pin, pin) |> encode_device()
end
@doc false
@spec encode_device(map()) :: binary()
def encode_device(device) do
device
|> Map.new()
|> drop_nil_values()
|> :json.encode()
|> IO.iodata_to_binary()
end
defp drop_nil_values(map) do
:maps.filter(fn _k, v -> v != nil end, map)
end
end