Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions config/target.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ config :universal_proxy, UniversalProxyWeb.Endpoint,
# Use Ringlogger as the logger backend and remove :console.
# See https://hexdocs.pm/ring_logger/readme.html for more information on
# configuring ring_logger.

config :logger, backends: [RingLogger]
#
# Default to :info. Nerves firmware builds in MIX_ENV=dev, where the
# Logger level would otherwise default to :debug — capturing very chatty
# debug output (notably espex's per-advertisement logging) into the ring
# buffer by default. Bump to :debug at runtime when troubleshooting via
# `Logger.configure(level: :debug)` or `RingLogger`.
config :logger, level: :info, backends: [RingLogger]

# Use shoehorn to start the main application. See the shoehorn
# library documentation for more control in ordering how OTP
Expand Down
75 changes: 63 additions & 12 deletions lib/universal_proxy/audio/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ defmodule UniversalProxy.Audio.Server do
Tracks the live set of ALSA outputs and brokers state changes
between hardware, DETS, and the rest of the system.

Polls `Audio.Enumerate.safe/0` every 5 s — same cadence and
rationale as `UniversalProxy.UART.Server`. Whenever the enumerated
set diverges from the in-memory cache the server:
Re-enumerates `Audio.Enumerate.safe/0` on `sound`-subsystem kernel
uevents (via `NervesUEvent`, debounced) — falling back to a 5 s poll
only on host/dev where uevents aren't available. Either way an initial
enumeration runs at boot. Whenever the enumerated set diverges from the
in-memory cache the server:

* ensures a DETS row exists for new outputs (default `enabled =
true`, `volume = 50`, `muted = false`, fresh `client_id`),
Expand Down Expand Up @@ -49,6 +51,10 @@ defmodule UniversalProxy.Audio.Server do

@pubsub UniversalProxy.PubSub
@hotplug_interval 5_000
# Delay between a `sound` uevent and the re-enumeration it triggers:
# gives ALSA a beat to finish creating the card's sub-devices, and
# coalesces the burst of uevents one card emits into a single check.
@hotplug_debounce_ms 1_000
@mdns_port_base 8928
# Service types we always publish — used at boot to send a
# pre-emptive TTL=0 PTR goodbye so peers like Music Assistant's
Expand Down Expand Up @@ -181,7 +187,11 @@ defmodule UniversalProxy.Audio.Server do
# %{key => %{connection: :connected | :disconnected | :unknown,
# stream: %{codec, sample_rate, bit_depth, channels} | nil,
# last_error: String.t() | nil}}
connection_state: %{}
connection_state: %{},
# Debounce flag: a `sound` uevent schedules one delayed
# `:check_hotplug` and sets this; the burst of sub-device uevents a
# single card emits then coalesces into that one re-enumeration.
hotplug_pending: false
}

# Subscribe to our own state topic so binary-emitted volume/mute
Expand Down Expand Up @@ -214,13 +224,20 @@ defmodule UniversalProxy.Audio.Server do
# a fresh `Added` event on peers instead of a silent refresh.
send_preemptive_goodbyes(state)

# Start the timer AFTER state is built so a future failure between
# the two doesn't leak a timer pointing at a soon-to-be-dead PID.
# The immediate `send(self(), :check_hotplug)` makes the first
# poll fire on boot rather than after `interval` ms — Phase 4's
# LiveView would otherwise show an empty `/audio` page for 5 s.
if timer? and is_integer(interval) and interval > 0 do
:timer.send_interval(interval, self(), :check_hotplug)
# Hotplug detection. ALSA cards change only on discrete events, so
# prefer kernel uevents over a timer: subscribe to `NervesUEvent` and
# re-enumerate on `sound`-subsystem changes (debounced). Only when
# uevents aren't available (host/dev — `nerves_uevent` runs on Nerves
# targets only) do we fall back to the periodic `interval` poll. Either
Comment thread
bbangert marked this conversation as resolved.
# way the immediate `:check_hotplug` enumerates once at boot so the
# `/audio` page isn't empty until the first event. `start_timer: false`
# (tests) disables both; they drive enumeration via `check_now/1`.
if timer? do
unless subscribe_uevents() do
if is_integer(interval) and interval > 0,
do: :timer.send_interval(interval, self(), :check_hotplug)
end

send(self(), :check_hotplug)
end

Expand Down Expand Up @@ -327,7 +344,20 @@ defmodule UniversalProxy.Audio.Server do

@impl true
def handle_info(:check_hotplug, state) do
{:noreply, refresh_outputs(state)}
{:noreply, refresh_outputs(%{state | hotplug_pending: false})}
end

# Kernel uevent (via NervesUEvent's PropertyTable). Only `sound`-subsystem
# changes can move the ALSA output set; everything else is ignored.
# Debounced through `hotplug_pending` so one card's burst of sub-device
# uevents triggers a single re-enumeration.
def handle_info(%PropertyTable.Event{property: path}, state) do
if "sound" in path and not state.hotplug_pending do
Process.send_after(self(), :check_hotplug, @hotplug_debounce_ms)
{:noreply, %{state | hotplug_pending: true}}
else
{:noreply, state}
end
end

# Binary-emitted volume/mute events flow back through PubSub. Persist
Expand Down Expand Up @@ -416,6 +446,27 @@ defmodule UniversalProxy.Audio.Server do

# -- Private --

