Bluetooth web-tab backend: settings, lifecycle, radio selection, stats - #56
Merged
Conversation
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>
There was a problem hiding this comment.
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.Bluetoothpublic 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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 topicsbluetooth:state,bluetooth:stats(1 s tick), andbluetooth:radios.Architecture
enabled/active_connectionsgate 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.Bluez.Clientat 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.Bluetooth.Managerunder a DynamicSupervisor: boot start, failed-start retry, crash re-bind, state broadcasts./proc/device-tree/modeldisambiguation) overlaid with live Adapter1 identity, 5 s hotplug poll.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_childnever 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