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— forstart_discovery/1:bluetooth_connect— forpair/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 — so the classic surface above
(discovery, pairing, HFP, SPP) is Android-only: those functions return
{:error, :unsupported} synchronously on iOS.
iOS does expose BLE through CoreBluetooth, a separate, parallel protocol
(a classic headset won't appear in a BLE scan, and vice versa). The ble_*
functions — ble_scan/1, ble_stop_scan/1, ble_advertise/2,
ble_stop_advertise/1 — are iOS-only (they return {:error, :unsupported}
on Android, which has no BLE surface in this plugin yet) and need a real radio,
so they do nothing on the iOS Simulator.
Background BLE
For BLE to keep running while the app is backgrounded, two things are needed (both shipped here):
- The app's Info.plist must declare
UIBackgroundModesbluetooth-central(scanning/connecting) and/orbluetooth-peripheral(advertising). This plugin's manifest contributes both; mob_dev >= 0.6.16 merges them into the host Info.plist (alongside any existing entry such asaudio). - Background scanning requires a
:service_uuidsfilter —ble_scan(socket, service_uuids: ["180D"]). iOS silently drops an unfiltered scan once backgrounded, so a foreground-styleble_scan(socket)will not deliver in the background.
iOS heavily throttles background BLE: scans are coalesced and de-duplicated
(no repeat advertisement callbacks, slower RSSI), and background advertising
drops the local name and moves service UUIDs to an overflow area only other
iOS devices scanning for them can see. This is normal CoreBluetooth behaviour,
not a plugin limitation. (Do not reach for the mob_background audio
keep-alive for BLE — the dedicated background modes above are the Apple-blessed,
review-safe path.)
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).
Summary
Types
A discovered or paired Bluetooth device.
An opaque session identifier for an active profile connection.
Functions
Advertise this device as a BLE peripheral with a local :name
(default "Mob") — the BLE analog of
make_discoverable/2.
Scan for nearby BLE peripherals (iOS / CoreBluetooth).
Stop BLE advertising. Emits {:bt, :ble_advertise_stopped}. iOS only.
Stop a BLE scan. Emits {:bt, :ble_scan_stopped}. iOS only.
Cancel an in-progress discovery.
Disconnect a profile session by session_id.
List currently paired (bonded) Bluetooth devices.
Request that the device become discoverable to nearby Bluetooth devices for
:duration seconds (default 120). Shows the
system "make discoverable?" dialog.
Pair (bond) with a Bluetooth device.
Begin Bluetooth Classic discovery. Discovered devices arrive as
individual {:bt, :discovered, device} messages, terminated by
{:bt, :discovery_finished}.
Remove an existing pairing (bond).
Types
@type device() :: %{ :address => String.t(), :name => String.t(), optional(:bond_state) => :none | :bonding | :bonded, optional(:device_class) => non_neg_integer(), optional(:uuids) => [String.t()] }
A discovered or paired Bluetooth device.
@type session_id() :: pos_integer()
An opaque session identifier for an active profile connection.
Functions
Advertise this device as a BLE peripheral with a local :name
(default "Mob") — the BLE analog of
make_discoverable/2.
Emits {:bt, :ble_advertising} once advertising starts, or
{:bt, :error, %{reason: atom}}. iOS only — {:error, :unsupported}
elsewhere.
Scan for nearby BLE peripherals (iOS / CoreBluetooth).
Pass :service_uuids (a list of service-UUID strings, e.g.
["180D", "0000180F-0000-1000-8000-00805F9B34FB"]) to filter the scan.
Omitting it scans for everything, which works in the foreground but not in
the background — iOS silently drops an unfiltered scan once the app is
backgrounded, so a service-UUID filter is required for background scanning
(see "Background BLE" in the moduledoc).
Emits, to the calling process (same :bt device-event family as classic
discovery):
{:bt, :ble_scan_started}{:bt, :ble_device, %{id: uuid, name: name | nil, rssi: integer}}(once per advertisement seen){:bt, :error, %{reason: atom}}if the radio is off/unauthorized.
iOS only — {:error, :unsupported} elsewhere. BLE needs a real radio, so it
does nothing on the iOS Simulator.
Stop BLE advertising. Emits {:bt, :ble_advertise_stopped}. iOS only.
Stop a BLE scan. Emits {:bt, :ble_scan_stopped}. iOS only.
Cancel an in-progress discovery.
@spec disconnect(socket :: term(), session_id()) :: term()
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}
List currently paired (bonded) Bluetooth devices.
Result arrives as {:bt, :paired_list, [device]}.
Request that the device become discoverable to nearby Bluetooth devices for
:duration seconds (default 120). Shows the
system "make discoverable?" dialog.
An invalid :duration (missing, non-integer, or negative) falls back to the
default; the platform bounds the upper end. Requires the BLUETOOTH_ADVERTISE
runtime permission (the Android 12+ "Nearby devices" group) — request it
before calling. Fire-and-forget: the system dialog is the user-facing result;
the accept/deny outcome is not captured today. A failure (adapter off,
permission not granted) arrives as {:bt, :error, reason}.
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()}}
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.
Remove an existing pairing (bond).
Result: {:bt, :unpaired, device}.