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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions lib/universal_proxy_web/live/audio_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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))}
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)))}
Expand Down Expand Up @@ -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} />
</div>
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -965,6 +1013,24 @@ defmodule UniversalProxyWeb.AudioLive do
</form>
</div>

<%!-- Home Assistant pairing: at most one output at a time --%>
<div class="flex items-center gap-3 pt-3 pr-5 pb-3 pl-[22px] border-t border-border-2 bg-sunken">
<.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"
/>
<div class="min-w-0">
<div class="text-sm text-fg-2">Pair through Home Assistant</div>
<div :if={@ha_pairing?} id={"ha-pairing-hint-#{@id}"} class="text-[11px] text-fg-4">
Home Assistant hands this output's pairing token to Music Assistant
(encrypted API connections only). One output at a time.
</div>
</div>
</div>

<%!-- Disclosure: card index, client id and pairing token, each
with a copy action --%>
<div
Expand Down Expand Up @@ -1555,6 +1621,9 @@ defmodule UniversalProxyWeb.AudioLive do

defp encode_key(key), do: OpaqueKey.encode(key)

defp encode_selection(nil), do: nil
defp encode_selection(key), do: encode_key(key)

# Strip control chars + trim + cap length. Used both in the modal's
# live char counter and in the final commit path. Returns the raw
# cleaned string (not wrapped in {:ok, _}) for use in templates.
Expand Down
132 changes: 132 additions & 0 deletions test/universal_proxy_web/live/audio_live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,8 @@ defmodule UniversalProxyWeb.AudioLiveTest do
send(Application.get_env(:universal_proxy, :pairing_token_test_pid), {:read, key})
Application.get_env(:universal_proxy, :pairing_token_result)
end

def home_assistant_pairing, do: nil
end

setup do
Expand Down Expand Up @@ -963,4 +965,134 @@ defmodule UniversalProxyWeb.AudioLiveTest do
refute render(view) =~ @token
end
end

describe "Home Assistant pairing toggle" do
@usb_key {"USB Audio", 0x1D6B, 0x0104}

defmodule HAPairingFacade do
@moduledoc false
# Stands in for `UniversalProxy.Audio` (the `:audio_facade` seam):
# records the calls and answers with the scripted results.
def home_assistant_pairing, do: Application.get_env(:universal_proxy, :ha_pairing_selected)

def select_home_assistant_pairing(key) do
send(Application.get_env(:universal_proxy, :ha_pairing_test_pid), {:select, key})
Application.get_env(:universal_proxy, :ha_pairing_result, :ok)
end

def clear_home_assistant_pairing do
send(Application.get_env(:universal_proxy, :ha_pairing_test_pid), :clear)
Application.get_env(:universal_proxy, :ha_pairing_result, :ok)
end

def pairing_token(_key), do: {:error, :not_ready}
end

setup do
Application.put_env(:universal_proxy, :audio_facade, HAPairingFacade)
Application.put_env(:universal_proxy, :ha_pairing_test_pid, self())

on_exit(fn ->
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
Loading