Skip to content

Bluetooth web-tab backend: settings, lifecycle, radio selection, stats - #56

Merged
bbangert merged 11 commits into
mainfrom
feat/bluetooth-ui-api
Jun 13, 2026
Merged

Bluetooth web-tab backend: settings, lifecycle, radio selection, stats#56
bbangert merged 11 commits into
mainfrom
feat/bluetooth-ui-api

Conversation

@bbangert

Copy link
Copy Markdown
Owner

Backend/API for the new Bluetooth UI page. Defines and implements the contract the LiveView consumes: public functions on UniversalProxy.Bluetooth (status/0, stats/0, list_radios/0, refresh_radios/0, set_enabled/1, set_active_connections/1, select_radio/1) plus PubSub topics bluetooth:state, bluetooth:stats (1 s tick), and bluetooth:radios.

Architecture

  • Always-on stack: the BlueZ subtree runs whenever the hardware supports it. enabled/active_connections gate only the espex wiring (bluetooth_opts/2): disabled → no adapters (flags 0, data ignored — nothing subscribes), active-connections off → scanner only. Toggles are instant; no daemon churn.
  • Radio selection by MAC, resolved inside Bluez.Client at setup against Adapter1 objects (hardware finding: sysfs exposes no BT MAC). The Manager publishes the desired MAC via :persistent_term; switching restarts the subtree on the new adapter with a 1.5 s L2CAP settle.
  • Subtree liveness owned by the new Bluetooth.Manager under a DynamicSupervisor: boot start, failed-start retry, crash re-bind, state broadcasts.
  • Radio list from sysfs (bus/chip via modalias + /proc/device-tree/model disambiguation) overlaid with live Adapter1 identity, 5 s hotplug poll.
  • Stats: atomic ad counter in the advert hot path, windowed device count over the DeviceCache, GATT slot pushes on connect/disconnect.

Validation

479 host tests, strict compile on host + rpi3, reviewed (elixir-reviewer + security-analyzer, fixes applied). Hardware-validated on the rpi3 testbed across three firmware iterations, which surfaced and fixed: a stale bus-socket race defeating BusReady, failed DynamicSupervisor.start_child never being retried, and the old bluetoothd's L2CAP sockets outliving its exit. Final switch round-trip runs clean; chip identified as BCM4345C0 on the 3B+.

Not validated: dual-radio switching (the ASUS BT500 dongle was not attached to the Pi) and the HA-side flag check — follow-ups before/with the UI PR.

🤖 Generated with Claude Code

