Espex.BluetoothScanner behaviour (espex v0.3.0)

Copy Markdown View Source

Behaviour for Bluetooth Low Energy scanner adapters.

Implement this module to expose a passive BLE scanner — typically a built-in radio on a Nerves device — to ESPHome clients through the Native API's Bluetooth proxy feature. Home Assistant uses these advertisements to discover and update ESPHome BLE proxy entities, Govee/Xiaomi sensors, Bluetooth tags, and any other advertising device in range.

This behaviour is independent of Espex.BluetoothProxy: a device may expose only a scanner (passive sniffing, no outbound connections), or pair this with the active behaviour for full proxy capability.

Callbacks

CallbackRequiredPurpose
subscribe/1yesRegister a connection handler for inbound advertisements
unsubscribe/1yesDeregister a handler (idempotent)
set_scanner_mode/1noSwitch between passive and active scanning

When set_scanner_mode/1 is omitted, espex omits the STATE_AND_MODE bit from the advertised feature flags and the client will not attempt to flip modes.

Data flow

Subscribers receive two kinds of messages from the adapter:

{:espex_ble_advertisement, address, rssi, address_type, data}
{:espex_ble_scanner_state, state, mode, configured_mode}

Raw advertisements

Each advertisement is sent as a single message. Espex wraps each one in a one-element BluetoothLERawAdvertisementsResponse and forwards it to the subscribed client. Batching multiple advertisements into a single response is a future optimization — keep the adapter contract simple by sending one ad per message.

  • address — 48-bit MAC packed into a non_neg_integer() (uint64)
  • rssi — signed integer, dBm
  • address_type0 (public) or 1 (random)
  • data — raw advertisement payload bytes

Only the raw-advertisement format is supported. The legacy parsed format (BluetoothLEAdvertisementResponse, proto id 67) is not wired — modern clients ignore it in favor of the raw stream.

Scanner state changes

When the radio's state or mode changes (e.g., the controller starts, enters active-scan mode, or pauses for a connection), notify subscribers:

send(subscriber, {:espex_ble_scanner_state, :running, :passive, :passive})

The three atoms map directly to the BluetoothScannerStateResponse wire enums. Common values:

  • state:idle, :starting, :running, :failed, :stopping, :stopped
  • mode, configured_mode:passive or :active

Espex passes these straight through to the wire enum encoding; the exact set of supported values is determined by your underlying BLE stack.

Example: a passive scanner over BlueHeron

This sketch wraps a hypothetical BlueHeron-style stack and broadcasts advertisements to every subscribed connection handler.

defmodule MyApp.BLEScanner do
  @behaviour Espex.BluetoothScanner

  use GenServer

  @registry MyApp.BLEScannerSubscribers

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  @impl true
  def subscribe(pid) do
    Registry.register(@registry, :subscribers, nil)
    GenServer.cast(__MODULE__, :ensure_scanning)
    :ok
  end

  @impl true
  def unsubscribe(_pid) do
    Registry.unregister(@registry, :subscribers)
    :ok
  end

  @impl true
  def set_scanner_mode(:passive), do: GenServer.call(__MODULE__, {:set_mode, :passive})
  def set_scanner_mode(:active), do: GenServer.call(__MODULE__, {:set_mode, :active})

  # Called from the BlueHeron read loop on each advertisement:
  def broadcast_advertisement(address, rssi, address_type, data) do
    Registry.dispatch(@registry, :subscribers, fn entries ->
      Enum.each(entries, fn {pid, _} ->
        send(pid, {:espex_ble_advertisement, address, rssi, address_type, data})
      end)
    end)
  end

  # Called when the scanner state or mode transitions:
  def broadcast_state(state, mode, configured) do
    Registry.dispatch(@registry, :subscribers, fn entries ->
      Enum.each(entries, fn {pid, _} ->
        send(pid, {:espex_ble_scanner_state, state, mode, configured})
      end)
    end)
  end
end

Wiring

Espex.start_link(
  device_config: [name: "ble-proxy"],
  bluetooth_scanner: MyApp.BLEScanner
)

Pair with bluetooth_proxy: for an active proxy that can connect to discovered devices.

Summary

Callbacks

Switch the scanner between passive and active modes. Optional — adapters that can only run in one mode (or that select the mode out-of-band) may omit this callback. When omitted, espex omits the STATE_AND_MODE bit from bluetooth_proxy_feature_flags and the client will not send BluetoothScannerSetModeRequest.

Subscribe the given pid to BLE advertisements. The adapter must forward every advertisement and every scanner state change to the pid via the message tuples described in the moduledoc.

Unsubscribe a previously subscribed pid. Idempotent.

Callbacks

set_scanner_mode(arg1)

(optional)
@callback set_scanner_mode(:passive | :active) :: :ok | {:error, term()}

Switch the scanner between passive and active modes. Optional — adapters that can only run in one mode (or that select the mode out-of-band) may omit this callback. When omitted, espex omits the STATE_AND_MODE bit from bluetooth_proxy_feature_flags and the client will not send BluetoothScannerSetModeRequest.

subscribe(subscriber)

@callback subscribe(subscriber :: pid()) :: :ok | {:error, term()}

Subscribe the given pid to BLE advertisements. The adapter must forward every advertisement and every scanner state change to the pid via the message tuples described in the moduledoc.

unsubscribe(subscriber)

@callback unsubscribe(subscriber :: pid()) :: :ok

Unsubscribe a previously subscribed pid. Idempotent.