From 6b54066b6b27f4a9f7d70d5e07c2f2d4d38708ba Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:12:06 +0000 Subject: [PATCH 1/7] Track an output player's identity-persistence failure Audio.Server marks an output identity_error on the binary's state_dir error and clears it once a later start listens, so the Sendspin adapter can answer FAILED. Co-Authored-By: Claude Opus 5.5 --- lib/universal_proxy/audio/server.ex | 14 +++++++++++-- test/universal_proxy/audio/server_test.exs | 23 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/universal_proxy/audio/server.ex b/lib/universal_proxy/audio/server.ex index 7573330f..d03682ee 100644 --- a/lib/universal_proxy/audio/server.ex +++ b/lib/universal_proxy/audio/server.ex @@ -515,6 +515,14 @@ defmodule UniversalProxy.Audio.Server do end def handle_info({:sendspin_state, key, %{event: "error"} = payload}, state) do + # A keypair the binary could not persist makes it exit before it + # listens, on every respawn: sticky (a respawn's `shutdown` reset keeps + # it) until a later start does listen. + state = + if Map.get(payload, :kind) == "state_dir", + do: update_connection_state(state, key, %{identity_error: true}), + else: state + # Bound the cached error string. The binary's `:msg` may be a # server-forwarded value of arbitrary size; a multi-MB string would # otherwise sit in Server state and ship over the LiveView socket @@ -547,7 +555,8 @@ defmodule UniversalProxy.Audio.Server do broadcast_state(key, %{sendspin_client_id: id}) end - {:noreply, update_connection_state(state, key, %{sendspin_client_id: id})} + {:noreply, + update_connection_state(state, key, %{sendspin_client_id: id, identity_error: false})} end def handle_info({:sendspin_state, key, %{event: "shutdown"}}, state) do @@ -1185,7 +1194,8 @@ defmodule UniversalProxy.Audio.Server do connection: :unknown, stream: nil, last_error: nil, - sendspin_client_id: nil + sendspin_client_id: nil, + identity_error: false } defp update_connection_state(state, key, patch) do diff --git a/test/universal_proxy/audio/server_test.exs b/test/universal_proxy/audio/server_test.exs index 752b7787..690d0f00 100644 --- a/test/universal_proxy/audio/server_test.exs +++ b/test/universal_proxy/audio/server_test.exs @@ -556,6 +556,29 @@ defmodule UniversalProxy.Audio.ServerTest do {:sendspin_state, @hp_key, %{sendspin_client_id: "derived-id"}} ] = collect_messages(3) end + + test "a state_dir error marks the identity failed until a later start listens", %{ + server: server + } do + EnumerateStub.set(%{@hp_key => hp_hardware()}) + :ok = Server.check_now(server) + + assert {:ok, %{identity_error: false}} = Server.get_output(server, @hp_key) + + # Another kind of error leaves it alone. + send(server, {:sendspin_state, @hp_key, %{event: "error", kind: "pairing_token", msg: "x"}}) + assert {:ok, %{identity_error: false}} = Server.get_output(server, @hp_key) + + send(server, {:sendspin_state, @hp_key, %{event: "error", kind: "state_dir", msg: "x"}}) + assert {:ok, %{identity_error: true}} = Server.get_output(server, @hp_key) + + # The respawn's shutdown reset keeps it. + send(server, {:sendspin_state, @hp_key, %{event: "shutdown"}}) + assert {:ok, %{identity_error: true}} = Server.get_output(server, @hp_key) + + send(server, {:sendspin_state, @hp_key, %{event: "listening", client_id: "derived-id"}}) + assert {:ok, %{identity_error: false}} = Server.get_output(server, @hp_key) + end end describe "hotplug remove" do From 96e23e15c2c67f290030b16777ad0c0959bd2053 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:12:06 +0000 Subject: [PATCH 2/7] Report a selected output's Sendspin client to Home Assistant Adds a persisted Home Assistant pairing selection (Audio.HomeAssistantPairing) and an Espex.Sendspin adapter wired only while one exists. Selection and client_id changes update Espex's adapters and reconnect HA so it re-reads the capabilities. Co-Authored-By: Claude Opus 5.5 --- lib/universal_proxy/audio.ex | 41 ++- .../audio/home_assistant_pairing.ex | 294 +++++++++++++++++ lib/universal_proxy/audio/store.ex | 66 ++++ lib/universal_proxy/audio/supervisor.ex | 8 +- lib/universal_proxy/esphome/sendspin.ex | 79 +++++ lib/universal_proxy/esphome/supervisor.ex | 19 ++ .../audio/home_assistant_pairing_test.exs | 306 ++++++++++++++++++ test/universal_proxy/audio/store_test.exs | 35 ++ .../universal_proxy/esphome/sendspin_test.exs | 66 ++++ .../esphome/supervisor_test.exs | 16 + 10 files changed, 928 insertions(+), 2 deletions(-) create mode 100644 lib/universal_proxy/audio/home_assistant_pairing.ex create mode 100644 lib/universal_proxy/esphome/sendspin.ex create mode 100644 test/universal_proxy/audio/home_assistant_pairing_test.exs create mode 100644 test/universal_proxy/esphome/sendspin_test.exs diff --git a/lib/universal_proxy/audio.ex b/lib/universal_proxy/audio.ex index 9161d44f..4a4ffdfb 100644 --- a/lib/universal_proxy/audio.ex +++ b/lib/universal_proxy/audio.ex @@ -13,7 +13,7 @@ defmodule UniversalProxy.Audio do `{card_name, nil, nil}` (e.g. `{"bcm2835 Headphones", nil, nil}`). """ - alias UniversalProxy.Audio.{PairingToken, Player, Server, Store} + alias UniversalProxy.Audio.{HomeAssistantPairing, PairingToken, Player, Server, Store} @typedoc """ Composite identifier for an audio output: @@ -195,4 +195,43 @@ defmodule UniversalProxy.Audio do catch :exit, _ -> {:error, :unavailable} end + + @doc """ + The output reported to Home Assistant for Sendspin auto-pairing, or + `nil` when none is (or the audio subsystem is down). See + `UniversalProxy.Audio.HomeAssistantPairing`. + """ + @spec home_assistant_pairing() :: key() | nil + def home_assistant_pairing do + case HomeAssistantPairing.selection() do + %{key: key} -> key + nil -> nil + end + catch + :exit, _ -> nil + end + + @doc """ + Report the output `key` to Home Assistant for Sendspin auto-pairing, + replacing any other. `{:error, :not_found}` when the output isn't + present, `{:error, :unavailable}` while the audio subsystem is down. + """ + @spec select_home_assistant_pairing(key()) :: + :ok | {:error, :not_found | :unavailable | term()} + def select_home_assistant_pairing({_, _, _} = key) do + HomeAssistantPairing.select(key) + catch + :exit, _ -> {:error, :unavailable} + end + + @doc """ + Stop reporting any output to Home Assistant. `{:error, :unavailable}` + while the audio subsystem is down. + """ + @spec clear_home_assistant_pairing() :: :ok | {:error, :unavailable | term()} + def clear_home_assistant_pairing do + HomeAssistantPairing.clear() + catch + :exit, _ -> {:error, :unavailable} + end end diff --git a/lib/universal_proxy/audio/home_assistant_pairing.ex b/lib/universal_proxy/audio/home_assistant_pairing.ex new file mode 100644 index 00000000..8b56a4a7 --- /dev/null +++ b/lib/universal_proxy/audio/home_assistant_pairing.ex @@ -0,0 +1,294 @@ +defmodule UniversalProxy.Audio.HomeAssistantPairing do + @moduledoc """ + The one audio output whose Sendspin client is reported to Home Assistant, + so HA can read its pairing token and hand it to Music Assistant + (`UniversalProxy.ESPHome.Sendspin`, the `Espex.Sendspin` adapter). + + The ESPHome protocol carries one Sendspin client per device, but every + output runs its own player. So nothing is reported until the user selects + an output; the selection is persisted in `UniversalProxy.Audio.Store` + along with the last Sendspin `client_id` seen for it. + + ## Keeping Home Assistant in step + + Espex has the `:sendspin` adapter only while a selection exists: + `UniversalProxy.ESPHome.Supervisor.start_espex/2` reads `selected?/1` + whenever Espex starts, and a change made while it runs is applied with + `Espex.update_adapters/2`, then `Espex.disconnect_clients/1` so Home + Assistant reconnects and re-reads the device capabilities. Both steps are + retried with backoff (Espex down or mid-restart) until they succeed. + + HA reads the `client_id` only when it connects, so a new one for the + selected output (its player listening for the first time, or with a new + keypair) also disconnects the clients. The cached `client_id` survives + unplugs and reboots, so HA keeps seeing the same id while the output is + away or its player is starting; the token request answers `:not_ready` + meanwhile. + + ## Unplugged outputs + + The selection is kept while its output is unplugged, and applies again + when it comes back. Only an output that is present can be selected. + + Changes are broadcast on `topic/0` as `{:home_assistant_pairing, key | + nil}`. The pairing token never passes through this process. + """ + + use GenServer + + require Logger + + alias UniversalProxy.Audio.{Server, Store} + + @pubsub UniversalProxy.PubSub + @topic "sendspin:home_assistant_pairing" + @state_topic "sendspin:state" + @default_retry_ms 1_000 + @max_retry_ms 30_000 + + @type key :: Store.output_key() + @type selection :: Store.home_assistant_pairing() + + defstruct selection: nil, + store: Store, + audio_server: Server, + pubsub: @pubsub, + espex_server: Espex.Server, + adapter: UniversalProxy.ESPHome.Sendspin, + update_adapters: nil, + disconnect_clients: nil, + pending: nil, + timer: nil, + retry_ms: @default_retry_ms, + backoff_ms: @default_retry_ms + + # -- Client API -- + + @doc """ + Starts the process. Options: `:name` (default `#{inspect(__MODULE__)}`, + `nil` for none), `:store` and `:audio_server` (the `Audio.Store` and + `Audio.Server` to use), `:pubsub`, `:espex_server`, `:adapter` (the + module wired as `:sendspin`), `:retry_ms`, and the Espex seams + `:update_adapters` (`(server, changes) -> :ok | {:error, _}`) and + `:disconnect_clients` (`(server) -> :ok`). + """ + def start_link(opts \\ []) do + gen_opts = + case Keyword.get(opts, :name, __MODULE__) do + nil -> [] + name -> [name: name] + end + + GenServer.start_link(__MODULE__, opts, gen_opts) + end + + @doc "The PubSub topic selection changes are broadcast on." + @spec topic() :: String.t() + def topic, do: @topic + + @doc "The current selection, or `nil` when no output is selected." + @spec selection(GenServer.server()) :: selection() | nil + def selection(server \\ __MODULE__), do: GenServer.call(server, :selection) + + @doc """ + Whether an output is selected; `false` while this process is down (the + `catch :exit` idiom). + """ + @spec selected?(GenServer.server()) :: boolean() + def selected?(server \\ __MODULE__) do + selection(server) != nil + catch + :exit, _ -> false + end + + @doc """ + Select the output `key`, replacing any other selection. + `{:error, :not_found}` when the output isn't present, + `{:error, :unavailable}` while the audio subsystem is down, or + `{:error, reason}` if DETS rejects the write. + """ + @spec select(GenServer.server(), key()) :: + :ok | {:error, :not_found | :unavailable | term()} + def select(server \\ __MODULE__, {slot_sub, _vid, _pid} = key) when is_binary(slot_sub) do + GenServer.call(server, {:select, key}) + end + + @doc "Clear the selection. `{:error, reason}` if DETS rejects the write." + @spec clear(GenServer.server()) :: :ok | {:error, term()} + def clear(server \\ __MODULE__), do: GenServer.call(server, :clear) + + # -- Server Callbacks -- + + @impl true + def init(opts) do + t = %__MODULE__{ + store: Keyword.get(opts, :store, Store), + audio_server: Keyword.get(opts, :audio_server, Server), + pubsub: Keyword.get(opts, :pubsub, @pubsub), + espex_server: Keyword.get(opts, :espex_server, Espex.Server), + adapter: Keyword.get(opts, :adapter, UniversalProxy.ESPHome.Sendspin), + update_adapters: Keyword.get(opts, :update_adapters, &Espex.update_adapters/2), + disconnect_clients: Keyword.get(opts, :disconnect_clients, &Espex.disconnect_clients/1), + retry_ms: Keyword.get(opts, :retry_ms, @default_retry_ms), + backoff_ms: Keyword.get(opts, :retry_ms, @default_retry_ms) + } + + Phoenix.PubSub.subscribe(t.pubsub, @state_topic) + t = %{t | selection: load(t)} + + # Catch up with a `client_id` that arrived while this process was down. + t = + case t.selection && live_client_id(t, t.selection.key) do + id when is_binary(id) -> observe_client_id(t, id) + _none -> t + end + + {:ok, t} + end + + @impl true + def handle_call(:selection, _from, t), do: {:reply, t.selection, t} + + def handle_call({:select, key}, _from, %{selection: %{key: key}} = t), do: {:reply, :ok, t} + + def handle_call({:select, key}, _from, t) do + case get_output(t, key) do + {:ok, output} -> + id = Map.get(output, :sendspin_client_id) + change(t, %{key: key, client_id: if(is_binary(id), do: id)}) + + {:error, _reason} = error -> + {:reply, error, t} + end + end + + def handle_call(:clear, _from, %{selection: nil} = t), do: {:reply, :ok, t} + def handle_call(:clear, _from, t), do: change(t, nil) + + @impl true + def handle_info({:sendspin_state, key, payload}, %{selection: %{key: key}} = t) do + case client_id_of(payload) do + id when is_binary(id) -> {:noreply, observe_client_id(t, id)} + nil -> {:noreply, t} + end + end + + def handle_info({:retry, token}, %{timer: token} = t), + do: {:noreply, apply_pending(%{t | timer: nil})} + + def handle_info(_msg, t), do: {:noreply, t} + + # -- Private -- + + defp change(t, selection) do + case store_put(t, selection) do + :ok -> + Logger.info( + "Home Assistant Sendspin pairing: " <> + if(selection, do: "reporting #{inspect(selection.key)}", else: "cleared") + ) + + Phoenix.PubSub.broadcast(t.pubsub, @topic, {:home_assistant_pairing, key_of(selection)}) + {:reply, :ok, apply_pending(%{t | selection: selection, pending: :update})} + + {:error, _reason} = error -> + {:reply, error, t} + end + end + + # The binary's one-shot `listening` event, or `Audio.Server`'s re-publish + # of it as `%{sendspin_client_id: id}`. + defp client_id_of(%{event: "listening", client_id: id}) when is_binary(id), do: id + defp client_id_of(%{sendspin_client_id: id}) when is_binary(id), do: id + defp client_id_of(_payload), do: nil + + defp observe_client_id(%{selection: %{client_id: id}} = t, id), do: t + + defp observe_client_id(t, id) do + selection = %{t.selection | client_id: id} + + case store_put(t, selection) do + :ok -> + :ok + + {:error, reason} -> + Logger.warning( + "Home Assistant Sendspin pairing: could not persist the client id (#{inspect(reason)})" + ) + end + + # An :update already pending reconnects once it has been applied. + pending = t.pending || :reconnect + apply_pending(%{t | selection: selection, pending: pending}) + end + + defp apply_pending(%{pending: nil} = t), do: t + + defp apply_pending(%{pending: :update} = t) do + adapter = if t.selection, do: t.adapter + + case espex(fn -> t.update_adapters.(t.espex_server, sendspin: adapter) end) do + :ok -> apply_pending(%{t | pending: :reconnect}) + error -> retry(t, error) + end + end + + defp apply_pending(%{pending: :reconnect} = t) do + case espex(fn -> t.disconnect_clients.(t.espex_server) end) do + :ok -> %{t | pending: nil, backoff_ms: t.retry_ms} + error -> retry(t, error) + end + end + + defp retry(%{timer: token} = t, _error) when is_reference(token), do: t + + defp retry(t, error) do + Logger.debug( + "Home Assistant Sendspin pairing: Espex #{t.pending} failed (#{inspect(error)}); retrying" + ) + + token = make_ref() + Process.send_after(self(), {:retry, token}, t.backoff_ms) + %{t | timer: token, backoff_ms: min(t.backoff_ms * 2, @max_retry_ms)} + end + + # Espex down or restarting: a call exits, its registry raises. + defp espex(fun) do + fun.() + rescue + ArgumentError -> {:error, :not_running} + catch + :exit, _reason -> {:error, :not_running} + end + + defp load(t) do + Store.get_home_assistant_pairing(t.store) + catch + :exit, _reason -> nil + end + + defp store_put(t, selection) do + Store.put_home_assistant_pairing(t.store, selection) + catch + :exit, _reason -> {:error, :unavailable} + end + + defp get_output(t, key) do + case Server.get_output(t.audio_server, key) do + {:ok, output} -> {:ok, output} + :error -> {:error, :not_found} + end + catch + :exit, _reason -> {:error, :unavailable} + end + + defp live_client_id(t, key) do + case get_output(t, key) do + {:ok, output} -> Map.get(output, :sendspin_client_id) + {:error, _reason} -> nil + end + end + + defp key_of(nil), do: nil + defp key_of(%{key: key}), do: key +end diff --git a/lib/universal_proxy/audio/store.ex b/lib/universal_proxy/audio/store.ex index a065fdfc..f834a843 100644 --- a/lib/universal_proxy/audio/store.ex +++ b/lib/universal_proxy/audio/store.ex @@ -60,6 +60,8 @@ defmodule UniversalProxy.Audio.Store do alias UniversalProxy.Display.Mode @default_table :audio_outputs + # Not an output key (those are 3-tuples), so `get_all/1` skips it. + @home_assistant_pairing :home_assistant_pairing @type slot_sub :: String.t() @type vendor_id :: non_neg_integer() | nil @@ -131,6 +133,39 @@ defmodule UniversalProxy.Audio.Store do GenServer.call(server, :all) end + @typedoc """ + The output reported to Home Assistant for Sendspin auto-pairing (see + `UniversalProxy.Audio.HomeAssistantPairing`): its key and the last + Sendspin `client_id` seen for it (`nil` until its player has listened). + """ + @type home_assistant_pairing :: %{key: output_key(), client_id: String.t() | nil} + + @doc """ + The persisted Home Assistant pairing selection, or `nil` when none is + set. Kept in the same table as the output rows, under a non-tuple key + that `get_all/1` skips. + """ + @spec get_home_assistant_pairing(GenServer.server()) :: home_assistant_pairing() | nil + def get_home_assistant_pairing(server \\ __MODULE__) do + GenServer.call(server, :get_home_assistant_pairing) + end + + @doc """ + Persist the Home Assistant pairing selection; `nil` clears it. Returns + `{:error, reason}` if DETS rejects the write. + """ + @spec put_home_assistant_pairing(GenServer.server(), home_assistant_pairing() | nil) :: + :ok | {:error, term()} + def put_home_assistant_pairing(server \\ __MODULE__, selection) + + def put_home_assistant_pairing(server, nil), + do: GenServer.call(server, {:put_home_assistant_pairing, nil}) + + def put_home_assistant_pairing(server, %{key: {slot_sub, _, _}, client_id: id} = selection) + when is_binary(slot_sub) and (is_binary(id) or is_nil(id)) do + GenServer.call(server, {:put_home_assistant_pairing, Map.take(selection, [:key, :client_id])}) + end + # -- Server Callbacks -- @impl true @@ -199,6 +234,37 @@ defmodule UniversalProxy.Audio.Store do end end + def handle_call(:get_home_assistant_pairing, _from, state) do + result = + case :dets.lookup(state.table, @home_assistant_pairing) do + [{_, %{key: {slot_sub, _, _}, client_id: id} = selection}] + when is_binary(slot_sub) and (is_binary(id) or is_nil(id)) -> + selection + + _ -> + nil + end + + {:reply, result, state} + end + + def handle_call({:put_home_assistant_pairing, selection}, _from, state) do + write = + case selection do + nil -> :dets.delete(state.table, @home_assistant_pairing) + _ -> :dets.insert(state.table, {@home_assistant_pairing, selection}) + end + + with :ok <- write, + :ok <- :dets.sync(state.table) do + {:reply, :ok, state} + else + {:error, reason} -> + Logger.error("Audio store Home Assistant pairing save failed: #{inspect(reason)}") + {:reply, {:error, reason}, state} + end + end + def handle_call(:all, _from, state) do all = :dets.foldl( diff --git a/lib/universal_proxy/audio/supervisor.ex b/lib/universal_proxy/audio/supervisor.ex index 1f8cfbab..573d5aaf 100644 --- a/lib/universal_proxy/audio/supervisor.ex +++ b/lib/universal_proxy/audio/supervisor.ex @@ -20,7 +20,9 @@ defmodule UniversalProxy.Audio.Supervisor do 4. `UniversalProxy.Audio.Server` — the registry/orchestrator that polls enumeration, manages player lifecycle via the DynamicSupervisor above, and brokers PubSub events. - 5. `UniversalProxy.Audio.MdnsAnnouncer` — re-announces mDNS on + 5. `UniversalProxy.Audio.HomeAssistantPairing` — the output whose + Sendspin client is reported to Home Assistant for auto-pairing. + 6. `UniversalProxy.Audio.MdnsAnnouncer` — re-announces mDNS on network-up so players advertised during the boot race (before `eth0` had an address) become discoverable. """ @@ -43,6 +45,10 @@ defmodule UniversalProxy.Audio.Supervisor do UniversalProxy.Audio.Store, UniversalProxy.Audio.MdnsDiscovery, UniversalProxy.Audio.Server, + # The output reported to Home Assistant for Sendspin auto-pairing. + # After Store (its persistence) and Server (it reads the selected + # output's live client id at start). + UniversalProxy.Audio.HomeAssistantPairing, # Last child: re-announces mDNS when an interface gains an IPv4 so # players advertised before the network was ready (boot race) become # discoverable. Independent of the others; placed last so its crash diff --git a/lib/universal_proxy/esphome/sendspin.ex b/lib/universal_proxy/esphome/sendspin.ex new file mode 100644 index 00000000..eb20b656 --- /dev/null +++ b/lib/universal_proxy/esphome/sendspin.ex @@ -0,0 +1,79 @@ +defmodule UniversalProxy.ESPHome.Sendspin do + @moduledoc """ + `Espex.Sendspin` adapter: reports the Sendspin client of the output + selected in `UniversalProxy.Audio.HomeAssistantPairing`, so Home + Assistant can read its pairing token and pair it with Music Assistant. + + Wired as `:sendspin` only while an output is selected (see + `HomeAssistantPairing`). Both callbacks run in an Espex connection + process, so every read is defensive: a down audio subsystem reads as no + client id and `:not_ready`, never a crash. + + `pairing_token/0` answers, for the selected output: + + * `:not_ready` while it is unplugged, before its player has written a + token, or while the audio subsystem is down + * `:disabled` while it is turned off (or nothing is selected) + * `:failed` while its player cannot persist its identity keypair + (`Audio.Server`'s `identity_error`) + * `{:ok, token}` otherwise + + The token is read from the player's state dir on every request + (`UniversalProxy.Audio.pairing_token/1`) and never logged. + """ + + @behaviour Espex.Sendspin + + alias UniversalProxy.Audio + alias UniversalProxy.Audio.HomeAssistantPairing + + @impl true + def client_id, do: client_id(selection()) + + @impl true + def pairing_token do + case selection() do + %{key: key} -> pairing_token(key, Audio) + nil -> :disabled + end + end + + @doc false + @spec client_id(HomeAssistantPairing.selection() | nil) :: String.t() | nil + def client_id(%{client_id: id}) when is_binary(id), do: id + def client_id(_selection), do: nil + + @doc false + # `audio` is `UniversalProxy.Audio` or a test stand-in with the same + # `get_output/1` and `pairing_token/1`. + @spec pairing_token(Audio.key(), module()) :: Espex.Sendspin.pairing_token_result() + def pairing_token(key, audio) do + case get_output(audio, key) do + {:ok, %{enabled: false}} -> :disabled + {:ok, %{identity_error: true}} -> :failed + {:ok, _output} -> read_token(audio, key) + :error -> :not_ready + end + end + + # A file that can't be read or doesn't hold a token is rewritten by the + # player's next start, so it is not a permanent failure. + defp read_token(audio, key) do + case audio.pairing_token(key) do + {:ok, token} -> {:ok, token} + {:error, _reason} -> :not_ready + end + end + + defp get_output(audio, key) do + audio.get_output(key) + catch + :exit, _reason -> :error + end + + defp selection do + HomeAssistantPairing.selection() + catch + :exit, _reason -> nil + end +end diff --git a/lib/universal_proxy/esphome/supervisor.ex b/lib/universal_proxy/esphome/supervisor.ex index c5ec6ea1..38c13bea 100644 --- a/lib/universal_proxy/esphome/supervisor.ex +++ b/lib/universal_proxy/esphome/supervisor.ex @@ -31,6 +31,12 @@ defmodule UniversalProxy.ESPHome.Supervisor do provider's, the ones its entities are advertised with. Without that, a restart would come back with fewer sub-devices and Home Assistant would delete the missing ones, and their history, on the next connect. + + ## Sendspin + + Every Espex start also wires the `:sendspin` adapter (`Sendspin`) if an + output is selected for Home Assistant pairing (`sendspin_opts/1`); + `UniversalProxy.Audio.HomeAssistantPairing` applies changes while it runs. """ use Supervisor @@ -43,11 +49,13 @@ defmodule UniversalProxy.ESPHome.Supervisor do EntityProvider, Infrared, PskStore, + Sendspin, SerialProxy, ZigbeeProxy, ZWaveProxy } + alias UniversalProxy.Audio.HomeAssistantPairing alias UniversalProxy.Bluetooth.Settings, as: BluetoothSettings alias UniversalProxy.ESPHome.EntityProvider.MeshSync alias UniversalProxy.Hardware @@ -152,9 +160,20 @@ defmodule UniversalProxy.ESPHome.Supervisor do def start_espex(espex_opts, live_devices_fun) do espex_opts |> refresh_devices(live_devices_fun.()) + |> Keyword.merge(sendspin_opts(HomeAssistantPairing.selected?())) |> Espex.Supervisor.start_link() end + @doc """ + The `:sendspin` adapter wiring: `Sendspin` only while an output is + selected for Home Assistant pairing, so without one Espex reports no + Sendspin capability. Read on every Espex start; changes while it runs + are applied by `UniversalProxy.Audio.HomeAssistantPairing`. + """ + @spec sendspin_opts(boolean()) :: keyword() + def sendspin_opts(true), do: [sendspin: Sendspin] + def sendspin_opts(false), do: [] + @doc false @spec refresh_devices(keyword(), {:ok, [Espex.DeviceConfig.Device.t()]} | term()) :: keyword() def refresh_devices(espex_opts, {:ok, devices}), diff --git a/test/universal_proxy/audio/home_assistant_pairing_test.exs b/test/universal_proxy/audio/home_assistant_pairing_test.exs new file mode 100644 index 00000000..d7a84903 --- /dev/null +++ b/test/universal_proxy/audio/home_assistant_pairing_test.exs @@ -0,0 +1,306 @@ +defmodule UniversalProxy.Audio.HomeAssistantPairingTest do + # async: false — the Store's DETS table name is a fixed atom. + use ExUnit.Case, async: false + + alias UniversalProxy.Audio.{HomeAssistantPairing, Store} + + @table :ha_pairing_test_store + @pubsub :ha_pairing_test_pubsub + @key {"bcm2835 Headphones", nil, nil} + @usb_key {"USB Audio", 0x1D6B, 0x0104} + + defmodule AudioServerStub do + @moduledoc false + # Answers `Audio.Server.get_output/2` from a map of present outputs. + use GenServer + + def start_link(outputs), do: GenServer.start_link(__MODULE__, outputs) + def set(pid, outputs), do: GenServer.call(pid, {:set, outputs}) + + @impl true + def init(outputs), do: {:ok, outputs} + + @impl true + def handle_call({:get_output, key}, _from, outputs), + do: {:reply, Map.fetch(outputs, key), outputs} + + def handle_call({:set, outputs}, _from, _outputs), do: {:reply, :ok, outputs} + end + + setup do + path = + Path.join(System.tmp_dir!(), "ha_pairing_test_#{System.unique_integer([:positive])}.dets") + + on_exit(fn -> File.rm(path) end) + + start_supervised!({Phoenix.PubSub, name: @pubsub}) + store = start_supervised!({Store, name: nil, table: @table, dets_path: path}) + + audio = + start_supervised!( + {AudioServerStub, + %{ + @key => %{key: @key, enabled: true, sendspin_client_id: "hp-id"}, + @usb_key => %{key: @usb_key, enabled: true, sendspin_client_id: nil} + }} + ) + + Phoenix.PubSub.subscribe(@pubsub, HomeAssistantPairing.topic()) + {:ok, store: store, audio: audio} + end + + # Records the Espex calls; `results` (an Agent) scripts their replies. + defp start_pairing!(ctx, opts \\ []) do + test = self() + results = start_supervised!({Agent, fn -> %{} end}, id: :results) + + reply = fn call -> + Agent.get_and_update(results, fn r -> + case Map.get(r, call, []) do + [next | rest] -> {next, Map.put(r, call, rest)} + [] -> {:ok, r} + end + end) + end + + defaults = [ + name: nil, + store: ctx.store, + audio_server: ctx.audio, + pubsub: @pubsub, + espex_server: :espex_test, + adapter: UniversalProxy.ESPHome.Sendspin, + retry_ms: 10, + update_adapters: fn server, changes -> + send(test, {:update_adapters, server, changes}) + result(reply.(:update_adapters)) + end, + disconnect_clients: fn server -> + send(test, {:disconnect_clients, server}) + result(reply.(:disconnect_clients)) + end + ] + + pid = start_supervised!({HomeAssistantPairing, Keyword.merge(defaults, opts)}) + {pid, results} + end + + defp result(:exit), do: exit(:noproc) + defp result(:raise), do: raise(ArgumentError, "unknown registry") + defp result(other), do: other + + defp script(results, call, replies), do: Agent.update(results, &Map.put(&1, call, replies)) + + defp sync(pid), do: :sys.get_state(pid) + + # Drops the messages of a setup step. + defp drain do + receive do + {tag, _} when tag in [:disconnect_clients, :home_assistant_pairing] -> drain() + {:update_adapters, _, _} -> drain() + after + 0 -> :ok + end + end + + test "nothing is selected by default", ctx do + {pid, _} = start_pairing!(ctx) + + assert HomeAssistantPairing.selection(pid) == nil + refute HomeAssistantPairing.selected?(pid) + refute_received {:update_adapters, _, _} + refute_received {:disconnect_clients, _} + end + + test "selected? is false while the process is down" do + refute HomeAssistantPairing.selected?(:no_such_ha_pairing) + end + + test "selecting an output persists it with its client id and reconnects HA", ctx do + {pid, _} = start_pairing!(ctx) + + assert :ok = HomeAssistantPairing.select(pid, @key) + + assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} + assert_received {:home_assistant_pairing, @key} + + assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + + assert_received {:disconnect_clients, :espex_test} + end + + test "reselecting the selected output changes nothing", ctx do + {pid, _} = start_pairing!(ctx) + :ok = HomeAssistantPairing.select(pid, @key) + assert_received {:disconnect_clients, _} + drain() + + assert :ok = HomeAssistantPairing.select(pid, @key) + refute_received {:home_assistant_pairing, _} + refute_received {:update_adapters, _, _} + refute_received {:disconnect_clients, _} + end + + test "selecting another output replaces the selection", ctx do + {pid, _} = start_pairing!(ctx) + :ok = HomeAssistantPairing.select(pid, @key) + + assert :ok = HomeAssistantPairing.select(pid, @usb_key) + assert HomeAssistantPairing.selection(pid) == %{key: @usb_key, client_id: nil} + assert_received {:home_assistant_pairing, @usb_key} + end + + test "an output that isn't present can't be selected", ctx do + {pid, _} = start_pairing!(ctx) + + assert {:error, :not_found} = HomeAssistantPairing.select(pid, {"Gone", nil, nil}) + assert HomeAssistantPairing.selection(pid) == nil + refute_received {:update_adapters, _, _} + end + + test "selecting while the audio server is down is :unavailable", ctx do + {pid, _} = start_pairing!(ctx) + :ok = stop_supervised(AudioServerStub) + + assert {:error, :unavailable} = HomeAssistantPairing.select(pid, @key) + end + + test "clearing unwires the adapter and reconnects HA", ctx do + {pid, _} = start_pairing!(ctx) + :ok = HomeAssistantPairing.select(pid, @key) + assert_received {:disconnect_clients, _} + drain() + + assert :ok = HomeAssistantPairing.clear(pid) + + assert HomeAssistantPairing.selection(pid) == nil + assert Store.get_home_assistant_pairing(ctx.store) == nil + assert_received {:home_assistant_pairing, nil} + assert_received {:update_adapters, :espex_test, [sendspin: nil]} + assert_received {:disconnect_clients, :espex_test} + + # Already clear. + assert :ok = HomeAssistantPairing.clear(pid) + refute_received {:update_adapters, _, _} + end + + test "the selection survives a restart", ctx do + :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: "hp-id"}) + {pid, _} = start_pairing!(ctx) + + assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert HomeAssistantPairing.selected?(pid) + # Espex reads it when it starts: nothing to apply. + refute_received {:update_adapters, _, _} + refute_received {:disconnect_clients, _} + end + + test "a selection kept while its output is unplugged keeps its cached client id", ctx do + :ok = + Store.put_home_assistant_pairing(ctx.store, %{key: {"Gone", nil, nil}, client_id: "old"}) + + {pid, _} = start_pairing!(ctx) + + assert HomeAssistantPairing.selection(pid) == %{key: {"Gone", nil, nil}, client_id: "old"} + refute_received {:disconnect_clients, _} + end + + test "a client id that changed while it was down is caught up at start", ctx do + :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: nil}) + {pid, _} = start_pairing!(ctx) + + assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} + assert_received {:disconnect_clients, :espex_test} + refute_received {:update_adapters, _, _} + end + + describe "the selected output's client id" do + setup ctx do + {pid, _} = start_pairing!(ctx) + :ok = HomeAssistantPairing.select(pid, @usb_key) + assert_received {:disconnect_clients, _} + drain() + {:ok, pairing: pid} + end + + test "a new one is persisted and reconnects HA", %{pairing: pid, store: store} do + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:state", + {:sendspin_state, @usb_key, %{sendspin_client_id: "usb-id"}} + ) + + sync(pid) + assert HomeAssistantPairing.selection(pid) == %{key: @usb_key, client_id: "usb-id"} + assert Store.get_home_assistant_pairing(store) == %{key: @usb_key, client_id: "usb-id"} + assert_received {:disconnect_clients, :espex_test} + refute_received {:update_adapters, _, _} + + # The binary's own listening event with the same id changes nothing. + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:state", + {:sendspin_state, @usb_key, %{event: "listening", port: 8928, client_id: "usb-id"}} + ) + + sync(pid) + refute_received {:disconnect_clients, _} + + # A different one (a new keypair) does. + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:state", + {:sendspin_state, @usb_key, %{event: "listening", port: 8928, client_id: "new-id"}} + ) + + sync(pid) + assert HomeAssistantPairing.selection(pid).client_id == "new-id" + assert_received {:disconnect_clients, :espex_test} + end + + test "another output's is ignored", %{pairing: pid} do + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:state", + {:sendspin_state, @key, %{sendspin_client_id: "other"}} + ) + + sync(pid) + assert HomeAssistantPairing.selection(pid).client_id == nil + refute_received {:disconnect_clients, _} + end + end + + describe "an Espex that is down" do + test "the update and the reconnect are retried until they work", ctx do + {pid, results} = start_pairing!(ctx) + script(results, :update_adapters, [:exit, {:error, :busy}]) + script(results, :disconnect_clients, [:raise]) + + assert :ok = HomeAssistantPairing.select(pid, @key) + + # exit, {:error, _}, then :ok; the reconnect raises once, then works. + for _ <- 1..3, do: assert_receive({:update_adapters, :espex_test, [sendspin: _]}, 500) + for _ <- 1..2, do: assert_receive({:disconnect_clients, :espex_test}, 500) + + refute_receive {:update_adapters, _, _}, 100 + refute_received {:disconnect_clients, _} + assert %{pending: nil} = sync(pid) + end + + test "a change while a retry is pending applies the latest selection, once", ctx do + {pid, results} = start_pairing!(ctx) + script(results, :update_adapters, [:exit]) + + :ok = HomeAssistantPairing.select(pid, @key) + :ok = HomeAssistantPairing.clear(pid) + + assert_receive {:update_adapters, _, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_receive {:update_adapters, _, [sendspin: nil]}, 500 + assert_receive {:disconnect_clients, _}, 500 + refute_receive {:update_adapters, _, _}, 100 + end + end +end diff --git a/test/universal_proxy/audio/store_test.exs b/test/universal_proxy/audio/store_test.exs index c640b1ae..4fe20a74 100644 --- a/test/universal_proxy/audio/store_test.exs +++ b/test/universal_proxy/audio/store_test.exs @@ -275,4 +275,39 @@ defmodule UniversalProxy.Audio.StoreTest do assert {:ok, %{display_mode: "Off"}} = Store.get_config(reopened, hdmi) end end + + describe "Home Assistant pairing selection" do + test "is nil until set, and nil clears it", %{server: server} do + assert Store.get_home_assistant_pairing(server) == nil + + :ok = Store.put_home_assistant_pairing(server, %{key: @key, client_id: nil}) + assert Store.get_home_assistant_pairing(server) == %{key: @key, client_id: nil} + + :ok = Store.put_home_assistant_pairing(server, %{key: @usb_key, client_id: "abc"}) + assert Store.get_home_assistant_pairing(server) == %{key: @usb_key, client_id: "abc"} + + :ok = Store.put_home_assistant_pairing(server, nil) + assert Store.get_home_assistant_pairing(server) == nil + end + + test "is not an output row", %{server: server} do + :ok = Store.save_config(server, @key, %{}) + :ok = Store.put_home_assistant_pairing(server, %{key: @key, client_id: "abc"}) + + assert server |> Store.get_all() |> Map.keys() == [@key] + end + + test "survives a restart", %{dets_path: path} do + :ok = stop_supervised(UniversalProxy.Audio.Store) + + pid = start_supervised!({Store, name: nil, table: @table, dets_path: path}, id: :first) + :ok = Store.put_home_assistant_pairing(pid, %{key: @usb_key, client_id: "abc"}) + :ok = stop_supervised(:first) + + reopened = + start_supervised!({Store, name: nil, table: @table, dets_path: path}, id: :second) + + assert Store.get_home_assistant_pairing(reopened) == %{key: @usb_key, client_id: "abc"} + end + end end diff --git a/test/universal_proxy/esphome/sendspin_test.exs b/test/universal_proxy/esphome/sendspin_test.exs new file mode 100644 index 00000000..ba521e47 --- /dev/null +++ b/test/universal_proxy/esphome/sendspin_test.exs @@ -0,0 +1,66 @@ +defmodule UniversalProxy.ESPHome.SendspinTest do + # async: false — the stand-in reads its scripted answers from app env. + use ExUnit.Case, async: false + + alias UniversalProxy.ESPHome.Sendspin + + @key {"bcm2835 Headphones", nil, nil} + @token "SP:" <> String.duplicate("A", 104) + + defmodule AudioStub do + @moduledoc false + def get_output(_key) do + case Application.get_env(:universal_proxy, :sendspin_test_output) do + :exit -> exit(:noproc) + result -> result + end + end + + def pairing_token(_key), do: Application.get_env(:universal_proxy, :sendspin_test_token) + end + + setup do + on_exit(fn -> + Application.delete_env(:universal_proxy, :sendspin_test_output) + Application.delete_env(:universal_proxy, :sendspin_test_token) + end) + end + + defp token_for(output, token) do + Application.put_env(:universal_proxy, :sendspin_test_output, output) + Application.put_env(:universal_proxy, :sendspin_test_token, token) + Sendspin.pairing_token(@key, AudioStub) + end + + describe "pairing_token/2" do + test "the token of an enabled output whose player wrote one" do + assert {:ok, @token} = token_for({:ok, %{enabled: true}}, {:ok, @token}) + end + + test ":not_ready before the player has written a token, or when it is unreadable" do + assert :not_ready = token_for({:ok, %{enabled: true}}, {:error, :not_ready}) + assert :not_ready = token_for({:ok, %{enabled: true}}, {:error, :unavailable}) + end + + test ":not_ready while the output is unplugged or the audio subsystem is down" do + assert :not_ready = token_for(:error, {:ok, @token}) + assert :not_ready = token_for(:exit, {:ok, @token}) + end + + test ":disabled while the output is turned off, whatever its token" do + assert :disabled = token_for({:ok, %{enabled: false}}, {:ok, @token}) + end + + test ":failed while the player can't persist its identity" do + assert :failed = token_for({:ok, %{enabled: true, identity_error: true}}, {:ok, @token}) + end + end + + describe "client_id/1" do + test "the selection's cached client id, nil without one" do + assert Sendspin.client_id(%{key: @key, client_id: "abc"}) == "abc" + assert Sendspin.client_id(%{key: @key, client_id: nil}) == nil + assert Sendspin.client_id(nil) == nil + end + end +end diff --git a/test/universal_proxy/esphome/supervisor_test.exs b/test/universal_proxy/esphome/supervisor_test.exs index d1a3eae0..be94373c 100644 --- a/test/universal_proxy/esphome/supervisor_test.exs +++ b/test/universal_proxy/esphome/supervisor_test.exs @@ -152,6 +152,22 @@ defmodule UniversalProxy.ESPHome.SupervisorTest do end end + describe "sendspin_opts/1" do + test "wires the Sendspin adapter only while an output is selected for HA pairing" do + assert ESPHomeSupervisor.sendspin_opts(true) == [sendspin: UniversalProxy.ESPHome.Sendspin] + assert ESPHomeSupervisor.sendspin_opts(false) == [] + end + + test "is not part of the init-time options (each Espex start reads it)" do + {:ok, {_flags, children}} = ESPHomeSupervisor.init([]) + + %{start: {ESPHomeSupervisor, :start_espex, [opts, _fun]}} = + Enum.find(children, &(&1.id == Espex)) + + refute Keyword.has_key?(opts, :sendspin) + end + end + describe "pick_zwave_port/3" do alias UniversalProxy.Hardware From 1eef961f7aa614690f2d4b7192c903de0a3a3562 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:33:58 +0000 Subject: [PATCH 3/7] Answer NOT_READY when the pairing selection can't be read A down HomeAssistantPairing used to read as no selection, so the token request answered DISABLED. DISABLED is now reserved for a successful read of an empty selection. Co-Authored-By: Claude Opus 5.5 --- lib/universal_proxy/esphome/sendspin.ex | 16 +++++++++++----- test/universal_proxy/esphome/sendspin_test.exs | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/universal_proxy/esphome/sendspin.ex b/lib/universal_proxy/esphome/sendspin.ex index eb20b656..d2605bcc 100644 --- a/lib/universal_proxy/esphome/sendspin.ex +++ b/lib/universal_proxy/esphome/sendspin.ex @@ -28,13 +28,19 @@ defmodule UniversalProxy.ESPHome.Sendspin do alias UniversalProxy.Audio.HomeAssistantPairing @impl true - def client_id, do: client_id(selection()) + def client_id do + case selection() do + {:ok, selection} -> client_id(selection) + :error -> nil + end + end @impl true def pairing_token do case selection() do - %{key: key} -> pairing_token(key, Audio) - nil -> :disabled + {:ok, %{key: key}} -> pairing_token(key, Audio) + {:ok, nil} -> :disabled + :error -> :not_ready end end @@ -72,8 +78,8 @@ defmodule UniversalProxy.ESPHome.Sendspin do end defp selection do - HomeAssistantPairing.selection() + {:ok, HomeAssistantPairing.selection()} catch - :exit, _reason -> nil + :exit, _reason -> :error end end diff --git a/test/universal_proxy/esphome/sendspin_test.exs b/test/universal_proxy/esphome/sendspin_test.exs index ba521e47..950d0097 100644 --- a/test/universal_proxy/esphome/sendspin_test.exs +++ b/test/universal_proxy/esphome/sendspin_test.exs @@ -56,6 +56,20 @@ defmodule UniversalProxy.ESPHome.SendspinTest do end end + describe "the callbacks with the selection process down" do + setup do + sup = UniversalProxy.Audio.Supervisor + pairing = UniversalProxy.Audio.HomeAssistantPairing + :ok = Supervisor.terminate_child(sup, pairing) + on_exit(fn -> Supervisor.restart_child(sup, pairing) end) + end + + test "answer :not_ready and no client id, not :disabled" do + assert Sendspin.pairing_token() == :not_ready + assert Sendspin.client_id() == nil + end + end + describe "client_id/1" do test "the selection's cached client id, nil without one" do assert Sendspin.client_id(%{key: @key, client_id: "abc"}) == "abc" From 38999c289cb662efe7538ff1f9c5d86157d11993 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:33:58 +0000 Subject: [PATCH 4/7] Re-apply the HA pairing selection on restart and Espex.Server replacement HomeAssistantPairing now loads and reconciles in handle_continue, applying the persisted selection and reconnecting HA on every start. It monitors Espex.Server, which restarts with its start-time adapters, and re-applies to each replacement. Co-Authored-By: Claude Opus 5.5 --- .../audio/home_assistant_pairing.ex | 54 +++++++- .../audio/home_assistant_pairing_test.exs | 120 ++++++++++++++++-- 2 files changed, 157 insertions(+), 17 deletions(-) diff --git a/lib/universal_proxy/audio/home_assistant_pairing.ex b/lib/universal_proxy/audio/home_assistant_pairing.ex index 8b56a4a7..4aec20e6 100644 --- a/lib/universal_proxy/audio/home_assistant_pairing.ex +++ b/lib/universal_proxy/audio/home_assistant_pairing.ex @@ -18,6 +18,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do Assistant reconnects and re-reads the device capabilities. Both steps are retried with backoff (Espex down or mid-restart) until they succeed. + The persisted selection is the source of truth: whenever this process + starts it applies it again (an earlier run may have died before it had), + and it monitors `Espex.Server`, which restarts with the adapters Espex was + started with, to apply it again to each replacement. + HA reads the `client_id` only when it connects, so a new one for the selected output (its player listening for the first time, or with a new keypair) also disconnects the clients. The cached `client_id` survives @@ -58,6 +63,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do update_adapters: nil, disconnect_clients: nil, pending: nil, + espex_mon: nil, timer: nil, retry_ms: @default_retry_ms, backoff_ms: @default_retry_ms @@ -134,16 +140,26 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do } Phoenix.PubSub.subscribe(t.pubsub, @state_topic) + {:ok, t, {:continue, :reconcile}} + end + + # Runs before any call is served, so the persisted selection is already + # loaded when `selected?/1` is first answered. + @impl true + def handle_continue(:reconcile, t) do t = %{t | selection: load(t)} # Catch up with a `client_id` that arrived while this process was down. t = case t.selection && live_client_id(t, t.selection.key) do - id when is_binary(id) -> observe_client_id(t, id) - _none -> t + id when is_binary(id) and id != t.selection.client_id -> learn_client_id(t, id) + _same_or_none -> t end - {:ok, t} + # A previous run may have died with an update or reconnect still pending, + # and Espex keeps whatever it was last given: re-apply the persisted + # selection, then reconnect. + {:noreply, apply_pending(%{t | pending: :update})} end @impl true @@ -176,6 +192,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do def handle_info({:retry, token}, %{timer: token} = t), do: {:noreply, apply_pending(%{t | timer: nil})} + # `Espex.Server` restarts with the adapters Espex was started with, not + # the ones given since: give it the current selection again. + def handle_info({:DOWN, ref, :process, _pid, _reason}, %{espex_mon: {_pid2, ref}} = t), + do: {:noreply, apply_pending(%{t | espex_mon: nil, pending: :update})} + def handle_info(_msg, t), do: {:noreply, t} # -- Private -- @@ -205,6 +226,12 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do defp observe_client_id(%{selection: %{client_id: id}} = t, id), do: t defp observe_client_id(t, id) do + t = learn_client_id(t, id) + # An :update already pending reconnects once it has been applied. + apply_pending(%{t | pending: t.pending || :reconnect}) + end + + defp learn_client_id(t, id) do selection = %{t.selection | client_id: id} case store_put(t, selection) do @@ -217,15 +244,16 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do ) end - # An :update already pending reconnects once it has been applied. - pending = t.pending || :reconnect - apply_pending(%{t | selection: selection, pending: pending}) + %{t | selection: selection} end defp apply_pending(%{pending: nil} = t), do: t + # The server is monitored before it is updated, so one that is replaced + # afterwards (restarting with its start-time adapters) is updated again. defp apply_pending(%{pending: :update} = t) do adapter = if t.selection, do: t.adapter + t = monitor_espex(t) case espex(fn -> t.update_adapters.(t.espex_server, sendspin: adapter) end) do :ok -> apply_pending(%{t | pending: :reconnect}) @@ -252,6 +280,20 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do %{t | timer: token, backoff_ms: min(t.backoff_ms * 2, @max_retry_ms)} end + defp monitor_espex(t) do + case {GenServer.whereis(t.espex_server), t.espex_mon} do + {pid, {pid, _ref}} -> + t + + {pid, previous} when is_pid(pid) -> + with {_old, ref} <- previous, do: Process.demonitor(ref, [:flush]) + %{t | espex_mon: {pid, Process.monitor(pid)}} + + _not_running -> + t + end + end + # Espex down or restarting: a call exits, its registry raises. defp espex(fun) do fun.() diff --git a/test/universal_proxy/audio/home_assistant_pairing_test.exs b/test/universal_proxy/audio/home_assistant_pairing_test.exs index d7a84903..1bf9d6bf 100644 --- a/test/universal_proxy/audio/home_assistant_pairing_test.exs +++ b/test/universal_proxy/audio/home_assistant_pairing_test.exs @@ -81,10 +81,45 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do end ] + {drain?, opts} = Keyword.pop(opts, :drain, true) pid = start_supervised!({HomeAssistantPairing, Keyword.merge(defaults, opts)}) + + # Its startup reconcile has run; its calls are dropped unless the test + # is about them. + sync(pid) + if drain?, do: drain() + {pid, results} end + defp restart_pairing!(ctx, opts) do + :ok = stop_supervised(HomeAssistantPairing) + :ok = stop_supervised(:results) + start_pairing!(ctx, opts) + end + + # Stands in for a registered `Espex.Server` the process can monitor. + defp register_espex!(name) do + pid = spawn(fn -> receive(do: (:stop -> :ok)) end) + Process.register(pid, name) + on_exit(fn -> Process.exit(pid, :kill) end) + pid + end + + # Like the real call: fails while no server is registered. + defp registered_update(test) do + fn server, changes -> + send(test, {:update_adapters, server, changes}) + if Process.whereis(server), do: :ok, else: exit(:noproc) + end + end + + defp kill_and_wait(pid) do + ref = Process.monitor(pid) + Process.exit(pid, :kill) + assert_receive {:DOWN, ^ref, :process, ^pid, _} + end + defp result(:exit), do: exit(:noproc) defp result(:raise), do: raise(ArgumentError, "unknown registry") defp result(other), do: other @@ -103,13 +138,13 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do end end - test "nothing is selected by default", ctx do - {pid, _} = start_pairing!(ctx) + test "nothing is selected by default, and the start applies that", ctx do + {pid, _} = start_pairing!(ctx, drain: false) assert HomeAssistantPairing.selection(pid) == nil refute HomeAssistantPairing.selected?(pid) - refute_received {:update_adapters, _, _} - refute_received {:disconnect_clients, _} + assert_received {:update_adapters, :espex_test, [sendspin: nil]} + assert_received {:disconnect_clients, :espex_test} end test "selected? is false while the process is down" do @@ -185,15 +220,32 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do refute_received {:update_adapters, _, _} end - test "the selection survives a restart", ctx do - :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: "hp-id"}) + test "the selection survives a restart and is applied again, then HA reconnected", ctx do {pid, _} = start_pairing!(ctx) + :ok = HomeAssistantPairing.select(pid, @key) + drain() + + {pid, _} = restart_pairing!(ctx, drain: false) assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} assert HomeAssistantPairing.selected?(pid) - # Espex reads it when it starts: nothing to apply. - refute_received {:update_adapters, _, _} + assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:disconnect_clients, :espex_test} + end + + test "work a previous run left pending is done by the next one", ctx do + {pid, results} = start_pairing!(ctx, retry_ms: 60_000) + script(results, :update_adapters, [:exit]) + + # The update fails and its retry is far off: the process dies with it pending. + :ok = HomeAssistantPairing.select(pid, @key) + assert_received {:update_adapters, _, [sendspin: UniversalProxy.ESPHome.Sendspin]} refute_received {:disconnect_clients, _} + assert %{pending: :update} = sync(pid) + + restart_pairing!(ctx, drain: false) + assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:disconnect_clients, :espex_test} end test "a selection kept while its output is unplugged keeps its cached client id", ctx do @@ -203,17 +255,63 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do {pid, _} = start_pairing!(ctx) assert HomeAssistantPairing.selection(pid) == %{key: {"Gone", nil, nil}, client_id: "old"} - refute_received {:disconnect_clients, _} + assert Store.get_home_assistant_pairing(ctx.store).client_id == "old" end test "a client id that changed while it was down is caught up at start", ctx do :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: nil}) - {pid, _} = start_pairing!(ctx) + {pid, _} = start_pairing!(ctx, drain: false) assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} + assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} - refute_received {:update_adapters, _, _} + end + + describe "an Espex.Server that is replaced" do + @espex :ha_pairing_test_espex + + test "gets the current selection again, and HA is reconnected", ctx do + first = register_espex!(@espex) + + {pid, _} = + start_pairing!(ctx, espex_server: @espex, update_adapters: registered_update(self())) + + :ok = HomeAssistantPairing.select(pid, @key) + drain() + + # Restarted with its start-time adapters: re-applied once it is back. + kill_and_wait(first) + assert_receive {:update_adapters, @espex, _}, 500 + second = register_espex!(@espex) + + assert_receive {:update_adapters, @espex, [sendspin: UniversalProxy.ESPHome.Sendspin]}, 500 + assert_receive {:disconnect_clients, @espex}, 500 + drain() + + # The replacement is monitored too. + assert %{espex_mon: {^second, _ref}, pending: nil} = sync(pid) + kill_and_wait(second) + register_espex!(@espex) + assert_receive {:update_adapters, @espex, [sendspin: UniversalProxy.ESPHome.Sendspin]}, 500 + assert_receive {:disconnect_clients, @espex}, 500 + end + + test "after a clear, gets no adapter again", ctx do + first = register_espex!(@espex) + + {pid, _} = + start_pairing!(ctx, espex_server: @espex, update_adapters: registered_update(self())) + + :ok = HomeAssistantPairing.select(pid, @key) + :ok = HomeAssistantPairing.clear(pid) + drain() + + kill_and_wait(first) + register_espex!(@espex) + assert_receive {:update_adapters, @espex, [sendspin: nil]}, 500 + assert_receive {:disconnect_clients, @espex}, 500 + end end describe "the selected output's client id" do From 229c00e64d90233f9900e2ff68e79333c6b726b9 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:57:26 +0000 Subject: [PATCH 5/7] Update Espex.Server by pid and retry a failed selection load HA pairing now monitors and updates the same resolved Espex.Server pid, retrying while none is registered, so a replacement can't escape the monitor. A failed Store load is retried instead of read as no selection: nothing is applied and selection/select/clear answer :unavailable until it works. Co-Authored-By: Claude Opus 5.5 --- lib/universal_proxy/audio.ex | 4 +- .../audio/home_assistant_pairing.ex | 132 +++++++++++------- lib/universal_proxy/esphome/sendspin.ex | 6 +- .../audio/home_assistant_pairing_test.exs | 102 ++++++++++---- 4 files changed, 165 insertions(+), 79 deletions(-) diff --git a/lib/universal_proxy/audio.ex b/lib/universal_proxy/audio.ex index 4a4ffdfb..e7cb5557 100644 --- a/lib/universal_proxy/audio.ex +++ b/lib/universal_proxy/audio.ex @@ -204,8 +204,8 @@ defmodule UniversalProxy.Audio do @spec home_assistant_pairing() :: key() | nil def home_assistant_pairing do case HomeAssistantPairing.selection() do - %{key: key} -> key - nil -> nil + {:ok, %{key: key}} -> key + _none_or_unavailable -> nil end catch :exit, _ -> nil diff --git a/lib/universal_proxy/audio/home_assistant_pairing.ex b/lib/universal_proxy/audio/home_assistant_pairing.ex index 4aec20e6..30103226 100644 --- a/lib/universal_proxy/audio/home_assistant_pairing.ex +++ b/lib/universal_proxy/audio/home_assistant_pairing.ex @@ -21,7 +21,10 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do The persisted selection is the source of truth: whenever this process starts it applies it again (an earlier run may have died before it had), and it monitors `Espex.Server`, which restarts with the adapters Espex was - started with, to apply it again to each replacement. + started with, to apply it again to each replacement. A selection that + can't be loaded (the Store down) is not an empty one: the load is retried, + nothing is applied, and `selection/1`, `select/2` and `clear/1` answer + `{:error, :unavailable}` until it works. HA reads the `client_id` only when it connects, so a new one for the selected output (its player listening for the first time, or with a new @@ -55,6 +58,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do @type selection :: Store.home_assistant_pairing() defstruct selection: nil, + loaded?: false, store: Store, audio_server: Server, pubsub: @pubsub, @@ -92,17 +96,20 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do @spec topic() :: String.t() def topic, do: @topic - @doc "The current selection, or `nil` when no output is selected." - @spec selection(GenServer.server()) :: selection() | nil + @doc """ + The current selection (`nil` when no output is selected), or + `{:error, :unavailable}` until the persisted one has been loaded. + """ + @spec selection(GenServer.server()) :: {:ok, selection() | nil} | {:error, :unavailable} def selection(server \\ __MODULE__), do: GenServer.call(server, :selection) @doc """ Whether an output is selected; `false` while this process is down (the - `catch :exit` idiom). + `catch :exit` idiom) or has not loaded the selection yet. """ @spec selected?(GenServer.server()) :: boolean() def selected?(server \\ __MODULE__) do - selection(server) != nil + match?({:ok, %{}}, selection(server)) catch :exit, _ -> false end @@ -110,8 +117,9 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do @doc """ Select the output `key`, replacing any other selection. `{:error, :not_found}` when the output isn't present, - `{:error, :unavailable}` while the audio subsystem is down, or - `{:error, reason}` if DETS rejects the write. + `{:error, :unavailable}` while the audio subsystem is down or the + persisted selection has not been loaded yet, or `{:error, reason}` if + DETS rejects the write. """ @spec select(GenServer.server(), key()) :: :ok | {:error, :not_found | :unavailable | term()} @@ -119,7 +127,10 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do GenServer.call(server, {:select, key}) end - @doc "Clear the selection. `{:error, reason}` if DETS rejects the write." + @doc """ + Clear the selection. `{:error, :unavailable}` until the persisted + selection has been loaded, `{:error, reason}` if DETS rejects the write. + """ @spec clear(GenServer.server()) :: :ok | {:error, term()} def clear(server \\ __MODULE__), do: GenServer.call(server, :clear) @@ -143,27 +154,24 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do {:ok, t, {:continue, :reconcile}} end - # Runs before any call is served, so the persisted selection is already - # loaded when `selected?/1` is first answered. + # Runs before any call is served, so the persisted selection is normally + # loaded when `selected?/1` is first answered. A load that fails is + # retried (`pending: :load`); nothing is applied to Espex until it works, + # since an unread selection is not an empty one. @impl true - def handle_continue(:reconcile, t) do - t = %{t | selection: load(t)} - - # Catch up with a `client_id` that arrived while this process was down. - t = - case t.selection && live_client_id(t, t.selection.key) do - id when is_binary(id) and id != t.selection.client_id -> learn_client_id(t, id) - _same_or_none -> t - end - - # A previous run may have died with an update or reconnect still pending, - # and Espex keeps whatever it was last given: re-apply the persisted - # selection, then reconnect. - {:noreply, apply_pending(%{t | pending: :update})} - end + def handle_continue(:reconcile, t), do: {:noreply, apply_pending(%{t | pending: :load})} @impl true - def handle_call(:selection, _from, t), do: {:reply, t.selection, t} + def handle_call(:selection, _from, %{loaded?: false} = t), + do: {:reply, {:error, :unavailable}, t} + + def handle_call(:selection, _from, t), do: {:reply, {:ok, t.selection}, t} + + def handle_call({:select, _key}, _from, %{loaded?: false} = t), + do: {:reply, {:error, :unavailable}, t} + + def handle_call(:clear, _from, %{loaded?: false} = t), + do: {:reply, {:error, :unavailable}, t} def handle_call({:select, key}, _from, %{selection: %{key: key}} = t), do: {:reply, :ok, t} @@ -194,8 +202,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do # `Espex.Server` restarts with the adapters Espex was started with, not # the ones given since: give it the current selection again. - def handle_info({:DOWN, ref, :process, _pid, _reason}, %{espex_mon: {_pid2, ref}} = t), - do: {:noreply, apply_pending(%{t | espex_mon: nil, pending: :update})} + # A load still pending applies the selection once it works. + def handle_info({:DOWN, ref, :process, _pid, _reason}, %{espex_mon: {_pid2, ref}} = t) do + pending = if t.pending == :load, do: :load, else: :update + {:noreply, apply_pending(%{t | espex_mon: nil, pending: pending})} + end def handle_info(_msg, t), do: {:noreply, t} @@ -249,15 +260,47 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do defp apply_pending(%{pending: nil} = t), do: t - # The server is monitored before it is updated, so one that is replaced - # afterwards (restarting with its start-time adapters) is updated again. + defp apply_pending(%{pending: :load} = t) do + case load(t) do + {:ok, selection} -> + t = %{t | selection: selection, loaded?: true} + # Pages mounted before the load read no selection. + Phoenix.PubSub.broadcast(t.pubsub, @topic, {:home_assistant_pairing, key_of(selection)}) + + # Catch up with a `client_id` that arrived while this process was down. + t = + case selection && live_client_id(t, selection.key) do + id when is_binary(id) and id != selection.client_id -> learn_client_id(t, id) + _same_or_none -> t + end + + # A previous run may have died with an update or reconnect still + # pending, and Espex keeps whatever it was last given: re-apply the + # persisted selection, then reconnect. + apply_pending(%{t | pending: :update}) + + {:error, _reason} = error -> + retry(t, error) + end + end + + # The update goes to the very server that is monitored (resolved once, not + # by name), so a replacement (restarting with its start-time adapters) is + # always updated again. No server registered: retried. defp apply_pending(%{pending: :update} = t) do adapter = if t.selection, do: t.adapter - t = monitor_espex(t) - case espex(fn -> t.update_adapters.(t.espex_server, sendspin: adapter) end) do - :ok -> apply_pending(%{t | pending: :reconnect}) - error -> retry(t, error) + case GenServer.whereis(t.espex_server) do + pid when is_pid(pid) -> + t = monitor_espex(t, pid) + + case espex(fn -> t.update_adapters.(pid, sendspin: adapter) end) do + :ok -> apply_pending(%{t | pending: :reconnect}) + error -> retry(t, error) + end + + _not_running -> + retry(t, {:error, :not_running}) end end @@ -272,7 +315,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do defp retry(t, error) do Logger.debug( - "Home Assistant Sendspin pairing: Espex #{t.pending} failed (#{inspect(error)}); retrying" + "Home Assistant Sendspin pairing: #{t.pending} failed (#{inspect(error)}); retrying" ) token = make_ref() @@ -280,18 +323,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do %{t | timer: token, backoff_ms: min(t.backoff_ms * 2, @max_retry_ms)} end - defp monitor_espex(t) do - case {GenServer.whereis(t.espex_server), t.espex_mon} do - {pid, {pid, _ref}} -> - t - - {pid, previous} when is_pid(pid) -> - with {_old, ref} <- previous, do: Process.demonitor(ref, [:flush]) - %{t | espex_mon: {pid, Process.monitor(pid)}} + defp monitor_espex(%{espex_mon: {pid, _ref}} = t, pid), do: t - _not_running -> - t - end + defp monitor_espex(t, pid) do + with {_old, ref} <- t.espex_mon, do: Process.demonitor(ref, [:flush]) + %{t | espex_mon: {pid, Process.monitor(pid)}} end # Espex down or restarting: a call exits, its registry raises. @@ -304,9 +340,9 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do end defp load(t) do - Store.get_home_assistant_pairing(t.store) + {:ok, Store.get_home_assistant_pairing(t.store)} catch - :exit, _reason -> nil + :exit, _reason -> {:error, :unavailable} end defp store_put(t, selection) do diff --git a/lib/universal_proxy/esphome/sendspin.ex b/lib/universal_proxy/esphome/sendspin.ex index d2605bcc..763d5497 100644 --- a/lib/universal_proxy/esphome/sendspin.ex +++ b/lib/universal_proxy/esphome/sendspin.ex @@ -77,8 +77,12 @@ defmodule UniversalProxy.ESPHome.Sendspin do :exit, _reason -> :error end + # Down, or the selection not loaded yet: unknown, not empty. defp selection do - {:ok, HomeAssistantPairing.selection()} + case HomeAssistantPairing.selection() do + {:ok, selection} -> {:ok, selection} + {:error, _reason} -> :error + end catch :exit, _reason -> :error end diff --git a/test/universal_proxy/audio/home_assistant_pairing_test.exs b/test/universal_proxy/audio/home_assistant_pairing_test.exs index 1bf9d6bf..347ef213 100644 --- a/test/universal_proxy/audio/home_assistant_pairing_test.exs +++ b/test/universal_proxy/audio/home_assistant_pairing_test.exs @@ -46,7 +46,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do ) Phoenix.PubSub.subscribe(@pubsub, HomeAssistantPairing.topic()) - {:ok, store: store, audio: audio} + {:ok, store: store, audio: audio, path: path, espex: register_espex!(:espex_test)} end # Records the Espex calls; `results` (an Agent) scripts their replies. @@ -106,11 +106,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do pid end - # Like the real call: fails while no server is registered. + # Like the real call: fails on a server that is gone. defp registered_update(test) do fn server, changes -> send(test, {:update_adapters, server, changes}) - if Process.whereis(server), do: :ok, else: exit(:noproc) + if is_pid(server) and Process.alive?(server), do: :ok, else: exit(:noproc) end end @@ -141,12 +141,46 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do test "nothing is selected by default, and the start applies that", ctx do {pid, _} = start_pairing!(ctx, drain: false) - assert HomeAssistantPairing.selection(pid) == nil + assert HomeAssistantPairing.selection(pid) == {:ok, nil} refute HomeAssistantPairing.selected?(pid) - assert_received {:update_adapters, :espex_test, [sendspin: nil]} + # The update goes to the server's pid, the reconnect through its name. + espex = ctx.espex + assert_received {:update_adapters, ^espex, [sendspin: nil]} assert_received {:disconnect_clients, :espex_test} end + describe "a selection that can't be loaded" do + test "is not taken as none: nothing is applied until the load works", ctx do + :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: "hp-id"}) + :ok = stop_supervised(Store) + + # A Store that isn't running yet, under the name the process uses. + {pid, _} = start_pairing!(ctx, store: :ha_pairing_late_store, drain: false) + + assert HomeAssistantPairing.selection(pid) == {:error, :unavailable} + refute HomeAssistantPairing.selected?(pid) + assert {:error, :unavailable} = HomeAssistantPairing.select(pid, @usb_key) + assert {:error, :unavailable} = HomeAssistantPairing.clear(pid) + refute_receive {:update_adapters, _, _}, 50 + refute_received {:disconnect_clients, _} + + start_supervised!( + {Store, name: :ha_pairing_late_store, table: @table, dets_path: ctx.path}, + id: :late_store + ) + + espex = ctx.espex + assert_receive {:home_assistant_pairing, @key}, 2_000 + + assert_receive {:update_adapters, ^espex, [sendspin: UniversalProxy.ESPHome.Sendspin]}, + 1_000 + + assert_receive {:disconnect_clients, :espex_test}, 2_000 + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} + assert :ok = HomeAssistantPairing.clear(pid) + end + end + test "selected? is false while the process is down" do refute HomeAssistantPairing.selected?(:no_such_ha_pairing) end @@ -156,11 +190,11 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert :ok = HomeAssistantPairing.select(pid, @key) - assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} assert_received {:home_assistant_pairing, @key} - assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} end @@ -182,7 +216,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do :ok = HomeAssistantPairing.select(pid, @key) assert :ok = HomeAssistantPairing.select(pid, @usb_key) - assert HomeAssistantPairing.selection(pid) == %{key: @usb_key, client_id: nil} + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @usb_key, client_id: nil}} assert_received {:home_assistant_pairing, @usb_key} end @@ -190,7 +224,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do {pid, _} = start_pairing!(ctx) assert {:error, :not_found} = HomeAssistantPairing.select(pid, {"Gone", nil, nil}) - assert HomeAssistantPairing.selection(pid) == nil + assert HomeAssistantPairing.selection(pid) == {:ok, nil} refute_received {:update_adapters, _, _} end @@ -209,10 +243,10 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert :ok = HomeAssistantPairing.clear(pid) - assert HomeAssistantPairing.selection(pid) == nil + assert HomeAssistantPairing.selection(pid) == {:ok, nil} assert Store.get_home_assistant_pairing(ctx.store) == nil assert_received {:home_assistant_pairing, nil} - assert_received {:update_adapters, :espex_test, [sendspin: nil]} + assert_received {:update_adapters, _espex, [sendspin: nil]} assert_received {:disconnect_clients, :espex_test} # Already clear. @@ -227,9 +261,9 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do {pid, _} = restart_pairing!(ctx, drain: false) - assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} assert HomeAssistantPairing.selected?(pid) - assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} end @@ -244,7 +278,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert %{pending: :update} = sync(pid) restart_pairing!(ctx, drain: false) - assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} end @@ -254,7 +288,9 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do {pid, _} = start_pairing!(ctx) - assert HomeAssistantPairing.selection(pid) == %{key: {"Gone", nil, nil}, client_id: "old"} + assert HomeAssistantPairing.selection(pid) == + {:ok, %{key: {"Gone", nil, nil}, client_id: "old"}} + assert Store.get_home_assistant_pairing(ctx.store).client_id == "old" end @@ -262,9 +298,9 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: nil}) {pid, _} = start_pairing!(ctx, drain: false) - assert HomeAssistantPairing.selection(pid) == %{key: @key, client_id: "hp-id"} + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} - assert_received {:update_adapters, :espex_test, [sendspin: UniversalProxy.ESPHome.Sendspin]} + assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} end @@ -278,23 +314,33 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do start_pairing!(ctx, espex_server: @espex, update_adapters: registered_update(self())) :ok = HomeAssistantPairing.select(pid, @key) + assert %{espex_mon: {^first, _ref}} = sync(pid) drain() # Restarted with its start-time adapters: re-applied once it is back. + # Nothing is sent while no server is registered. kill_and_wait(first) - assert_receive {:update_adapters, @espex, _}, 500 + refute_receive {:update_adapters, _, _}, 50 + + # The one registered meanwhile is the one updated, and monitored... second = register_espex!(@espex) - assert_receive {:update_adapters, @espex, [sendspin: UniversalProxy.ESPHome.Sendspin]}, 500 + assert_receive {:update_adapters, ^second, [sendspin: UniversalProxy.ESPHome.Sendspin]}, + 1_000 + assert_receive {:disconnect_clients, @espex}, 500 drain() - - # The replacement is monitored too. assert %{espex_mon: {^second, _ref}, pending: nil} = sync(pid) + + # ...so its own restart is re-applied too. kill_and_wait(second) - register_espex!(@espex) - assert_receive {:update_adapters, @espex, [sendspin: UniversalProxy.ESPHome.Sendspin]}, 500 + third = register_espex!(@espex) + + assert_receive {:update_adapters, ^third, [sendspin: UniversalProxy.ESPHome.Sendspin]}, + 1_000 + assert_receive {:disconnect_clients, @espex}, 500 + assert %{espex_mon: {^third, _ref}} = sync(pid) end test "after a clear, gets no adapter again", ctx do @@ -309,7 +355,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do kill_and_wait(first) register_espex!(@espex) - assert_receive {:update_adapters, @espex, [sendspin: nil]}, 500 + assert_receive {:update_adapters, _server, [sendspin: nil]}, 2_000 assert_receive {:disconnect_clients, @espex}, 500 end end @@ -331,7 +377,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do ) sync(pid) - assert HomeAssistantPairing.selection(pid) == %{key: @usb_key, client_id: "usb-id"} + assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @usb_key, client_id: "usb-id"}} assert Store.get_home_assistant_pairing(store) == %{key: @usb_key, client_id: "usb-id"} assert_received {:disconnect_clients, :espex_test} refute_received {:update_adapters, _, _} @@ -354,7 +400,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do ) sync(pid) - assert HomeAssistantPairing.selection(pid).client_id == "new-id" + assert {:ok, %{client_id: "new-id"}} = HomeAssistantPairing.selection(pid) assert_received {:disconnect_clients, :espex_test} end @@ -366,7 +412,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do ) sync(pid) - assert HomeAssistantPairing.selection(pid).client_id == nil + assert {:ok, %{client_id: nil}} = HomeAssistantPairing.selection(pid) refute_received {:disconnect_clients, _} end end @@ -380,7 +426,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert :ok = HomeAssistantPairing.select(pid, @key) # exit, {:error, _}, then :ok; the reconnect raises once, then works. - for _ <- 1..3, do: assert_receive({:update_adapters, :espex_test, [sendspin: _]}, 500) + for _ <- 1..3, do: assert_receive({:update_adapters, _espex, [sendspin: _]}, 500) for _ <- 1..2, do: assert_receive({:disconnect_clients, :espex_test}, 500) refute_receive {:update_adapters, _, _}, 100 From 5079644d70e871b82336e14692e9951a25db4261 Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:20:04 +0000 Subject: [PATCH 6/7] Treat an HA pairing Store read error as a failed load get_home_assistant_pairing/1 now returns {:ok, selection | nil} or {:error, reason}, so a DETS lookup error goes through HomeAssistantPairing's load retry instead of reading as no selection. A malformed row is logged and reads as none. Co-Authored-By: Claude Opus 5.5 --- .../audio/home_assistant_pairing.ex | 2 +- lib/universal_proxy/audio/store.ex | 24 ++++++++++---- .../audio/home_assistant_pairing_test.exs | 31 +++++++++++++++--- test/universal_proxy/audio/store_test.exs | 32 ++++++++++++++++--- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/lib/universal_proxy/audio/home_assistant_pairing.ex b/lib/universal_proxy/audio/home_assistant_pairing.ex index 30103226..d9a0c75f 100644 --- a/lib/universal_proxy/audio/home_assistant_pairing.ex +++ b/lib/universal_proxy/audio/home_assistant_pairing.ex @@ -340,7 +340,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairing do end defp load(t) do - {:ok, Store.get_home_assistant_pairing(t.store)} + Store.get_home_assistant_pairing(t.store) catch :exit, _reason -> {:error, :unavailable} end diff --git a/lib/universal_proxy/audio/store.ex b/lib/universal_proxy/audio/store.ex index f834a843..04784555 100644 --- a/lib/universal_proxy/audio/store.ex +++ b/lib/universal_proxy/audio/store.ex @@ -141,11 +141,13 @@ defmodule UniversalProxy.Audio.Store do @type home_assistant_pairing :: %{key: output_key(), client_id: String.t() | nil} @doc """ - The persisted Home Assistant pairing selection, or `nil` when none is - set. Kept in the same table as the output rows, under a non-tuple key - that `get_all/1` skips. + The persisted Home Assistant pairing selection: `{:ok, nil}` when none is + set (a malformed row is logged and reads as none), `{:error, reason}` when + DETS can't be read. Kept in the same table as the output rows, under a + non-tuple key that `get_all/1` skips. """ - @spec get_home_assistant_pairing(GenServer.server()) :: home_assistant_pairing() | nil + @spec get_home_assistant_pairing(GenServer.server()) :: + {:ok, home_assistant_pairing() | nil} | {:error, term()} def get_home_assistant_pairing(server \\ __MODULE__) do GenServer.call(server, :get_home_assistant_pairing) end @@ -239,10 +241,18 @@ defmodule UniversalProxy.Audio.Store do case :dets.lookup(state.table, @home_assistant_pairing) do [{_, %{key: {slot_sub, _, _}, client_id: id} = selection}] when is_binary(slot_sub) and (is_binary(id) or is_nil(id)) -> - selection + {:ok, selection} - _ -> - nil + [] -> + {:ok, nil} + + {:error, reason} -> + Logger.error("Audio store Home Assistant pairing read failed: #{inspect(reason)}") + {:error, reason} + + [_malformed] -> + Logger.warning("Audio store: ignoring a malformed Home Assistant pairing row") + {:ok, nil} end {:reply, result, state} diff --git a/test/universal_proxy/audio/home_assistant_pairing_test.exs b/test/universal_proxy/audio/home_assistant_pairing_test.exs index 347ef213..e311f377 100644 --- a/test/universal_proxy/audio/home_assistant_pairing_test.exs +++ b/test/universal_proxy/audio/home_assistant_pairing_test.exs @@ -150,6 +150,24 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do end describe "a selection that can't be loaded" do + test "a Store read error is retried like a Store that is down", ctx do + :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: "hp-id"}) + # Truncating the file under the open table makes DETS reads fail. + File.write!(ctx.path, "") + + ExUnit.CaptureLog.capture_log(fn -> + {pid, _} = start_pairing!(ctx, drain: false) + + assert HomeAssistantPairing.selection(pid) == {:error, :unavailable} + assert {:error, :unavailable} = HomeAssistantPairing.clear(pid) + assert %{pending: :load, timer: timer} = sync(pid) + assert is_reference(timer) + refute_receive {:update_adapters, _, _}, 50 + refute_received {:disconnect_clients, _} + assert %{pending: :load, loaded?: false} = sync(pid) + end) + end + test "is not taken as none: nothing is applied until the load works", ctx do :ok = Store.put_home_assistant_pairing(ctx.store, %{key: @key, client_id: "hp-id"}) :ok = stop_supervised(Store) @@ -191,7 +209,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert :ok = HomeAssistantPairing.select(pid, @key) assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} - assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} + assert Store.get_home_assistant_pairing(ctx.store) == {:ok, %{key: @key, client_id: "hp-id"}} assert_received {:home_assistant_pairing, @key} assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} @@ -244,7 +262,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert :ok = HomeAssistantPairing.clear(pid) assert HomeAssistantPairing.selection(pid) == {:ok, nil} - assert Store.get_home_assistant_pairing(ctx.store) == nil + assert Store.get_home_assistant_pairing(ctx.store) == {:ok, nil} assert_received {:home_assistant_pairing, nil} assert_received {:update_adapters, _espex, [sendspin: nil]} assert_received {:disconnect_clients, :espex_test} @@ -291,7 +309,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do assert HomeAssistantPairing.selection(pid) == {:ok, %{key: {"Gone", nil, nil}, client_id: "old"}} - assert Store.get_home_assistant_pairing(ctx.store).client_id == "old" + assert {:ok, %{client_id: "old"}} = Store.get_home_assistant_pairing(ctx.store) end test "a client id that changed while it was down is caught up at start", ctx do @@ -299,7 +317,7 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do {pid, _} = start_pairing!(ctx, drain: false) assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @key, client_id: "hp-id"}} - assert Store.get_home_assistant_pairing(ctx.store) == %{key: @key, client_id: "hp-id"} + assert Store.get_home_assistant_pairing(ctx.store) == {:ok, %{key: @key, client_id: "hp-id"}} assert_received {:update_adapters, _espex, [sendspin: UniversalProxy.ESPHome.Sendspin]} assert_received {:disconnect_clients, :espex_test} end @@ -378,7 +396,10 @@ defmodule UniversalProxy.Audio.HomeAssistantPairingTest do sync(pid) assert HomeAssistantPairing.selection(pid) == {:ok, %{key: @usb_key, client_id: "usb-id"}} - assert Store.get_home_assistant_pairing(store) == %{key: @usb_key, client_id: "usb-id"} + + assert Store.get_home_assistant_pairing(store) == + {:ok, %{key: @usb_key, client_id: "usb-id"}} + assert_received {:disconnect_clients, :espex_test} refute_received {:update_adapters, _, _} diff --git a/test/universal_proxy/audio/store_test.exs b/test/universal_proxy/audio/store_test.exs index 4fe20a74..468b9fce 100644 --- a/test/universal_proxy/audio/store_test.exs +++ b/test/universal_proxy/audio/store_test.exs @@ -278,16 +278,37 @@ defmodule UniversalProxy.Audio.StoreTest do describe "Home Assistant pairing selection" do test "is nil until set, and nil clears it", %{server: server} do - assert Store.get_home_assistant_pairing(server) == nil + assert Store.get_home_assistant_pairing(server) == {:ok, nil} :ok = Store.put_home_assistant_pairing(server, %{key: @key, client_id: nil}) - assert Store.get_home_assistant_pairing(server) == %{key: @key, client_id: nil} + assert Store.get_home_assistant_pairing(server) == {:ok, %{key: @key, client_id: nil}} :ok = Store.put_home_assistant_pairing(server, %{key: @usb_key, client_id: "abc"}) - assert Store.get_home_assistant_pairing(server) == %{key: @usb_key, client_id: "abc"} + assert Store.get_home_assistant_pairing(server) == {:ok, %{key: @usb_key, client_id: "abc"}} :ok = Store.put_home_assistant_pairing(server, nil) - assert Store.get_home_assistant_pairing(server) == nil + assert Store.get_home_assistant_pairing(server) == {:ok, nil} + end + + test "a malformed row reads as none, and is logged", %{server: server} do + :ok = :dets.insert(:sys.get_state(server).table, {:home_assistant_pairing, :garbage}) + + log = + ExUnit.CaptureLog.capture_log(fn -> + assert Store.get_home_assistant_pairing(server) == {:ok, nil} + end) + + assert log =~ "malformed Home Assistant pairing row" + end + + test "a read error is an error, not no selection", %{server: server, dets_path: path} do + :ok = Store.put_home_assistant_pairing(server, %{key: @key, client_id: "abc"}) + # Truncating the file under the open table makes DETS reads fail. + File.write!(path, "") + + ExUnit.CaptureLog.capture_log(fn -> + assert {:error, _reason} = Store.get_home_assistant_pairing(server) + end) end test "is not an output row", %{server: server} do @@ -307,7 +328,8 @@ defmodule UniversalProxy.Audio.StoreTest do reopened = start_supervised!({Store, name: nil, table: @table, dets_path: path}, id: :second) - assert Store.get_home_assistant_pairing(reopened) == %{key: @usb_key, client_id: "abc"} + assert Store.get_home_assistant_pairing(reopened) == + {:ok, %{key: @usb_key, client_id: "abc"}} end end end From 116a68f5e24446869a087d7460ac7928a263eb7f Mon Sep 17 00:00:00 2001 From: Ben Bangert <100193+bbangert@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:20:04 +0000 Subject: [PATCH 7/7] Emit a state_dir error when sendspin_player can't create its state dir The binary only printed to stderr, so Audio.Server never marked the output's identity failed and HA pairing answered NOT_READY instead of FAILED. Co-Authored-By: Claude Opus 5.5 --- c_src/sendspin_player/README.md | 4 +++- c_src/sendspin_player/src/main.cpp | 5 +++++ test/sendspin_player_contract_test.exs | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/c_src/sendspin_player/README.md b/c_src/sendspin_player/README.md index fdf4de46..8497cd0d 100644 --- a/c_src/sendspin_player/README.md +++ b/c_src/sendspin_player/README.md @@ -210,6 +210,7 @@ changes, and on errors. {"event":"pcm_tap","value":false,"ok":false,"error":"open: Permission denied"} // pcm_tap on failed {"event":"error","kind":"alsa_configure","msg":"..."} {"event":"error","kind":"pairing_token","msg":"..."} // non-fatal; token itself never on stdout +{"event":"error","kind":"state_dir","msg":"..."} // fatal: the identity cannot be kept (exits 1) {"event":"error","kind":"oversize_line","msg":"..."} // a line over the cap was dropped (a bug) {"event":"shutdown"} ``` @@ -306,7 +307,8 @@ X25519 public key, generates that keypair on first start, and stores it through the persistence provider. `FilePersistence` writes each library blob as one file in `--state-dir` (dir `0700`, files `0600`, written via temp file + fsync + rename), so the identity survives respawns and -reboots. A state dir that cannot be created fails the process before the +reboots. A state dir that cannot be created fails the process (with a +`state_dir` error event, before `started`) before the client starts, rather than letting it mint an identity it cannot keep. The library only logs a failed save of a freshly generated keypair (a full or read-only disk) and starts anyway, so the binary checks for that after diff --git a/c_src/sendspin_player/src/main.cpp b/c_src/sendspin_player/src/main.cpp index 7419685d..64e18464 100644 --- a/c_src/sendspin_player/src/main.cpp +++ b/c_src/sendspin_player/src/main.cpp @@ -746,6 +746,11 @@ int main(int argc, char* argv[]) { std::string err; if (!persistence.init(err)) { std::fprintf(stderr, "Error: --state-dir: %s\n", err.c_str()); + // The same event as a keypair that cannot be persisted: the BEAM + // side marks the output's identity as failed on it. `err` names + // the path, so it is bounded to keep the line under the budget. + emit_json("{\"event\":\"error\",\"kind\":\"state_dir\",\"msg\":\"state dir unusable: " + + json_escape(sanitize_text(err, 512)) + "\"}"); return 1; } } diff --git a/test/sendspin_player_contract_test.exs b/test/sendspin_player_contract_test.exs index 6e8a1f8a..7ffb819c 100644 --- a/test/sendspin_player_contract_test.exs +++ b/test/sendspin_player_contract_test.exs @@ -288,6 +288,23 @@ defmodule SendspinPlayerContractTest do assert File.read!(keypair) == original end + test "reports a --state-dir it cannot create as a state_dir error, then exits", %{ + tmp_dir: tmp_dir + } do + # A regular file where a parent directory should be: the dir can't be + # created, even when CI runs as root. + blocker = Path.join(tmp_dir, "not_a_dir") + File.write!(blocker, "") + + port = spawn_player(state_dir: Path.join(blocker, "state"), ws_port: @ws_port + 7) + + # Before `started`: nothing else has run yet. + assert %{"event" => "error", "kind" => "state_dir", "msg" => "state dir unusable: " <> _} = + next_event(port) + + assert wait_for_exit(port) == 1 + end + test "exits non-zero without --state-dir" do port = spawn_player(state_dir: nil, ws_port: @ws_port + 3) assert wait_for_exit(port) != 0