bbangert and others added 9 commits June 12, 2026 22:33
DETS-persisted settings (enabled/active_connections/adapter MAC) and a
Manager that starts/stops the Bluez subtree under a DynamicSupervisor,
resolving the selected radio MAC to an adapter path via sysfs and
publishing it through :persistent_term before each subtree start.
Status map + "bluetooth:state" PubSub broadcasts for the web tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DevicePath owns the {UniversalProxy.Bluez, :adapter_path} key (default
/org/bluez/hci0); Bluez.Client reads it per call instead of a
compile-time @adapter_path, so the Manager can point the subtree at any
controller before (re)starting it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Radios.enumerate/1 classifies each hci controller (SoC UART vs USB with
port/speed) and identifies chips from a static modalias/VID:PID table.
RadioMonitor polls every 5s, overlays live Adapter1 names via the new
Client.adapters_info/0, marks the in-use radio, and broadcasts list
changes on "bluetooth:radios". Public list_radios/0 + refresh_radios/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_enabled/set_active_connections persist, reconcile the Bluez subtree,
and restart espex so HA's bluetooth flags follow the settings (disabled
= no adapters, active-connections off = scanner only). select_radio
validates against the enumerated list and restarts the subtree on the
new adapter path; Client setup best-effort powers off deselected radios.
Bluetooth subtree now starts before the ESPHome supervisor so espex sees
persisted settings on first boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stats GenServer broadcasts a 1s tick on "bluetooth:stats". The advert
hot path bumps an atomic :counters ref published via persistent_term
(no-op when Stats is down); devices_15min is a windowed count over the
DeviceCache; Gatt pushes off-tick connection-slot updates on
connect/disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Demonitor before re-monitoring on already_started; validate Settings
field types on read (a corrupt enabled would crash reconcile); run the
espex restart under the app Task.Supervisor; tolerate malformed hci
names in fixture trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware finding: the kernel exposes no MAC under
/sys/class/bluetooth/hciX, so the Manager cannot map MAC to hci while
bluetoothd is down. The Manager now publishes the desired MAC via
persistent_term and Bluez.Client resolves it against Adapter1 objects
during setup, claiming the adapter path itself. Radio-list addresses
come solely from the live daemon overlay, and the ambiguous bcm43438
DT compatible is refined via /proc/device-tree/model (the 3B+ actually
carries a BCM4345C0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User decision during hardware validation: the enabled toggle no longer
stops the Bluez subtree — from the Elixir side only rebus utilization
matters. enabled/active_connections gate the espex wiring (flags 0,
nothing subscribes), keeping toggles instant and the radio list (whose
MACs only exist via the daemon) available while disabled. Also fixes
two hw-found restart bugs that still affect radio switches: a stale
bus-socket file defeating BusReady, and failed subtree starts never
being retried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware-found: the old bluetoothd releases its L2CAP listening sockets
a beat after exiting; an immediately-started replacement fails adapter
registration and idles ~10s until the no-adapter loop bounces it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-clear global terms in setup (crashed predecessors leak past
on_exit); sum stats bumps across tick boundaries; widen the
crash-rebind budget; assert the broadcast the no-op reconcile test
claims; cover the stats/0 degraded path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds the backend/API surface for a new Bluetooth LiveView page by introducing persisted Bluetooth settings, a runtime manager that owns BlueZ subtree liveness + radio switching, sysfs-based radio enumeration with live BlueZ overlays, and a stats broadcaster for the UI.

Changes:

  • Add UniversalProxy.Bluetooth public API (status/stats/radios/settings setters) and new subsystems: Bluetooth.Settings, Bluetooth.Manager, Bluetooth.RadioMonitor, Bluetooth.Radios, Bluetooth.Stats.
  • Update the BlueZ stack to support adapter selection by MAC via :persistent_term (desired MAC → Bluez.Client resolves to Adapter1 path).
  • Wire ESPHome Bluetooth adapters based on persisted settings (enabled/active_connections), and add extensive host test coverage for the new behavior.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/universal_proxy/application.ex Starts UniversalProxy.Bluetooth before the ESPHome supervisor so espex wiring can read persisted BT settings at boot.
lib/universal_proxy/bluetooth.ex Defines the Bluetooth public API and supervisor tree (settings/manager/radios/stats + topics).
lib/universal_proxy/bluetooth/manager.ex New lifecycle owner for the BlueZ subtree (boot start, failed-start retry, crash re-bind, radio-switch restarts, state broadcasts).
lib/universal_proxy/bluetooth/radio_monitor.ex New poll/refresh + PubSub broadcaster that merges sysfs radio enumeration with live Adapter1 identity and in-use marking.
lib/universal_proxy/bluetooth/radios.ex New sysfs-based radio enumeration with bus/chip identification and stable ordering.
lib/universal_proxy/bluetooth/settings.ex New DETS-backed persistence for Bluetooth tab settings (enabled/active_connections/adapter MAC).
lib/universal_proxy/bluetooth/stats.ex New periodic stats broadcaster (ads/s, devices window, GATT slot usage) with a low-overhead ad counter.
lib/universal_proxy/bluez.ex Ensures stale system bus socket file is removed on restart to avoid BusReady false positives.
lib/universal_proxy/bluez/client.ex Adds adapter selection/claiming logic and exposes adapters_info/0 + devices_seen/1 for UI/Stats.
lib/universal_proxy/bluez/device_cache.ex Adds seen_within/3 windowed distinct-device count for stats.
lib/universal_proxy/bluez/device_path.ex Makes device path logic adapter-aware via :persistent_term adapter path + desired adapter MAC keys.
lib/universal_proxy/bluez/gatt.ex Notifies Stats on connect/disconnect to push off-tick connection usage updates.
lib/universal_proxy/esphome/bluetooth_scanner.ex Bumps the ad counter on each advertisement to feed the web-tab stats.
lib/universal_proxy/esphome/supervisor.ex Makes espex Bluetooth adapter wiring depend on support + persisted settings (enabled/active_connections), and exposes a pure bluetooth_opts/2 for testing.
test/universal_proxy/bluetooth_test.exs New integration-style tests for the UniversalProxy.Bluetooth public API behavior.
test/universal_proxy/bluetooth/manager_test.exs New tests for Manager boot reconcile, restart behavior, failed-start retry, and crash re-bind.
test/universal_proxy/bluetooth/radio_monitor_test.exs New tests for radio enumeration overlay, change broadcasting, and polling refresh behavior.
test/universal_proxy/bluetooth/radios_test.exs New tests for sysfs radio classification and chip identification.
test/universal_proxy/bluetooth/settings_test.exs New tests for Settings defaults, validation, persistence, and sanitization.
test/universal_proxy/bluetooth/stats_test.exs New tests for periodic stats broadcasting, ad counting/draining, and off-tick connection updates.
test/universal_proxy/bluez/device_cache_test.exs Adds tests for DeviceCache.seen_within/3.
test/universal_proxy/bluez/device_path_test.exs Adds tests for runtime adapter path behavior and makes the module non-async due to persistent_term usage.
test/universal_proxy/esphome/supervisor_test.exs New tests for bluetooth_opts/2 matrix behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/universal_proxy/bluetooth/settings.ex
select_radio calls reconcile(restart: true) — a keyword first arg the
lone-keyword clause handles at runtime, but the spec typed the first arg
as GenServer.server() only, so dialyzer rejected both call sites. Broaden
the spec to GenServer.server() | keyword(). Also correct the Settings
moduledoc: enabled gates espex wiring, not the always-on radio stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bbangert
bbangert merged commit 1201672 into main Jun 13, 2026
6 checks passed
@bbangert
bbangert deleted the feat/bluetooth-ui-api branch June 13, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants