You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Report a selected output's Sendspin client to Home Assistant (backend) - #320
Backend half of #229: report one user-selected audio output's Sendspin client to Home Assistant through an Espex.Sendspin adapter, so HA 2026.11 can read its pairing token and hand it to Music Assistant. The Audio tab UI that makes the selection comes in a stacked PR.
UniversalProxy.Audio.HomeAssistantPairing (new, under Audio.Supervisor after Server): holds the selection (at most one output key, none by default), persisted in Audio.Store's DETS table under a non-output key along with the last Sendspin client_id seen for it. API: selection/1, selected?/1, select/2, clear/1; facade Audio.home_assistant_pairing/0, select_home_assistant_pairing/1, clear_home_assistant_pairing/0 (with the catch :exit idiom). Changes are broadcast on sendspin:home_assistant_pairing. The selection is the only thing broadcast, never the token.
UniversalProxy.ESPHome.Sendspin (new): the Espex.Sendspin adapter. client_id/0 returns the selection's cached id. pairing_token/0 reads through Audio.pairing_token/1.
ESPHome.Supervisor.start_espex/2 wires sendspin: only while a selection exists. It checks on every Espex start, so an Espex restart picks up runtime changes.
Audio.Server now tracks a per-output identity_error. It is set by the binary's state_dir error event, survives a respawn's shutdown reset, and is cleared by the next listening. This is what :failed maps from.
Design decisions
Reconfig: selecting, switching or clearing calls Espex.update_adapters(sendspin: Sendspin | nil) and then Espex.disconnect_clients/1. Both steps are retried with backoff (1 s, up to 30 s) while Espex is down or restarting, the same as MeshSync. The Espex calls are injectable for tests.
The persisted selection is the source of truth.HomeAssistantPairing loads and reconciles in handle_continue/2. On every start it re-applies the selection with update_adapters and then disconnect_clients, so work a crashed run left pending is redone. It also monitors Espex.Server, because a Server-only restart reverts to the adapters Espex started with. It updates the same pid it monitors (resolved once) and, on :DOWN, re-applies to the replacement. A failed Store load is retried, not read as no selection: nothing is applied until it works.
Late client_id: HA reads capabilities only on connect. So when the selected output reports a new client_id (its first listening, or a new keypair), the clients are disconnected too, without an adapter update. Otherwise HA would show an empty id until its next reconnect. To avoid needless reconnects, the id is persisted with the selection: after a reboot HA sees the same id straight away, with no empty-id flap and no extra disconnect while the player starts.
Unplugged output: the selection is kept. client_id/0 keeps reporting the cached id, pairing_token/0 answers :not_ready, and the selection applies again on replug. Only a present output can be selected.
Token mapping: unplugged, no token file yet, or audio subsystem down → :not_ready. Output turned off → :disabled. identity_error → :failed. A readable token → {:ok, token}. An unreadable or malformed token file gives :not_ready, not :failed, because the player rewrites it on every start. No selection (a connection that raced the unwiring) gives :disabled.
Secrecy: the token is only ever returned to espex. It is never logged, cached, put on PubSub, or held in HomeAssistantPairing state.
Inputs are not selectable: they still offer only the old-wire dynamic_pin.
Testing
New: home_assistant_pairing_test.exs covers select/clear/reselect, persistence, unplugged selection, client-id catch-up at start and on events, the Espex call order, and retry with a stubbed Espex. sendspin_test.exs covers the token mapping and client_id. There are also additions to the Store, Server and ESPHome supervisor tests.
mise run test: 3809 passed (8 doctests, 3801 tests), 3 excluded
Argus: 33 findings, 33 baselined, 0 new, 0 stale
mix dialyzer (host, dev): passed
mix format --check-formatted: clean
HW validation still required (the issue's "Done when" boxes) on the rpi3 against HA 2026.11 + MA 2.11 dev, once the UI PR lands:
HA pairs a selected output with MA end to end
Changing the selection makes HA see the new client_id after it reconnects
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Process restarts can leave Sendspin capability or identity stale by losing pending updates or restoring old wiring, and an unavailable selection process is incorrectly reported as disabled rather than not ready.
Adds the backend for #229, allowing Home Assistant to discover one user-selected audio output’s Sendspin client and request its pairing token. The selection UI is deferred to a stacked PR; end-to-end hardware validation remains outstanding.
Changes:
Persists the selected output and cached client ID, with public selection APIs and retryable Espex updates.
Adds the Sendspin adapter and tracks identity-persistence failures for token status reporting.
Adds tests for persistence, selection changes, reconnects, retries, and token mapping.
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 <noreply@anthropic.com>
…ment
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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved reconciliation bugs can lose the persisted Home Assistant selection after a Store timeout or leave Sendspin capability incorrect after an Espex restart due to a monitor-registration race.
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 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
DETS read errors are treated as empty selections, causing Sendspin to be unwired instead of retried, and state-directory initialization failures lack a structured error event, leaving affected outputs reporting :not_ready rather than :failed.
No open issues 🎉
2 previously missed issues
In code that hasn't changed since last review
Emit a state_dir error when state-directory initialization fails
lib/universal_proxy/audio/server.ex:523
When FilePersistence::init cannot create, chmod, or open the state directory, c_src/sendspin_player/src/main.cpp:747-750 prints to stderr and exits without emitting a state_dir event. This leaves identity_error false, so an output with no readable token keeps reporting :not_ready instead of :failed across respawns. Emit the same structured state_dir error from that initialization-failure path and add regression coverage.
Propagate DETS lookup errors to the load-retry path
lib/universal_proxy/audio/store.ex:245
A DETS lookup can return {:error, reason}, but this catch-all converts it to nil. HomeAssistantPairing.load/1 then treats the failed read as a successfully loaded empty selection and unwires Sendspin instead of retrying. Please distinguish lookup errors from missing rows and propagate them into the existing load-retry path.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This fixes the two issues Copilot's round-3 review raised without inline threads:
A Store read error was read as no selection. Fixed in 5079644. Audio.Store.get_home_assistant_pairing/1 now returns:
{:ok, selection} for a valid row
{:ok, nil} for a missing row, or for a malformed one (logged as a warning)
{:error, reason} when the :dets.lookup fails
HomeAssistantPairing.load/1 passes the error through, so a lookup error goes down the existing load-retry path. Nothing is applied, and selection/select/clear answer :unavailable until a load succeeds. All callers are updated. Tests inject the error by truncating the DETS file under the open table, in the Store tests and in the HomeAssistantPairing tests. There is also a malformed-row test.
sendspin_player exited without a state_dir event when it couldn't create its state dir. Fixed in 116a68f. When persistence.init fails, the binary still writes its stderr message. It now also emits {"event":"error","kind":"state_dir","msg":"state dir unusable: <err>"} through emit_json (escaped with json_escape, and bounded with sanitize_text(err, 512) because the message carries the path) before exiting 1. Audio.Server therefore sets identity_error, and the token request answers FAILED instead of NOT_READY. emit_json is a plain stdout write under a mutex, with no client needed. A new contract test runs the real host binary with a state dir under a regular file, so it can't be created even as root. It checks that this event comes first, before started, and that the exit status is 1. The README event list is updated.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Required rpi3 validation with HA 2026.11 and MA 2.11 dev remains pending, leaving token-based auto-pairing and client_id refresh after output switching and reconnect unverified by the stubbed Espex tests.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Backend half of #229: report one user-selected audio output's Sendspin client to Home Assistant through an
Espex.Sendspinadapter, so HA 2026.11 can read its pairing token and hand it to Music Assistant. The Audio tab UI that makes the selection comes in a stacked PR.UniversalProxy.Audio.HomeAssistantPairing(new, underAudio.SupervisorafterServer): holds the selection (at most one output key, none by default), persisted inAudio.Store's DETS table under a non-output key along with the last Sendspinclient_idseen for it. API:selection/1,selected?/1,select/2,clear/1; facadeAudio.home_assistant_pairing/0,select_home_assistant_pairing/1,clear_home_assistant_pairing/0(with thecatch :exitidiom). Changes are broadcast onsendspin:home_assistant_pairing. The selection is the only thing broadcast, never the token.UniversalProxy.ESPHome.Sendspin(new): theEspex.Sendspinadapter.client_id/0returns the selection's cached id.pairing_token/0reads throughAudio.pairing_token/1.ESPHome.Supervisor.start_espex/2wiressendspin:only while a selection exists. It checks on every Espex start, so an Espex restart picks up runtime changes.Audio.Servernow tracks a per-outputidentity_error. It is set by the binary'sstate_direrror event, survives a respawn's shutdown reset, and is cleared by the nextlistening. This is what:failedmaps from.Design decisions
Espex.update_adapters(sendspin: Sendspin | nil)and thenEspex.disconnect_clients/1. Both steps are retried with backoff (1 s, up to 30 s) while Espex is down or restarting, the same asMeshSync. The Espex calls are injectable for tests.HomeAssistantPairingloads and reconciles inhandle_continue/2. On every start it re-applies the selection withupdate_adaptersand thendisconnect_clients, so work a crashed run left pending is redone. It also monitorsEspex.Server, because a Server-only restart reverts to the adapters Espex started with. It updates the same pid it monitors (resolved once) and, on:DOWN, re-applies to the replacement. A failed Store load is retried, not read as no selection: nothing is applied until it works.client_id(its firstlistening, or a new keypair), the clients are disconnected too, without an adapter update. Otherwise HA would show an empty id until its next reconnect. To avoid needless reconnects, the id is persisted with the selection: after a reboot HA sees the same id straight away, with no empty-id flap and no extra disconnect while the player starts.client_id/0keeps reporting the cached id,pairing_token/0answers:not_ready, and the selection applies again on replug. Only a present output can be selected.:not_ready. Output turned off →:disabled.identity_error→:failed. A readable token →{:ok, token}. An unreadable or malformed token file gives:not_ready, not:failed, because the player rewrites it on every start. No selection (a connection that raced the unwiring) gives:disabled.HomeAssistantPairingstate.dynamic_pin.Testing
home_assistant_pairing_test.exscovers select/clear/reselect, persistence, unplugged selection, client-id catch-up at start and on events, the Espex call order, and retry with a stubbed Espex.sendspin_test.exscovers the token mapping andclient_id. There are also additions to the Store, Server and ESPHome supervisor tests.mise run test: 3809 passed (8 doctests, 3801 tests), 3 excludedmix dialyzer(host, dev): passedmix format --check-formatted: cleanHW validation still required (the issue's "Done when" boxes) on the rpi3 against HA 2026.11 + MA 2.11 dev, once the UI PR lands:
client_idafter it reconnectsRefs #229
🤖 Generated with Claude Code