# Subscribe to kernel uevents (all `devices`; `handle_info` filters for
# the `sound` subsystem). Returns false when `nerves_uevent` isn't
# running (host/dev) so the caller falls back to a timer. The broad
# `["devices"]` pattern is a prefix match — the `sound` segment sits at a
# bus-dependent depth that a narrower pattern can't pin.
#
# The subscribe call itself is the readiness check (not a prior
# `Process.whereis`): if nerves_uevent hasn't started yet — e.g. this
# Server initialized before it on a cold boot — fall back to the timer
# for this incarnation, no whereis/subscribe TOCTOU race. An unstarted
# PropertyTable raises `ArgumentError` (unknown registry); a process
# that dies mid-call exits. Both mean "not available" → false.
defp subscribe_uevents do
NervesUEvent.subscribe(["devices"])
true
rescue
ArgumentError -> false
catch
:exit, _ -> false
end

defp refresh_outputs(state) do
enumerated = state.enumerate_module.safe()
current_keys = MapSet.new(Map.keys(state.outputs))
Expand Down
57 changes: 31 additions & 26 deletions lib/universal_proxy/bluetooth/radio_monitor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do
plus an `in_use?` mark on the radio the BlueZ subtree is driving
(selected/claimed — independent of the HA-facing `enabled` toggle).

Polls every 5 s — the same hotplug strategy as the Audio subsystem's
output enumeration — and broadcasts `{:bluetooth_radios, radios}` on
`UniversalProxy.Bluetooth.radios_topic()` whenever the list changes.
`refresh/1` re-enumerates immediately (the UI's Rescan button and the
radio-selection flow).
## Event-driven, not polled

The radio set only changes on discrete events, so this re-enumerates on
those rather than on a timer (an SoC radio is soldered in; USB dongles
announce themselves). The trigger is
`UniversalProxy.Bluez.Client.adapters_topic/0`, on which the Client
broadcasts `{:bluetooth_adapters_changed}` when it claims an adapter at
setup (boot, and after a radio-switch restart) and on every adapter
`InterfacesAdded`/`InterfacesRemoved` (hotplug). Subscribing before the
first enumeration closes the lost-edge race if a claim lands during init.
`refresh/1` re-enumerates on demand (the UI's Rescan button), the manual
escape hatch if an event is ever missed.

Broadcasts `{:bluetooth_radios, radios}` on
`UniversalProxy.Bluetooth.radios_topic/0` whenever the list changes.

Runs even while Bluetooth is disabled: the tab must list radios to pick
*before* the stack is enabled. Every external source is read
Expand All @@ -19,17 +29,17 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do

## Options (host-testability)

`:name`, `:sysfs_root`, `:pubsub`, `:poll_ms`, plus the
`:adapters_info_fun` injection point (defaults to
`UniversalProxy.Bluez.Client.adapters_info/0`).
`:name`, `:sysfs_root`, `:pubsub`, plus the `:adapters_info_fun`
injection point (defaults to
`UniversalProxy.Bluez.Client.adapters_info/0`). Tests drive a
re-enumeration by broadcasting `{:bluetooth_adapters_changed}` on
`UniversalProxy.Bluez.Client.adapters_topic/0`.
"""

use GenServer

alias UniversalProxy.Bluetooth.Radios
alias UniversalProxy.Bluez.DevicePath

@poll_ms 5_000
alias UniversalProxy.Bluez.{Client, DevicePath}

def start_link(opts \\ []) do
gen_opts =
Expand All @@ -42,7 +52,7 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do
end

@doc """
The current radio list (cached from the last poll/refresh):
The current radio list (cached from the last enumeration):

[%{hci:, address:, name:, chip:, bus:, detail:, bt_version:,
ble?:, bredr?:, in_use?:}]
Expand All @@ -63,21 +73,20 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do
state = %{
sysfs_root: Keyword.get(opts, :sysfs_root, "/sys/class/bluetooth"),
pubsub: Keyword.get(opts, :pubsub, UniversalProxy.PubSub),
poll_ms: Keyword.get(opts, :poll_ms, @poll_ms),
adapters_info_fun:
Keyword.get(opts, :adapters_info_fun, &UniversalProxy.Bluez.Client.adapters_info/0),
radios: []
}

{:ok, state, {:continue, :first_poll}}
# Subscribe BEFORE the first enumerate so a claim landing in between
# still triggers a re-enumerate (no lost-edge race).
Phoenix.PubSub.subscribe(state.pubsub, Client.adapters_topic())

{:ok, state, {:continue, :enumerate}}
end

@impl GenServer
def handle_continue(:first_poll, state) do
state = poll(state)
schedule(state)
{:noreply, state}
end
def handle_continue(:enumerate, state), do: {:noreply, poll(state)}

@impl GenServer
def handle_call(:list, _from, state), do: {:reply, state.radios, state}
Expand All @@ -88,11 +97,9 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do
end

@impl GenServer
def handle_info(:poll, state) do
state = poll(state)
schedule(state)
{:noreply, state}
end
# The adapter set changed (claim at setup, hotplug add/remove) — the only
# thing that moves the radio list. Re-enumerate.
def handle_info({:bluetooth_adapters_changed}, state), do: {:noreply, poll(state)}

def handle_info(_other, state), do: {:noreply, state}

Expand Down Expand Up @@ -137,6 +144,4 @@ defmodule UniversalProxy.Bluetooth.RadioMonitor do
end

defp by_hci(_), do: %{}

defp schedule(state), do: Process.send_after(self(), :poll, state.poll_ms)
end
Loading
Loading