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 BlueZAdvertisementMonitor. 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'sset_method_handler/2).:active—Adapter1.StartDiscoverywith 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:
Bluez.Rebus.connect(:system),set_method_handler(self()), monitor the connection, and install bus match rules for org.bluez device signals.- Power the adapter on, then engage
configured_mode/0: eitherAdvertisementMonitorManager1.RegisterMonitorour root object (BlueZ enumerates the monitor viaObjectManager.GetManagedObjectsand callsActivate/DeviceFoundon it; the monitor'sor_patterns(FLAGS \x02/\x06/\x1a) match effectively all advertisers — the habluetooth "match all" recipe), orSetDiscoveryFilter+StartDiscovery. - Matched devices surface as
InterfacesAdded/PropertiesChangedsignals; props are unwrapped (Bluez.Variant) and fed toBluez.DeviceCache, which reconstructs + emit-gates and returns the adverts to fan out viaBluetoothScanner.on_advertisement/1.
Mode transitions:
- Run in a Task — BlueZ calls
GetManagedObjectsback on us beforeRegisterMonitorreturns, 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. Aset_mode/1arriving mid-transition parks in a one-slot pending queue keyed by target mode: callers asking for the same target coalesce (all get:okwhen 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/InProgressas success, so the next transition always converges on the target mode. - The configured mode persists in
:persistent_termacross 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(notProcess.sleep) so the GenServer stays responsive while waiting forbluetoothdto 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
@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.
@spec adapters_topic() :: String.t()
PubSub topic carrying {:bluetooth_adapters_changed} on adapter add/remove.
Returns a specification to start this module under a supervisor.
See Supervisor.
@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).
@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.
@spec resume_scan() :: :ok
Re-engage the HA-configured scanner mode after suspend_scan/0.
@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).
@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).