MobBluetooth (mob_bluetooth v0.5.0)

Copy Markdown View Source

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

One runtime request covers the whole plugin on Android:

Mob.Permissions.request(socket, :bluetooth_connect)

It asks for what the running Android version needs:

  • Android 11 and below (API ≤ 30) — ACCESS_FINE_LOCATION. The legacy BLUETOOTH / BLUETOOTH_ADMIN permissions are install-time and already granted, but classic discovery returns nothing without location.
  • Android 12+ (API 31+) — the "Nearby devices" group (BLUETOOTH_SCAN, BLUETOOTH_CONNECT, BLUETOOTH_ADVERTISE) plus ACCESS_FINE_LOCATION / ACCESS_COARSE_LOCATION, because discovery still needs location unless the app's manifest declares BLUETOOTH_SCAN with android:usesPermissionFlags="neverForLocation". When it does, location is not requested on API 31+ (see the README for the manifest lines).

Discovery also needs precise location: if the user picks "Approximate" the request reports :denied and start_discovery/1 fails with :location_permission_required. Pairing, profiles and advertising don't use location.

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

Background BLE is opt-in per app — by default this plugin declares no background modes (foreground only), so apps that don't need it don't ship an unused background-mode declaration (Apple rejects those at review). To enable it, two things are needed:

  • Declare the mode(s) you use in your app config. Each adds the matching UIBackgroundModes entry to the host Info.plist (array-merged by mob_dev >= 0.6.16, alongside any existing entry such as audio):

    config :mob_bluetooth, ble_background_modes: [:central]              # background scanning/connecting
    config :mob_bluetooth, ble_background_modes: [:peripheral]           # background advertising
    config :mob_bluetooth, ble_background_modes: [:central, :peripheral] # both
  • Background scanning requires a :service_uuids filter — ble_scan(socket, service_uuids: ["180D"]). iOS silently drops an unfiltered scan once backgrounded, so a foreground-style ble_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

device()

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

session_id()

@type session_id() :: pos_integer()

An opaque session identifier for an active profile connection.

Functions

ble_advertise(socket, opts \\ [])

@spec ble_advertise(
  socket :: term(),
  keyword()
) :: term()

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.

ble_scan(socket, opts \\ [])

@spec ble_scan(
  socket :: term(),
  keyword()
) :: term()

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.

ble_stop_advertise(socket)

@spec ble_stop_advertise(socket :: term()) :: term()

Stop BLE advertising. Emits {:bt, :ble_advertise_stopped}. iOS only.

ble_stop_scan(socket)

@spec ble_stop_scan(socket :: term()) :: term()

Stop a BLE scan. Emits {:bt, :ble_scan_stopped}. iOS only.

cancel_discovery(socket)

@spec cancel_discovery(socket :: term()) :: term()

Cancel an in-progress discovery.

disconnect(socket, session_id)

@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_paired(socket)

@spec list_paired(socket :: term()) :: term()

List currently paired (bonded) Bluetooth devices.

Result arrives as {:bt, :paired_list, [device]}.

make_discoverable(socket, opts \\ [])

@spec make_discoverable(
  socket :: term(),
  keyword()
) :: term()

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(socket, device, opts \\ [])

@spec pair(socket :: term(), device(), keyword()) :: term()

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.

Per the Bluetooth spec, PINs are ASCII digits — a Unicode string works, but non-ASCII bytes are encoded as UTF-8 and the peer will reject them silently. Stick to "0000", "1234", or the digit string printed on the device.

Result arrives as one of:

  • {:bt, :paired, device}
  • {:bt, :pair_failed, %{address: String.t(), reason: atom()}}

Calling pair/3 again for a device whose bond an earlier pair/3 is still waiting on joins that bond instead of starting another: every call gets the same terminal message (one per call), and the later call's :pin is ignored — the first caller's PIN answers the prompt. The same applies when the device is already bonding (e.g. started from system Settings): the call waits for that bond's outcome. If the host Activity is replaced while a call is waiting, the plugin can no longer observe the bond, so each waiting call gets {:bt, :pair_failed, %{address: addr, reason: :activity_replaced}} — check list_paired/1 or pair again.

start_discovery(socket)

@spec start_discovery(socket :: term()) :: term()

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. Request :bluetooth_connect first (see "Permissions" in the module doc).

If discovery can't start you get {:bt, :error, %{reason: reason}} and no further discovery events for this call:

  • :location_permission_required — the platform wants ACCESS_FINE_LOCATION (API ≤ 30, or API 31+ without neverForLocation) and it isn't granted (or the user granted only approximate location).
  • :location_disabled — location access is granted but the device's location setting is off (Android 10+ refuses discovery then).
  • :permission_denied — BLUETOOTH_SCAN not granted (API 31+).
  • :adapter_disabled, :no_adapter, :no_activity, :register_failed, :exception, or :start_failed (the platform refused for another reason).

unpair(socket, device)

@spec unpair(socket :: term(), device()) :: term()

Remove an existing pairing (bond).

Result: {:bt, :unpaired, device}.