diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index f7257bbc..dc13760a 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -56,6 +56,16 @@ defmodule UniversalProxyWeb.AudioLive do receiver (`volume_external`). Outputs on unpaired cards, and every output on a build without CEC, show none of this. + ## Home Assistant pairing + + Each output card has a "Pair through Home Assistant" toggle that selects + the one output whose Sendspin client is reported to Home Assistant + (`UniversalProxy.Audio.HomeAssistantPairing`), so HA can hand its + pairing token to Music Assistant. Turning it on for one output turns it + off for any other; the selection follows `{:home_assistant_pairing, key}` + on `HomeAssistantPairing.topic/0`. The token itself never reaches this + view through the toggle. + ## MilkDrop visualizer While `UniversalProxy.MilkDrop.enabled?/0`, a "MilkDrop visualizer" @@ -87,6 +97,7 @@ defmodule UniversalProxyWeb.AudioLive do import UniversalProxyWeb.Components.Audio alias UniversalProxy.Audio + alias UniversalProxy.Audio.HomeAssistantPairing alias UniversalProxy.Audio.Input alias UniversalProxy.Bluetooth alias UniversalProxy.Bluetooth.AudioManager @@ -130,6 +141,7 @@ defmodule UniversalProxyWeb.AudioLive do Phoenix.PubSub.subscribe(UniversalProxy.PubSub, "sendspin:input_removed") Phoenix.PubSub.subscribe(UniversalProxy.PubSub, "sendspin:input_state") Phoenix.PubSub.subscribe(UniversalProxy.PubSub, Cec.topic()) + Phoenix.PubSub.subscribe(UniversalProxy.PubSub, HomeAssistantPairing.topic()) if milkdrop? do Phoenix.PubSub.subscribe(UniversalProxy.PubSub, MilkDrop.settings_topic()) @@ -171,6 +183,10 @@ defmodule UniversalProxyWeb.AudioLive do # be read. Absent means hidden. The token is read on demand only and # dropped again on Hide / disclosure close / output removal. |> assign(:pairing_tokens, %{}) + # Encoded id of the output reported to Home Assistant, or nil. The key + # needn't be a shown card: a selected output that is unplugged stays + # selected. + |> assign(:ha_pairing, encode_selection(audio_facade().home_assistant_pairing())) # Audio inputs (capture cards), keyed by the same base64-encoded # `{slot_sub, vid, pid}` id the outputs map above uses. |> assign(:inputs, build_inputs_map(inputs))} @@ -361,6 +377,32 @@ defmodule UniversalProxyWeb.AudioLive do {:noreply, update(socket, :pairing_tokens, &Map.delete(&1, id))} end + # The view's selection is updated by the broadcast, not here, so every + # open page shows the same one. + def handle_event("toggle_ha_pairing", %{"id" => id}, socket) do + with {:ok, _output} <- fetch_output(socket, id), + {:ok, key} <- decode_key(id) do + result = + if socket.assigns.ha_pairing == id, + do: audio_facade().clear_home_assistant_pairing(), + else: audio_facade().select_home_assistant_pairing(key) + + case result do + :ok -> + {:noreply, socket} + + {:error, reason} -> + Logger.warning( + "Audio Home Assistant pairing change failed for #{inspect(key)}: #{inspect(reason)}" + ) + + {:noreply, put_flash(socket, :error, "Home Assistant pairing change failed")} + end + else + _ -> {:noreply, socket} + end + end + # Operator consent gesture for pairing a capture card. Opens the source's # time-boxed "allow pairing" window; the source stays authoritative over what # happens next (it still requires MA to type the derived PIN). @@ -449,6 +491,10 @@ defmodule UniversalProxyWeb.AudioLive do {:noreply, assign(socket, :cec, cec_by_card(adapters))} end + def handle_info({:home_assistant_pairing, key}, socket) do + {:noreply, assign(socket, :ha_pairing, encode_selection(key))} + end + def handle_info({:sendspin_input_added, input}, socket) do {:noreply, update(socket, :inputs, &Map.put(&1, encode_key(input.key), build_input_card(input)))} @@ -522,6 +568,7 @@ defmodule UniversalProxyWeb.AudioLive do more_open?={MapSet.member?(@more_open, id)} pairing={Map.get(@pairing_tokens, id)} cec={Map.get(@cec, output.card_index)} + ha_pairing?={@ha_pairing == id} /> <.reconnecting_card :for={{mac, info} <- active_reconnecting(@reconnecting, @outputs)} name={info.name} mac={mac} /> @@ -748,6 +795,7 @@ defmodule UniversalProxyWeb.AudioLive do attr(:more_open?, :boolean, required: true) attr(:pairing, :any, default: nil) attr(:cec, :map, default: nil) + attr(:ha_pairing?, :boolean, default: false) # Single player card. Layout is a vertical flex with three logical # blocks: @@ -965,6 +1013,24 @@ defmodule UniversalProxyWeb.AudioLive do + <%!-- Home Assistant pairing: at most one output at a time --%> +
+ <.toggle + checked={@ha_pairing?} + phx-click="toggle_ha_pairing" + phx-value-id={@id} + id={"ha-pairing-#{@id}"} + aria-label="Let Home Assistant pair this output with Music Assistant" + /> +
+
Pair through Home Assistant
+
+ Home Assistant hands this output's pairing token to Music Assistant + (encrypted API connections only). One output at a time. +
+
+
+ <%!-- Disclosure: card index, client id and pairing token, each with a copy action --%>
+ for key <- [:audio_facade, :ha_pairing_test_pid, :ha_pairing_selected, :ha_pairing_result], + do: Application.delete_env(:universal_proxy, key) + end) + end + + defp mount_with_outputs(conn) do + {:ok, view, _html} = live(conn, "/audio") + + for output <- [ + sample_output(), + sample_output(%{key: @usb_key, card_index: 1, friendly_name: "USB DAC"}) + ] do + Phoenix.PubSub.broadcast( + @pubsub, + "sendspin:output_added", + {:sendspin_output_added, output} + ) + end + + render(view) + view + end + + defp checked?(view, key), + do: has_element?(view, ~s(#ha-pairing-#{encode(key)}[aria-checked="true"])) + + defp broadcast_selection(key) do + Phoenix.PubSub.broadcast( + @pubsub, + UniversalProxy.Audio.HomeAssistantPairing.topic(), + {:home_assistant_pairing, key} + ) + end + + test "every output offers it, off by default", %{conn: conn} do + view = mount_with_outputs(conn) + + assert has_element?(view, "#ha-pairing-#{encode(@hp_key)}") + assert has_element?(view, "#ha-pairing-#{encode(@usb_key)}") + refute checked?(view, @hp_key) + refute checked?(view, @usb_key) + assert render(view) =~ "Pair through Home Assistant" + refute has_element?(view, "#ha-pairing-hint-#{encode(@hp_key)}") + end + + test "shows the persisted selection at mount", %{conn: conn} do + Application.put_env(:universal_proxy, :ha_pairing_selected, @usb_key) + view = mount_with_outputs(conn) + + assert checked?(view, @usb_key) + refute checked?(view, @hp_key) + assert has_element?(view, "#ha-pairing-hint-#{encode(@usb_key)}") + end + + test "selects an output, and follows the broadcast selection", %{conn: conn} do + view = mount_with_outputs(conn) + + view |> element("#ha-pairing-#{encode(@hp_key)}") |> render_click() + assert_received {:select, @hp_key} + + broadcast_selection(@hp_key) + assert checked?(view, @hp_key) + refute checked?(view, @usb_key) + + # Selecting another output moves it. + view |> element("#ha-pairing-#{encode(@usb_key)}") |> render_click() + assert_received {:select, @usb_key} + broadcast_selection(@usb_key) + assert checked?(view, @usb_key) + refute checked?(view, @hp_key) + end + + test "clicking the selected output clears it", %{conn: conn} do + Application.put_env(:universal_proxy, :ha_pairing_selected, @hp_key) + view = mount_with_outputs(conn) + + view |> element("#ha-pairing-#{encode(@hp_key)}") |> render_click() + assert_received :clear + refute_received {:select, _} + + broadcast_selection(nil) + refute checked?(view, @hp_key) + end + + test "a failed change flashes an error and keeps the selection", %{conn: conn} do + Application.put_env(:universal_proxy, :ha_pairing_result, {:error, :not_found}) + view = mount_with_outputs(conn) + + html = view |> element("#ha-pairing-#{encode(@hp_key)}") |> render_click() + assert html =~ "Home Assistant pairing change failed" + refute checked?(view, @hp_key) + end + + test "an event for an output the page doesn't show is ignored", %{conn: conn} do + view = mount_with_outputs(conn) + + render_hook(view, "toggle_ha_pairing", %{"id" => encode({"Gone", nil, nil})}) + render_hook(view, "toggle_ha_pairing", %{"id" => "not-a-key"}) + refute_received {:select, _} + refute_received :clear + end + end end