Skip to content

Report a selected output's Sendspin client to Home Assistant (backend) - #320

Merged
bbangert merged 7 commits into
mainfrom
feat/ha-sendspin-auto-pair
Nov 10, 2026
Merged

bbangert merged 7 commits into
mainfrom
feat/ha-sendspin-auto-pair

Conversation

@bbangert

@bbangert bbangert commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

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

Refs #229

🤖 Generated with Claude Code

bbangert and others added 2 commits November 10, 2026 01:12
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

3 open issues
What changed in this PR

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.
File Description
test/​universal_proxy/​esphome/​supervisor_test.exs Tests conditional Sendspin adapter options.
test/​universal_proxy/​esphome/​sendspin_test.exs Tests token status mapping and cached client IDs.
test/​universal_proxy/​audio/​store_test.exs Tests selection persistence and separation from output rows.
test/​universal_proxy/​audio/​server_test.exs Tests identity-error tracking and recovery.
test/​universal_proxy/​audio/​home_assistant_pairing_test.exs Tests selection lifecycle, client-ID updates, and retries.
lib/​universal_proxy/​esphome/​supervisor.ex Reads selection when starting the Espex supervisor.
lib/​universal_proxy/​esphome/​sendspin.ex Implements the Sendspin client-ID and token adapter.
lib/​universal_proxy/​audio/​supervisor.ex Supervises the pairing-selection process.
lib/​universal_proxy/​audio/​store.ex Stores the selection and cached client ID in DETS.
lib/​universal_proxy/​audio/​server.ex Tracks identity-persistence errors until listening resumes.
lib/​universal_proxy/​audio/​home_assistant_pairing.ex Owns selection state and coordinates Espex updates and reconnects.
lib/​universal_proxy/​audio.ex Exposes selection, select, and clear facade APIs.

Review effort: Balanced

Comment thread lib/universal_proxy/esphome/sendspin.ex Resolved
bbangert and others added 2 commits November 10, 2026 01:33
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

2 open issues
3 resolved since last review

Review effort: Balanced

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium severity 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.

Medium severity 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.

2 resolved since last review

Review effort: Balanced

bbangert and others added 2 commits November 10, 2026 02:20
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>
@bbangert

Copy link
Copy Markdown
Owner Author

This fixes the two issues Copilot's round-3 review raised without inline threads:

  1. 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.

  2. 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

No open issues 🎉

Review effort: Balanced

@bbangert
bbangert merged commit 7753db2 into main Nov 10, 2026
9 checks passed
@bbangert
bbangert deleted the feat/ha-sendspin-auto-pair branch November 10, 2026 02:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants