Packages
mob
0.6.18
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/bt/hfp.ex
defmodule Mob.Bt.Hfp do
@moduledoc """
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 `Mob.Bt` for pairing, discovery, and disconnect — those are
device-level concerns. Profile-specific operations live here.
## Typical flow
# 1. Pair (only needed once per device — Mob.Bt.pair/2)
socket = Mob.Bt.pair(socket, device)
# {:bt, :pair_succeeded, nil, device}
# 2. Connect HFP profile
socket = Mob.Bt.Hfp.connect(socket, device)
# {:bt, :hfp_connected, session_id, device}
# 3. (Optional) subscribe to vendor AT commands the headset emits.
# Hytera EHW02 fires +CTXD on PTT press, +CUTXC on release.
socket = Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id)
# {:bt, :vendor_at, session_id, %{cmd: "+CTXD", args: ""}}
# 4. (Optional) bring up the SCO audio link.
socket = Mob.Bt.Hfp.start_sco(socket, session_id)
# {:bt, :sco_started, session_id, %{sample_rate: 8000, ...}}
# then audio chunks stream as:
# {:bt, :sco_audio_in, session_id, pcm_bytes}
# 5. Send PCM audio out to the headset earpiece:
Mob.Bt.Hfp.send_audio(socket, session_id, pcm_bytes)
# 6. Disconnect (one canonical path — Mob.Bt.disconnect/2)
Mob.Bt.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, :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 audio
SCO (Synchronous Connection-Oriented) is the real-time bidirectional
voice channel HFP uses for call audio. `start_sco/2` opens it; PCM
bytes flow both ways until `stop_sco/2` or disconnect.
Format is 8 kHz / 16-bit / mono PCM by default; modern devices may
negotiate up to 16 kHz wideband (mSBC). The `:sco_started` event
reports the negotiated parameters.
"""
alias Mob.Bt
@doc """
Open an HFP profile connection to `device`. The device must already
be paired (`Mob.Bt.pair/2`).
Result: `{:bt, :hfp_connected, session_id, device}` on success,
`{:bt, :hfp_connect_failed, nil, %{device: device, reason: atom()}}`
on failure.
"""
@spec connect(socket :: term(), Bt.device()) :: term()
def connect(socket, device) do
json = Bt.encode_device(device)
:mob_nif.bt_hfp_connect(json)
socket
end
@doc """
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, :vendor_at, session_id, %{cmd: String.t(), cmd_type: integer(), args: String.t(), address: String.t()}}`.
## Example
Mob.Bt.Hfp.subscribe_vendor_at(socket, session_id, company_ids: [313])
"""
@spec subscribe_vendor_at(socket :: term(), Bt.session_id(), keyword()) :: term()
def subscribe_vendor_at(socket, session_id, opts \\ [])
when is_integer(session_id) and is_list(opts) do
json = encode_vendor_at_opts(opts)
:mob_nif.bt_hfp_subscribe_vendor_at(session_id, json)
socket
end
@doc false
@spec encode_vendor_at_opts(keyword()) :: binary()
def encode_vendor_at_opts(opts) when is_list(opts) do
company_ids = Keyword.get(opts, :company_ids, [])
%{company_ids: company_ids}
|> :json.encode()
|> IO.iodata_to_binary()
end
@doc """
Send a vendor AT command to the headset. Useful for headset-specific
feature toggles or query/response protocols.
Mob.Bt.Hfp.send_vendor_at(socket, session, "+XAPL", "0505,2")
"""
@spec send_vendor_at(socket :: term(), Bt.session_id(), String.t(), String.t()) :: term()
def send_vendor_at(socket, session_id, cmd, args \\ "")
when is_integer(session_id) and is_binary(cmd) and is_binary(args) do
:mob_nif.bt_hfp_send_vendor_at(session_id, cmd, args)
socket
end
@doc """
Open the SCO audio link for this HFP session.
Emits `{:bt, :sco_started, session_id, %{sample_rate: integer, encoding: atom, channels: integer}}`
when the link is up. Mic audio then streams as
`{:bt, :sco_audio_in, session_id, pcm_bytes}`.
On failure: `{:bt, :sco_failed, session_id, reason}`.
"""
@spec start_sco(socket :: term(), Bt.session_id()) :: term()
def start_sco(socket, session_id) when is_integer(session_id) do
:mob_nif.bt_hfp_start_sco(session_id)
socket
end
@doc """
Close the SCO audio link without disconnecting the HFP session.
Emits `{:bt, :sco_stopped, session_id, nil}`.
"""
@spec stop_sco(socket :: term(), Bt.session_id()) :: term()
def stop_sco(socket, session_id) when is_integer(session_id) do
:mob_nif.bt_hfp_stop_sco(session_id)
socket
end
@doc """
Send PCM audio bytes out the SCO link to the headset earpiece.
Bytes are linear PCM matching the format reported in `:sco_started`
(typically 8 kHz / 16-bit / mono signed little-endian).
Returns the socket. This is fire-and-forget; no completion event.
"""
@spec send_audio(socket :: term(), Bt.session_id(), binary()) :: term()
def send_audio(socket, session_id, pcm_bytes)
when is_integer(session_id) and is_binary(pcm_bytes) do
:mob_nif.bt_hfp_send_audio(session_id, pcm_bytes)
socket
end
end