MobBluetooth.Hfp (mob_bluetooth v0.1.1)

Copy Markdown View Source

Bluetooth Classic Hands-Free Profile (HFP) — audio + vendor AT commands.

Use this for headsets, PTT-equipped earpieces (Hytera EHW02, etc), and any device that exposes an HFP control link plus an SCO audio link.

See MobBluetooth for pairing, discovery, and disconnect — those are device-level concerns. Profile-specific operations live here.

Typical flow

# 1. Pair (only needed once per device — MobBluetooth.pair/2)
socket = MobBluetooth.pair(socket, device)
# {:bt, :paired, device}

# 2. Connect HFP profile
socket = MobBluetooth.Hfp.connect(socket, device)
# {:bt_hfp, :connected, session_id, payload}

# 3. (Optional) subscribe to vendor AT commands the headset emits.
#    Hytera EHW02 fires +CTXD on PTT press, +CUTXC on release.
socket = MobBluetooth.Hfp.subscribe_vendor_at(socket, session_id)
# {:bt_hfp, :vendor_at, session_id, %{cmd: "+CTXD", args: ""}}

# 4. (Optional) bring up the SCO audio link (audio then routes through
#    Android's normal in-call path; see "SCO audio" below).
socket = MobBluetooth.Hfp.start_sco(socket, session_id)
# {:bt_hfp, :sco_started, session_id, %{address: ...}}

# 5. Disconnect (one canonical path — MobBluetooth.disconnect/2)
MobBluetooth.disconnect(socket, session_id)

Vendor AT commands

HFP defines a small core AT vocabulary (call control, volume, ring). Headset vendors extend with their own +-prefixed commands. Subscribing via subscribe_vendor_at/2 delivers any unrecognized AT command from the headset as {:bt_hfp, :vendor_at, session_id, %{cmd, args}} for your app to interpret.

Sending a vendor AT command to the headset is send_vendor_at/4. Standard responses (OK, ERROR) are emitted by Android automatically — use the response argument to override only when the AT spec demands custom payload.

SCO (Synchronous Connection-Oriented) is the real-time bidirectional voice channel HFP uses for call audio. start_sco/2 brings the link up (via startScoUsingVirtualVoiceCall + MODE_IN_COMMUNICATION) and stop_sco/2 tears it down.

Audio is not streamed through events

Once the SCO link is up, audio flows through Android's normal in-call audio routing (the headset becomes the active mic/speaker). Raw PCM is not delivered to the BEAM as events, and the codec parameters (sample rate / mSBC vs CVSD) are not surfaced — :sco_started carries only the device address. Forwarding PCM frames as {:bt_hfp, :sco_audio, ...} events would be net-new work; it has never been implemented (true in mob core as well).

Summary

Functions

Open an HFP profile connection to device. The device must already be paired (MobBluetooth.pair/2).

Send a vendor AT command to the headset. Useful for headset-specific feature toggles or query/response protocols.

Open the SCO audio link for this HFP session.

Close the SCO audio link without disconnecting the HFP session.

Subscribe to vendor-specific AT commands emitted by the headset on the given HFP session.

Functions

connect(socket, device)

@spec connect(socket :: term(), MobBluetooth.device()) :: term()

Open an HFP profile connection to device. The device must already be paired (MobBluetooth.pair/2).

Result: {:bt_hfp, :connected, session_id, payload} on success, {:bt_hfp, :connect_failed, %{address: String.t(), reason: atom()}} on failure (3-tuple — no session id exists yet).

send_vendor_at(socket, session_id, cmd, args \\ "")

@spec send_vendor_at(
  socket :: term(),
  MobBluetooth.session_id(),
  String.t(),
  String.t()
) :: term()

Send a vendor AT command to the headset. Useful for headset-specific feature toggles or query/response protocols.

MobBluetooth.Hfp.send_vendor_at(socket, session, "+XAPL", "0505,2")

start_sco(socket, session_id)

@spec start_sco(socket :: term(), MobBluetooth.session_id()) :: term()

Open the SCO audio link for this HFP session.

Emits {:bt_hfp, :sco_started, session_id, %{address: String.t()}} when the link is up; on failure {:bt_hfp, :error, session_id, reason}. Audio then routes through Android's normal in-call path — PCM is not delivered as events (see the "SCO audio" section in the module doc).

stop_sco(socket, session_id)

@spec stop_sco(socket :: term(), MobBluetooth.session_id()) :: term()

Close the SCO audio link without disconnecting the HFP session.

Emits {:bt_hfp, :sco_stopped, session_id} (3-tuple).

subscribe_vendor_at(socket, session_id, opts \\ [])

@spec subscribe_vendor_at(socket :: term(), MobBluetooth.session_id(), keyword()) ::
  term()

Subscribe to vendor-specific AT commands emitted by the headset on the given HFP session.

The caller specifies which BT SIG company IDs to listen for via the :company_ids option. Android's ACTION_VENDOR_SPECIFIC_HEADSET_EVENT broadcasts are only delivered for explicitly-registered IDs, so a default empty list means no events will be received.

Common values:

  • 313 — Hytera (PTT commercial radios)
  • 76 — Apple (AirPods custom events)
  • 10 — Qualcomm
  • 1117 — Plantronics / Poly

Standard (non-vendor) AT commands are handled by Android's HFP stack and never surface here.

Stream events: {:bt_hfp, :vendor_at, session_id, %{cmd: String.t(), cmd_type: integer(), args: String.t(), address: String.t()}}.

Example

MobBluetooth.Hfp.subscribe_vendor_at(socket, session_id, company_ids: [313])