Bluez.Client (bluez v0.1.0)

Copy Markdown View Source

Persistent rebus D-Bus client + service to org.bluez, driving BLE scanning and turning device signals into advertisements, fanned out through the on_advertisement: fun.

Supports both scanner modes Home Assistant can request (set_mode/1, called via BluetoothScanner.set_scanner_mode/1):

  • :passive (default) — a BlueZ AdvertisementMonitor. We never send scan requests, so scannable peripherals don't burn battery answering us. Requires exporting a D-Bus object BlueZ calls back into, so this process is both a client and a service (via the forked rebus's set_method_handler/2).
  • :activeAdapter1.StartDiscovery with an LE filter. BlueZ sends scan requests, so SCAN_RSP data (e.g. device names) is collected — parity with ESP32 proxies' active mode.

Device data arrives the same way in both modes (InterfacesAdded/PropertiesChanged on Device1 objects), so the advert pipeline downstream is mode-agnostic.

Flow:

  1. Bluez.Rebus.connect(:system), set_method_handler(self()), monitor the connection, and install bus match rules for org.bluez device signals.
  2. Power the adapter on, then engage configured_mode/0: either AdvertisementMonitorManager1.RegisterMonitor our root object (BlueZ enumerates the monitor via ObjectManager.GetManagedObjects and calls Activate/DeviceFound on it; the monitor's or_patterns (FLAGS \x02/\x06/\x1a) match effectively all advertisers — the habluetooth "match all" recipe), or SetDiscoveryFilter + StartDiscovery.
  3. Matched devices surface as InterfacesAdded/PropertiesChanged signals; props are unwrapped (Bluez.Variant) and fed to Bluez.DeviceCache, which reconstructs + emit-gates and returns the adverts to fan out via BluetoothScanner.on_advertisement/1.

Mode transitions:

  • Run in a Task — BlueZ calls GetManagedObjects back on us before RegisterMonitor returns, so the GenServer must stay free to answer — and are serialized: at most one in flight, identified by a generation ref so a stale Task result can't corrupt state. A set_mode/1 arriving mid-transition parks in a one-slot pending queue keyed by target mode: callers asking for the same target coalesce (all get :ok when it lands); a different target displaces them with {:error, :superseded} (latest target wins).
  • Transitions engage the new mode BEFORE disengaging the old one (monitor and discovery can legally coexist in BlueZ): a failed engage leaves the previous mode still scanning rather than going dark. Disengage is best-effort, and self-healing lives in engage's idempotency: whatever drifted, re-engaging treats AlreadyExists/InProgress as success, so the next transition always converges on the target mode.
  • The configured mode persists in :persistent_term across Client restarts: a bluetoothd/connection crash re-engages what HA chose rather than silently reverting to passive.

Resilience:

  • The rebus connection is monitored; if it dies (e.g. a malformed bus frame :stops it) the Client stops and the supervisor restarts it, re-establishing the connection.
  • Setup retries via send_after (not Process.sleep) so the GenServer stays responsive while waiting for bluetoothd to claim org.bluez.

Summary

Functions

org.bluez.Adapter1 properties for every adapter object the daemon exposes: [%{path:, address:, name:, powered:}] — e.g. for a host's radio list. Returns [] when this Client isn't running (BT subtree down, host tests) or the daemon can't answer.

PubSub topic carrying {:bluetooth_adapters_changed} on adapter add/remove.

Returns a specification to start this module under a supervisor.

The HA-configured scanner mode (:passive default). Pure :persistent_term read — safe on any target, with or without the Client running (host tests, early boot).

Distinct devices the advert cache has seen in the last window_ms (Bluez.DeviceCache.seen_within/3). 0 when this Client isn't running — e.g. for a host's stats ticker.

Re-engage the HA-configured scanner mode after suspend_scan/0.

Switch the scanner between :passive (AdvertisementMonitor) and :active (StartDiscovery) at runtime. Returns once the BlueZ transition completes.

Suspend scanning entirely (disengage the monitor/discovery), preserving the HA-configured mode so resume_scan/0 restores it. Used by Improv Wi-Fi provisioning: it only runs on a no-connectivity boot, when there is no HA client to consume proxied advertisements anyway — so scanning is pointless (and may degrade the active BLE peripheral connection on a single radio).

Functions

adapters_info()

@spec adapters_info() :: [
  %{
    path: String.t(),
    address: String.t() | nil,
    name: String.t() | nil,
    powered: boolean()
  }
]

org.bluez.Adapter1 properties for every adapter object the daemon exposes: [%{path:, address:, name:, powered:}] — e.g. for a host's radio list. Returns [] when this Client isn't running (BT subtree down, host tests) or the daemon can't answer.

adapters_topic()

@spec adapters_topic() :: String.t()

PubSub topic carrying {:bluetooth_adapters_changed} on adapter add/remove.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

configured_mode()

@spec configured_mode() :: :passive | :active

The HA-configured scanner mode (:passive default). Pure :persistent_term read — safe on any target, with or without the Client running (host tests, early boot).

devices_seen(window_ms)

@spec devices_seen(pos_integer()) :: non_neg_integer()

Distinct devices the advert cache has seen in the last window_ms (Bluez.DeviceCache.seen_within/3). 0 when this Client isn't running — e.g. for a host's stats ticker.

resume_scan()

@spec resume_scan() :: :ok

Re-engage the HA-configured scanner mode after suspend_scan/0.

set_mode(mode)

@spec set_mode(:passive | :active) :: :ok | {:error, term()}

Switch the scanner between :passive (AdvertisementMonitor) and :active (StartDiscovery) at runtime. Returns once the BlueZ transition completes.

A caller whose transition is already in flight always gets that transition's own result. Only callers parked behind an in-flight transition can get {:error, :superseded} — when a newer set_mode/1 asking for a different mode displaces them (same-mode callers coalesce and succeed together).

Callers must catch :exit for the not-running/timeout cases (see the moduledoc idiom note in Bluez).

start_link(opts \\ [])

suspend_scan()

@spec suspend_scan() :: :ok

Suspend scanning entirely (disengage the monitor/discovery), preserving the HA-configured mode so resume_scan/0 restores it. Used by Improv Wi-Fi provisioning: it only runs on a no-connectivity boot, when there is no HA client to consume proxied advertisements anyway — so scanning is pointless (and may degrade the active BLE peripheral connection on a single radio).

Fire-and-forget cast (the transition runs off-loop); safe to call when the Client isn't running (no-op).