# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [2.73.4] - 2026-07-23 ### Fixed - **Slow Sendspin handshakes no longer restart the player daemon.** Timing telemetry now waits for clock synchronization before reading uncertainty, and optional observability tasks cannot terminate playback if metric collection fails. ([#403](https://github.com/trudenboy/sendspin-bt-bridge/issues/403)) ## [2.73.3] - 2026-07-23 ### Changed - Updated `aiohttp` to 3.14.3, `certifi` to 2026.7.22, `websockets` to 16.1.1, Ruff to 0.15.22, and the Docker uv stage to 0.11.31. CI now verifies installed exact dev-tool pins against project metadata. ### Fixed - **Bluetooth volume now uses one gain stage.** Bridge-owned sink-inputs are normalized to 100% when streams start or move, while the Bluetooth sink remains the only volume-control point; unrelated application streams are left untouched. - **The Render demo now preserves the required `aiosendspin` override.** Demo dependencies install first and `sendspin` installs with `--no-deps`, preventing its stale 6.0.x constraint from replacing `aiosendspin` 6.1.1. ## [2.73.2] - 2026-07-23 ### Fixed - **Docker images now retain the required `aiosendspin` 6.1.1 override.** Installing `sendspin` can no longer silently downgrade the already pinned client library to 6.0.5, and every release image now fails its smoke test if the installed version drifts from `requirements.txt`. - **Bluetooth reconnect failures now expose the actual BlueZ error.** Diagnostics preserve `bluetoothctl` stderr and discard the harmless `Agent registered` banner, while the duplicate-bridge warning validates Linux process identity before treating a reused Docker PID as another running instance. ([forum report](https://community.home-assistant.io/t/sendspin-bluetooth-bridge-turn-any-bt-speaker-into-an-ma-player-and-ha/993762/146)) ## [2.73.1] - 2026-07-22 ### Fixed - **Fresh installs and upgrades on x86_64 LXC no longer fail while resolving Python dependencies.** The installer preserves the required `aiosendspin` 6.1.1 override and installs `sendspin` separately instead of asking pip to resolve its stale dependency metadata. ([#393](https://github.com/trudenboy/sendspin-bt-bridge/issues/393)) ## [2.73.0] - 2026-07-22 ### Added - **Per-speaker synchronization telemetry and guided latency tuning.** Device cards can show the AVDTP delay reported by the Bluetooth speaker, offer codec-based starting values when no report exists, apply ±10/±50 ms corrections live, play calibration clicks, and run an opt-in three-trial browser-microphone comparison between two speakers. - **Live Sendspin timing and buffer controls.** Backend output latency, buffered audio, playback error, clock offset/uncertainty, and rolling re-anchor counts now flow through device status, history API, diagnostics, and Home Assistant entities. Per-device startup lead and minimum buffer settings hot-apply without a player restart when supported by the installed client library. - **Advanced Bluetooth recovery controls now report whether they can work on the current host.** The UI distinguishes PipeWire from classic PulseAudio, verifies the loaded Bluetooth discovery module, Linux networking capabilities, rfkill access, and USB-reset access, then disables unavailable controls with a concrete reason. - **Adapter recovery now has structured runtime status.** Diagnostics can show when recovery ran, which adapter it targeted, its current stage, result, and failure reason without scraping log wording. ### Changed - **Experimental features are now presented as narrowly scoped advanced compatibility tools.** Stable RSSI, room labels, pairing peer-quiesce, and microphone calibration remain visible normally; the Class of Device workaround appears only when already configured or when diagnostics identify the matching Samsung failure; recovery workarounds retain their warnings and restart requirements. - **Risky pairing compatibility choices are now one-shot and target-bound.** Just-Works and HFP/HSP authorization apply only to the selected request, reset in the UI immediately after it starts, and the BlueZ pairing agent rejects every device other than the selected MAC. Temporarily making an adapter quiet is now accurately described as disconnecting its other speakers rather than pausing them. - **Bluetooth discovery now starts only after explicit confirmation on one concrete adapter.** Checklist and empty-state Scan actions open the modal and emphasize **Start Scan** without starting automatically; the first detected controller is selected and the ambiguous all-adapters scope is removed. The room-handoff readiness badge is shortened to **Transfer**. - **Test clicks now toggles a continuous synchronized metronome.** Multiple speakers join one shared 120-BPM woodblock phase, active buttons switch to Stop clicks, and live delay adjustments rejoin the shared phase with the new compensation value while microphone comparison keeps its bounded recording chirp. PipeWire uses a low-latency native stream, while an inaudible carrier and short gate pre-roll keep DSP-heavy speakers active between clicks. - **Latency recommendations are now one-shot defaults for newly registered devices.** The first available BlueZ delay report or codec fallback is applied and persisted automatically, then permanently yields to live nudges, microphone calibration, and every other manual value. Existing devices are never changed by this initialization path. - **Delay tuning now lives with each device's configuration actions.** The duplicate grid/list controls and standalone Delay column are replaced by one compact minus/value/plus stepper before Bluetooth actions, with a shared ±10/±50 selector, press-and-hold adjustment, metronome, and microphone controls. Microphone comparison is now a stable always-available workflow instead of an experimental opt-in. - **Latency Assistant now uses runtime instability, not configuration alone.** Repeated re-anchors during playback produce an evidence-backed buffer recommendation, while confirmed manual or microphone calibration remains distinct from advisory Bluetooth and codec estimates. ### Removed - **Persisted global defaults for Just-Works and HFP/HSP pairing authorization.** Legacy saved values are discarded during configuration migration so an earlier troubleshooting choice cannot silently weaken or alter later pairing attempts. ### Fixed - **Standalone lifecycle and installation labels are now accurate.** **Save & Restart** launches a detached successor before stopping a directly started Python process, while the header reports `Standalone` unless the host exposes an actual Home Assistant, Docker, LXC, or installed systemd-service marker. - **Live latency tuning now remains applied and renders correctly for every speaker.** Successful changes are reflected in the daemon's next status snapshot instead of being overwritten by an older value, devices without AVDTP delay reporting show their numeric codec fallback, microphone comparison accepts a Bluetooth-connected speaker from a different Music Assistant group while rejecting disconnected peers before recording begins, calibration audio waits for suspended Bluetooth sinks to wake before emitting an audible chirp, comparison uses the acoustic energy envelope so speakers with different frequency responses still correlate, and a recovered status stream clears stale backend-unavailable warnings without exhausting the web server's request workers. - **Each Sendspin player now stays routed to its own Bluetooth speaker on PipeWire.** Bluetooth subprocesses use the PulseAudio-compatible PortAudio output that honors their explicit target sink, instead of PipeWire's shared ALSA default, and route correction remains active for later streams rather than expiring shortly after startup. ## [2.72.1] - 2026-07-13 ### Added - **A per-speaker "Reclaim" button in the Home Assistant integration.** Exposes the web-UI Reclaim action as a Home Assistant button, so you can take an auto-released speaker back over — or trigger it from an automation when the speaker comes back — without opening the bridge web UI. ([#357](https://github.com/trudenboy/sendspin-bt-bridge/issues/357)) ### Changed - **A speaker left off for a long time no longer floods the log with reconnect attempts.** The repeated "reconnecting… (attempt N)" message is now logged as a warning only on the first attempt and quietly at debug level thereafter, so the saturated-back-off retries of an unavailable speaker don't bury the rest of the log. ([#322](https://github.com/trudenboy/sendspin-bt-bridge/issues/322)) ## [2.72.0] - 2026-07-13 ### Added - **Auto-released speakers now reclaim themselves when they reconnect on their own.** Once a device hit the auto-release threshold ("N consecutive failed reconnects") or the churn guard, it stayed released until someone pressed Reclaim in the web UI — so a speaker switched off overnight needed a manual click every morning. The bridge now watches released devices: when the speaker re-establishes the Bluetooth link, management is reclaimed automatically, audio is configured and the player restarts. Only automatic releases are eligible — an operator release stays released, including across restarts. A one-minute quiet period after each auto-release prevents flapping. ([#349](https://github.com/trudenboy/sendspin-bt-bridge/issues/349), [#350](https://github.com/trudenboy/sendspin-bt-bridge/issues/350)) - **WirePlumber 0.5 headless systems (Raspberry Pi OS Trixie) no longer silently fail to create Bluetooth sinks.** WirePlumber 0.5 gates Bluetooth node creation on an active logind seat by default, so on a headless box a speaker connected at the BlueZ level while no sink ever appeared. The bridge now detects the condition and logs the exact fix, the Raspberry Pi install script ships the disabling drop-in automatically, and the troubleshooting guide covers the new configuration format. ([#331](https://github.com/trudenboy/sendspin-bt-bridge/issues/331), [#347](https://github.com/trudenboy/sendspin-bt-bridge/issues/347)) - **Pairing failures caused by HFP-default speakers now explain themselves.** When BlueZ reports that no audio profile could be brought up (typical for mic-equipped speakers that insist on the headset profile first, on hosts without an HFP backend — LXC containers especially), the failure reason in logs and the web UI now names the cause and links the recipe instead of a bare BlueZ error code. The troubleshooting guide gained a host-side-fix section and the community speaker list documents the Tribit StormBox 2 fingerprint. ([#355](https://github.com/trudenboy/sendspin-bt-bridge/issues/355)) ### Changed - **Dependency modernization across the 2.72.0 cycle.** Core protocol stack `sendspin` 7.3.1 → 7.5.0 and its Sendspin client `aiosendspin` 5.3.0 → 6.1.1 (a newer, gentler audio sync corrector — the previous fixed-window startup mute is gone; see Removed), `music-assistant-client` 1.3.5 → 1.4.3, and `pulsectl-asyncio` → 1.3.2 (official Python 3.13 support plus a leaked-file-descriptor fix relevant to [#345](https://github.com/trudenboy/sendspin-bt-bridge/issues/345)). Also refreshed `dbus-fast`, `zeroconf`, `websockets`, `bluetooth-adapters`, `usb-devices`, `bluetooth-auto-recovery`, the developer toolchain, and routine transitive updates. The dependency audit stays clean. - **The Home Assistant integration re-pairs once after this upgrade.** API access tokens for the Home Assistant custom component now use a faster verification scheme that also removes a per-request processing cost on every authenticated call. Existing tokens are retired as part of the upgrade, so the integration prompts you to re-pair a single time — a click in the add-on's Home Assistant panel, after which everything reconnects normally. ### Removed - **The brief startup mute at the start of playback has been removed.** The bridge used to mute the speaker for the first couple of seconds of a fresh stream to hide the audio engine's sync-correction artefacts; with the updated, gentler corrector that mute (and its transport-state gating) is gone, so playback is audible immediately. Sink-routing correction is unchanged. ([#341](https://github.com/trudenboy/sendspin-bt-bridge/issues/341)) ### Fixed - **Bluetooth scan now runs on — and labels results with — the adapter actually selected in the UI.** On hosts with several adapters where the kernel's hci numbering differs from BlueZ's registration order (typically after USB hotplug), selecting an adapter could silently scan a different physical adapter and show devices under the wrong adapter name. Adapter selection now resolves through the same kernel mapping the adapter list uses, and per-adapter attribution runs in dedicated sessions. ([#340](https://github.com/trudenboy/sendspin-bt-bridge/issues/340)) - **A sick speaker daemon can no longer flood the bridge with tens of thousands of log lines per second.** When a daemon subprocess entered a pathological state (such as file-descriptor exhaustion after long disconnected periods), the bridge re-emitted every log line it produced — reported at ~22 000 lines/s, maxing a CPU core and drowning the log ring. Forwarded daemon logs are now rate-limited (50 lines/s sustained, 200-line burst) with a periodic "N lines suppressed" summary; status and error messages are never dropped. The underlying file-descriptor leak was fixed by the `pulsectl-asyncio` 1.3.2 upgrade. ([#345](https://github.com/trudenboy/sendspin-bt-bridge/issues/345)) - **Music Assistant volume, mute and transport state sync reliably again.** Current Music Assistant servers advertise a `seek_relative` transport command that the previously bundled protocol client did not recognise, which made it drop *every* controller-state update (volume / mute / play state) and flood the log with parse errors. The updated client understands the command, so state stays in sync. - **A Home Assistant MQTT command no longer briefly freezes the whole bridge.** Pressing a button or moving a slider exposed through the MQTT integration could stall every speaker's audio sync and status updates for several seconds — and in the worst case dead-lock the bridge — while the command was handled. Commands now run off the main processing loop, so playback and status stay responsive. - **Music Assistant volume ramps no longer trigger a burst of disk writes that can stutter audio.** Rapid volume and delay changes from the daemon are now coalesced into a single debounced save that runs off the audio path, instead of rewriting the config file on every step. - **One speaker's slow Bluetooth stack no longer hitches the others.** Blocking system operations — adapter recovery, battery-level reads, device-name lookups, standby-sink creation, network name resolution and MQTT/config file access — were moved off the main loop, so they can't momentarily stall playback or status updates for unrelated speakers. - **Background audio-routing corrections and monitor-loop errors are now logged with full detail** instead of failing silently, making intermittent Bluetooth issues easier to diagnose from the logs. - **A malformed value in an uploaded or hand-edited configuration file no longer stops the bridge from starting.** Non-numeric values in legacy numeric fields fall back to a safe default with a warning instead of crashing the load in a boot loop. - **The status and diagnostics views now correctly report whether Music Assistant is configured and which update channel is selected**, instead of always showing "not configured" and the stable channel. - **Changing the Music Assistant server address or token now correctly asks for a restart to take effect**, rather than reporting the change as applied while the bridge kept using the old connection. - **A speaker process that can't be stopped gracefully is now reliably force-stopped and reaped**, so its audio port is freed for the next start (fixes occasional "address already in use" after a restart). - **Hardened the status channel between the bridge and each speaker process** so heavy log/status traffic can't interleave and corrupt a message, and a status update mid-change can no longer crash the reporter. - **Bluetooth reconnect handling no longer accumulates duplicate signal handlers across reconnects** (which caused repeated reconnect and Home Assistant re-registration work), and audio is now configured immediately after a reconnect instead of after a fixed delay. - **A forced reconnect or reset now waits behind an in-progress scan or pairing** instead of driving the Bluetooth adapter at the same time, and returns a clear "operation already in progress" response. - **Saved per-speaker volumes are no longer lost when the stored address differs only in letter case** from the configured device. - **Unrecognised on/off payloads sent to a switch entity are now rejected instead of defaulting to "on".** - **Corrected Home Assistant entity categories and internal status labels** that an earlier refactor had mangled, so the custom component's configuration entities are categorised correctly again. - **The Home Assistant integration now recovers on its own after the bridge restarts** — it pulls a fresh snapshot as soon as the event stream reconnects and polls on a slow safety-net interval, instead of showing stale entity states until the next event. - **Rotating or invalidating the bridge's API token now prompts Home Assistant to re-authenticate** instead of the integration silently freezing until a restart. - **Adding the integration is more robust to a slow or unreachable bridge** — connection timeouts and non-JSON responses during setup now surface a clean error instead of an unhandled failure. - **A malformed or partially-written addon configuration can no longer prevent startup**: the option translator preserves prior settings on a best-effort basis and writes the generated configuration atomically, so an interrupted run can't leave a truncated file. - **A Bluetooth adapter's saved name is no longer lost (or duplicated) when its address is stored in a different letter case** than the detected hardware. - **The auto-disable reconnect threshold no longer resets to 5 when you set it to 0.** Saving `0` (unlimited reconnects, opting out of auto-disable) from the settings form used to be silently reverted to the default on the next save/reload, because a one-time upgrade migration re-ran on every save. The setting now sticks. ([#332](https://github.com/trudenboy/sendspin-bt-bridge/issues/332)) ### Security - **Security dependency bumps closing every CVE flagged by the dependency audit.** `aiohttp` 3.13.5 → 3.14.1 (eleven advisories spanning request-smuggling, header-parsing and resource-exhaustion fixes), `cryptography` 48.0.0 → 49.0.0 (GHSA-537c-gmf6-5ccf), and `PyJWT` 2.12.1 → 2.13.0 (five PYSEC-2026 advisories in the token-verification path). `dbus-fast` bumped 5.0.3 → 5.0.22, carrying further D-Bus message-parser hardening, and `pillow` 12.2.0 → 12.3.0 (five advisories). The CVE audit reports a clean tree. ([#333](https://github.com/trudenboy/sendspin-bt-bridge/pull/333), [#336](https://github.com/trudenboy/sendspin-bt-bridge/pull/336)) - **The web-UI password can no longer be bypassed on standalone (non-Home-Assistant) installs.** The automatic sign-in used for Home Assistant's ingress is now honored only when the bridge actually runs as a Home Assistant add-on. On plain Docker / LXC deployments a local process on the host can no longer present an ingress marker to skip the login prompt. - **A local process on a standalone host can no longer mint a full-access API token.** The one-shot Home Assistant pairing endpoint now operates only when running as a Home Assistant add-on, matching where legitimate pairing actually happens. - **Revoking an API token now requires the same anti-forgery token as creating one**, so a malicious web page can no longer silently delete your tokens. - **The MQTT "Test connection" button no longer sends your saved broker password to a different broker.** When the password field keeps its masked placeholder, the saved password is reused only if the host and port match the saved broker; testing against any other broker requires entering the password explicitly. - **Outgoing event webhooks are re-checked at delivery time, not just when they are added.** Delivery now verifies the address actually connected to, blocking a webhook host that later resolves to a loopback, private-network, or cloud-metadata address (DNS-rebinding). - **Server-side link checks now correctly handle IPv4-mapped IPv6 addresses**, closing a path that could have reached loopback or cloud-metadata endpoints through a crafted address. - **The bug-report submission rate limit can no longer be bypassed by spoofing a forwarding header** — the client address is trusted from a forwarding header only when the request arrives through a trusted proxy. - **Authentication and token exchange responses are no longer written to debug logs**, so a long-lived token or authorization code can't leak into captured log output. ## [2.71.2] - 2026-05-29 ### Changed - **Connect-failure log lines now carry the underlying BlueZ error.** When a configured speaker is paired/bonded/trusted but won't connect, the bridge used to log only `Failed to connect (not connected after 5 status checks)` and discard `bluetoothctl`'s stdout — hiding the actual reason (`br-connection-page-timeout`, `br-connection-already-active`, `Profile unavailable`, link-key mismatch, …) behind a generic warning. The failure warning now includes a single-line excerpt of the connect output, so the operator can distinguish "speaker is off / out of range" from "speaker is already paired with another host" without a follow-up bug report. ([#302](https://github.com/trudenboy/sendspin-bt-bridge/issues/302)) - **Dependency bumps**: `aiosendspin` 5.2.0 → 5.3.0, `dbus-fast` 4.0.4 → 5.0.3, `zeroconf` 0.148.0 → 0.149.16, `certifi` 2026.4.22 → 2026.5.20, and the `ruff` dev pin 0.15.12 → 0.15.13. The `dbus-fast` 5.x series adds denial-of-service hardening to its D-Bus message parser (container nesting and message-size bounds); its only behavioural change defers the socket connect from construction to `connect()`, which the bridge already calls, so no code changes were required. ### Fixed - **Battery-powered Bluetooth speakers: audio configuration is now triggered immediately when the speaker auto-reconnects.** Previously, when a configured speaker had been off long enough for the reconnect backoff to saturate at five minutes, the bridge would still wait out the entire remaining backoff before noticing the speaker had come back via `PropertiesChanged: Connected`, then take another `bt_check_interval` seconds to schedule the next attempt. The visible symptom was "I turn the speaker on, but it's three to five minutes before I can play anything." The failed-reconnect backoff sleep is now interrupted by an external connect event, so audio routing is configured within a couple of seconds of the speaker rejoining the bus. ([#312](https://github.com/trudenboy/sendspin-bt-bridge/issues/312)) - **Proxmox LXC: fail-loud at startup if an incompatible `sendspin` package is installed.** When `deployment/lxc/upgrade.sh` couldn't replace an older `sendspin` — for example 7.0.0 left over from a 2.57.x→2.66.x install — the bridge would start with a stale dependency whose daemon entry-point still takes an extra argument that the bridge dropped in v2.69.0. The daemon then crashed every 5 s in a tight restart loop with an obscure `TypeError`, with no hint that the actual problem was a pip-install that silently failed during the previous upgrade. The bridge now resolves the installed `sendspin` version at startup and refuses to start, with a single-line remediation command pointing at `pip3 install --break-system-packages -U "sendspin>=7.3.0,<8.0.0"`, when it falls outside the supported range. ([#324](https://github.com/trudenboy/sendspin-bt-bridge/issues/324)) - **Proxmox LXC `upgrade.sh` no longer silently swallows `pip install` failures.** The four `pip3 install` calls in the dependency-update and editable-install steps previously chained `2>/dev/null || true`, so a transient PyPI hiccup or a constraint conflict against `--break-system-packages` would leave stale dependencies on disk and the script would report success. Each call now surfaces its exit code via `die`, with a message that names which step failed — root cause of the silent path that led to [#324](https://github.com/trudenboy/sendspin-bt-bridge/issues/324). - **Ubuntu 26.04 / WirePlumber: Bluetooth audio sinks published with a raw colon-separated MAC are now discovered.** WirePlumber's newer naming convention publishes BT outputs as `bluez_output.XX:XX:XX:XX:XX:XX` (raw MAC, no `.1` / `.a2dp-sink` suffix). The bridge previously tried four PipeWire/PulseAudio patterns based on the underscore-substituted MAC and missed this variant, falling back to the default audio device with no audible cue. The pattern is now included in the discovery list, last so the more specific PipeWire/PulseAudio shapes still win when both forms exist. ([#314](https://github.com/trudenboy/sendspin-bt-bridge/issues/314)) - **BlueZ 5.82 + PipeWire: card-profile auto-switch and cycle helpers no longer false-warn on already-correct cards.** PipeWire publishes the A2DP sink profile as `a2dp-sink` (with a dash) while classic PulseAudio uses `a2dp_sink` (underscore); both are accepted by `pactl set-card-profile`. The bridge's profile auto-switch was hard-coded to the underscore form, so on PipeWire hosts it logged `BlueZ card … has no a2dp_sink profile available` even when the card already exposed `a2dp-sink` and was active on it. Both spellings are now treated as equivalent, and whichever variant the card advertises is the one passed through to pactl. ([#314](https://github.com/trudenboy/sendspin-bt-bridge/issues/314)) ## [2.71.1] - 2026-05-15 ### Added - **New documentation page: Bluetooth Speakers** — consolidates field datapoints on the speakers and headphones that have been driven through the bridge. Three tiers (confirmed working / working with caveats / documented quirks), every row linked back to the originating issue or to the production-fleet inventory. Initial roster covers IKEA ENEBY / VAPPEBY / Kallsup, Yandex Mini 2, Lenco LS-500, Sony WH-1000XM4 (BlueZ ≥ 5.82 caveat), Samsung Q910B (CoD `0x00010c` workaround), Synergy S65 (BlueZ 5.85 floor), JBL PartyBox Encore 2 (solo-only), HMDX Jam, EDIFIER B3, Anker Soundcore 2/3, Xiaomi Mi, Sony STR-DN1080, HK Onyx Studio 3 quirk. Mirrored in EN + RU and added to the sidebar. ([#166](https://github.com/trudenboy/sendspin-bt-bridge/issues/166), [#172](https://github.com/trudenboy/sendspin-bt-bridge/issues/172), [#191](https://github.com/trudenboy/sendspin-bt-bridge/issues/191), [#210](https://github.com/trudenboy/sendspin-bt-bridge/issues/210), [#213](https://github.com/trudenboy/sendspin-bt-bridge/issues/213), [#269](https://github.com/trudenboy/sendspin-bt-bridge/issues/269), [#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) ### Changed - **Documentation: x86_64 CPU baseline (`x86-64-v2` / SSE4.2 + POPCNT) is now spelled out** on the Docker, LXC and HA addon installation pages, and there is a dedicated **CPU-baseline crash (SIGILL / signal=4)** section in the troubleshooting guide with a one-line `grep /proc/cpuinfo` check and resolution paths for bare-metal, QEMU/KVM, and Docker `--platform` cases. The bundled audio path (PyAV / ffmpeg / NumPy) has been on this baseline since the v2.50.x NumPy 2.x bump, but the requirement was only mentioned in journey-log entries until now; v2.71.0's daemon-exit observability finally surfaces the underlying `SIGILL` instead of presenting it as a silent ~10 s restart loop. Mirrored in EN + RU. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Bluetooth Adapters docs: community-tested adapter block added** with four Amazon ASINs verified by the field ([#295](https://github.com/trudenboy/sendspin-bt-bridge/issues/295)) — `B08R8992YC` (UGREEN 80889) and `B09C25VRXD` (TP-Link UB500 Nano UK) as recommended buys; `B0DHJHMHFS` (TP-Link UB500 **Plus**, external antenna) and `B0CP5WQ7L8` (ZEXMTE 180 m long-range) added to the avoid list with the pattern documented: long-range / high-gain-antenna BT 5.3 variants currently underperform for A2DP streaming compared to plain RTL8761B BT 5.0 nano sticks. aptX / Snapdragon Sound transmitters such as the Creative BT-W6 are now called out in the avoid list since the bridge streams strictly via A2DP/SBC. Mirrored in EN + RU. ### Fixed - **Proxmox LXC update path now works again, both from the web UI button and from the standalone `bash <(curl …)` runner.** The same April 29 deployment reorg that broke fresh-install (fixed in v2.71.0) also broke updates: the in-app resolver still looked for `upgrade.sh` at `/opt/sendspin-client/lxc/` instead of `/opt/sendspin-client/deployment/lxc/`, so the **Update now** button responded `upgrade.sh not found` on every install made after the reorg; the runner script's own `install_systemd_units` step copied two systemd unit files from a `lxc/` subdirectory of the staged tree that no longer exists, dying with `cp: cannot stat '…/app-stage/lxc/pulseaudio-system.service'`; and the script's chicken-and-egg self-update fetch (designed to pull a fresh `upgrade.sh` before running, so a bug in the current upgrader can be fixed remotely) was hitting a 404 against `/lxc/upgrade.sh` on `raw.githubusercontent.com`. All three pointers now reference `deployment/lxc/`; the in-app resolver keeps the legacy `/opt/sendspin-client/lxc/` location as a fallback so pre-reorg installs can still update. ([#309](https://github.com/trudenboy/sendspin-bt-bridge/issues/309)) - **Proxmox LXC `upgrade.sh` no longer leaves the service in a `No module named sendspin_bridge` restart loop** when the swap completes successfully. The editable bridge package (`pip install -e`) was being registered against the temporary `mktemp -d` staging directory **before** the staged tree was moved to `/opt/sendspin-client`; once the script exited, the `trap cleanup EXIT` deleted the staging directory, leaving the `.pth` in `site-packages` pointing at a path that no longer existed. Rollback couldn't recover either, because it didn't re-register the editable install for the restored old tree (the `.pth` had already been clobbered during the upgrade attempt). The editable install now runs **after** the `mv` swap, against the final `/opt/sendspin-client` path, and the rollback path does the same against the restored backup. Reported by @Pauld-1 in #309. - **Proxmox LXC `upgrade.sh` self-update no longer corrupts process-substitution invocations.** When the script was launched as `bash <(curl -fsSL …/deployment/lxc/upgrade.sh)` — exactly the form the README and the in-bridge update guidance recommend — `${BASH_SOURCE[0]}` is `/dev/fd/N` (a pipe), so the self-update step's `cp ${new} ${BASH_SOURCE[0]}` silently failed against the read-only pipe and the follow-up `exec bash ${BASH_SOURCE[0]}` re-read the partially-consumed pipe, producing a `syntax error near unexpected token '('` somewhere in the middle of the script rather than running the upgrade. The self-update step now early-returns when `${BASH_SOURCE[0]}` is not a regular file — the curl invocation that fed bash already fetched the latest copy, so the in-script self-update is redundant in that case anyway. Reported by @Pauld-1 in #309. ## [2.71.0] - 2026-05-15 ### Added - **Strict validation for the `SENDSPIN_SERVER` config field.** Values with a scheme prefix (`http://`), embedded port (`:8095`), path/slash, or whitespace are now rejected with a clear, field-specific error — both in the web-UI form (inline message + native `pattern` check) and via `POST /api/config`. A startup pre-flight gate in the bridge also refuses to spawn the daemon when a raw config edit slipped a malformed value past the form, populating the device's banner with the same actionable message instead of looping silently. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Daemon-exit events now surface exit code, signal, lifetime, and a tail of the daemon output** in the parent log and on the device card. The same data is recorded into a per-device spawn-history ring that the diagnostics report renders in the new **`--- SENDSPIN CONNECTION ---`** block, with resolved target URL, reachability probe result, and the last 5 spawn cycles annotated as expected/unexpected. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Automatic detection of repeating-interval daemon exits.** When the last three unexpected daemon exits land within ±1 s of each other (the connection-handshake-timeout signature behind the silent-10s-loop class of bug), the bridge raises a specific guidance banner — "Sendspin daemon exits every ~Ns" — instead of cycling restart-loop warnings forever. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Test connection button on the Music Assistant settings card.** Probes the configured host/port (or the value currently in the form) and reports OK, port-shift warning, malformed-config error, or unreachable — without persisting anything. Backed by `POST /api/sendspin/test`. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Two new operator-guidance issues**: one fires when the SENDSPIN_SERVER value is malformed (config error, caught at validation or pre-flight); the other fires when the recurring-interval daemon-exit pattern is detected (runtime). Both carry targeted remediation actions to the matching settings card. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Raspberry Pi installer auto-enables `loginctl enable-linger`** for the audio user when PipeWire is detected, so the user systemd manager (which owns WirePlumber and the `bluez_*` sinks) survives logout and reboots without an interactive session. Previously the install completed cleanly but the bridge lost its audio sinks on next reboot until the user logged in once. - **Raspberry Pi preflight check now flags missing `libspa-0.2-bluetooth`** when PipeWire is the active audio system. The diagnostic surfaces a single-line install command instead of leaving the operator to deduce it from "speaker pairs but no audio sink appears" symptoms. ### Changed - **Default Sendspin port is now `8927`** to match Music Assistant's actual upstream Sendspin provider. Configs that explicitly pin the legacy `9000` keep working — the port probe now runs on every daemon spawn, dials the configured port first, and auto-shifts to `8927` when the configured port is closed. The HA addon description has been corrected to stop claiming `9000` "matches the MA default". ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Troubleshooting docs now document the BlueZ version requirement for the v2.70.0 AVDTP-collision fix.** The fix decides whether to skip the A2DP stabilization delay by reading `org.bluez.MediaTransport1.State` after a Bluetooth reconnect — that is only reliable on BlueZ ≥ 5.79. On Raspberry Pi OS Bookworm (BlueZ 5.66) the transport state returns `idle` prematurely during AVDTP setup, so the bridge takes the fast-path anyway and the Sony WH-1000XM4 reconnect storm continues. The new section in the English and Russian troubleshooting pages explains the symptom, the version split, and the recommended upgrade path (Debian Trixie / fresh Raspberry Pi OS 12 / HAOS 17.1+ all ship BlueZ ≥ 5.82). Community-verified by @arisonpl. ([#269](https://github.com/trudenboy/sendspin-bt-bridge/issues/269)) - **Proxmox VM installer apt list now includes `libspa-0.2-bluetooth`** so PipeWire on the freshly-provisioned VM has a Bluetooth audio backend out of the box — Ubuntu 24.04 ships PipeWire as the default audio system but doesn't pull this plugin in by default. - **Headless-PipeWire docs now start with the `libspa-0.2-bluetooth` install step** before the linger / `with-logind` workarounds; whichever fix you needed before, the package install was a silent prerequisite. Mirrored in EN + RU Docker installation and troubleshooting pages. - **`sendspin` upstream bumped 7.1.0 → 7.3.1.** Pulls in upstream fixes that affect this bridge directly: the server-command listener is no longer lost after a daemon reconnect, `sendspin serve` no longer crashes on Python 3.12, the audio-sync delta is now correct after a server-driven delay change, mid-stream joins no longer trigger an unwanted catch-up burst, and the ALSA backend now closes its device cleanly. Also includes PulseAudio/PipeWire integration improvements and brings `stream/clear` / `stream/end` back into spec compliance with the role-version fields removed. - **Other dependency bumps**: `cryptography` 47.0.0 → 48.0.0, `requests` 2.33.1 → 2.34.1, `idna` 3.13 → 3.15, `propcache` 0.4.1 → 0.5.2, and the `astral-sh/uv` Docker base image 0.11.11 → 0.11.14. - **"Base player listen port" hint in the Settings UI shortened** so it fits on a single line at standard widths instead of wrapping. ### Fixed - **The bridge no longer silently constructs malformed WebSocket URLs** such as `ws://http://host:port:port/sendspin` when `SENDSPIN_SERVER` is set to a full URL by mistake. The Sendspin daemon used to exit at the websockets open_timeout (~10 s) with no Python traceback, no exit-code log, and no `last_error` on the device card, leaving operators no signal to act on — debug threads on this could take many round-trips. The bridge now fails fast with a clear, field-specific error before spawning the daemon. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) - **Proxmox LXC fresh-install one-liner now works again.** The April 29 reorganization moved deployment scripts from `lxc/` into `deployment/lxc/` but missed updating the internal download URL inside the Proxmox helper script and the file-copy paths inside the LXC installer itself; the four supporting files (PulseAudio configs and the two systemd units) were silently `cp`-failed and the installer aborted before reaching the first runnable bridge. The installer scripts, the Proxmox helper, the LXC quick-install docs (en + ru) and the deployment README all now point at the canonical `deployment/lxc/` path, so `bash <(curl -fsSL .../proxmox-create.sh)` completes end-to-end. - **LXC installer missing build dependencies for `dbus-python`.** `dbus-python` has no binary wheel on PyPI and pip therefore builds it from source; the build needs `pkg-config`, `libdbus-1-dev` and `libglib2.0-dev` which the installer's `apt-get install` list didn't include, so the install failed at the `pip install -r requirements.txt` step with a `meson` "Did not find pkg-config" error. The three packages have been added to the installer's system-package list. - **LXC installer: bulk pip install switched to `--ignore-installed`** instead of patching a per-package allow-list every time a new Debian-shipped Python package without a `RECORD` file shows up. The whole "Cannot uninstall X, RECORD not found" class of bug (seen on `typing-extensions`, `blinker`, `requests`, `Pygments`, `rich`) now resolves once and for all — pip installs bridge dependencies into `/usr/local/lib/.../site-packages/` over the Debian originals without touching them. ## [2.70.1] - 2026-05-12 ### Changed - **Documentation: the `static_delay_ms` per-device setting now reads as the Sendspin protocol actually implements it.** The previous wording described it as "additional forward delay" added on top of the bridge's own latency compensation, with guidance to "raise the value for speakers that consistently play *ahead* of the rest of a group". The Sendspin client actually does the opposite — it *subtracts* `static_delay_ms` from each chunk's play-time, so audio is emitted to the kernel earlier by exactly that amount, and the speaker's hardware latency then adds it back. The right framing is "declares how much extra latency this speaker's hardware adds beyond the audio sink"; raise it for speakers that play *behind* the group (their real hardware latency exceeds what the current setting compensates), lower it for those that play ahead. Affects [Per-device fine-tuning](/devices/#per-device-fine-tuning), the MassDroid measurement workflow, and the configuration reference. ([#284](https://github.com/trudenboy/sendspin-bt-bridge/issues/284)) ### Fixed - **A Sendspin daemon WebSocket failure that has already recovered no longer rings as a current concern in *Recent Errors*.** When the bridge addon started a few seconds ahead of Music Assistant's Sendspin server, the daemon's connection watchdog wrote `Cannot connect to Sendspin server at ws://…/sendspin. Check that SENDSPIN_PORT matches your Music Assistant Sendspin port.` to the device's last error, then cleared it once the next attempt succeeded. The original `runtime-error` event, however, stayed in the per-device recent-events ring buffer for the whole uptime — so the bug-report excerpt and the recovery timeline kept presenting a long-since-resolved boot race as an active failure, sending operators to chase a non-existent port mismatch. The bridge now emits a follow-up `runtime-error-cleared` event when the underlying condition resolves, the recovery timeline downgrades the earlier error to an info-level "(recovered)" annotation, and the bug-report excerpt no longer surfaces it as an error. ([#296](https://github.com/trudenboy/sendspin-bt-bridge/issues/296)) - **Raspberry Pi install guide no longer 404s on the quick-start one-liner.** The English and Russian Raspberry Pi installation pages, and the installer script's own self-documentation comment, all pointed `curl …` at a `scripts/` directory that hasn't existed since the installer moved under `deployment/raspberry-pi/`. The one-liner therefore piped a GitHub 404 page into `bash` and failed in confusing ways — typically users reported that the script "couldn't install Docker." All three pointers now reference the actual path. ([#283](https://github.com/trudenboy/sendspin-bt-bridge/discussions/283)) - **Raspberry Pi one-liner installer now writes the initial bridge configuration to the directory the container actually reads.** The canonical compose file binds `/etc/docker/Sendspin` into the container as the config directory, but the installer was writing its generated configuration under `~/sendspin-bt-bridge/config/` instead — so any pre-populated speakers, server, or timezone were silently ignored and the first start ran with an empty configuration. The installer now provisions `/etc/docker/Sendspin` with the correct ownership before writing the initial configuration, and the bridge's "Config write: ✓ writable" startup banner is satisfied out of the box. ([#283](https://github.com/trudenboy/sendspin-bt-bridge/discussions/283)) - **Raspberry Pi one-liner installer no longer fails the very first `docker compose pull` when it just installed Docker itself.** Right after `get.docker.com` + `usermod -aG docker`, the new `docker` group is not yet active in the running shell, so the follow-up compose call hit `permission denied on /var/run/docker.sock` and the user concluded that Docker installation itself had failed. The installer now detects this case and either falls back to `sudo` for the remaining docker steps or aborts with an explicit "log out and back in, then re-run" message. ([#283](https://github.com/trudenboy/sendspin-bt-bridge/discussions/283)) - **Raspberry Pi pre-flight check no longer prints invalid JSON in its recommended configuration block.** The previous output left a trailing comma after the last `BLUETOOTH_DEVICES` entry and embedded a `# Add speakers via web UI` hint inside the JSON document, so users who copy-pasted the recommendation got a JSON parse error. The block now emits valid JSON; the hint is printed after the JSON document. - **Raspberry Pi `.env` generated by the installer now includes `AUDIO_GID`** alongside `AUDIO_UID`. Without it, hosts whose audio user is in a non-default group fell back to GID 1000, which could lock the bridge process out of the user-scoped audio socket. The pre-flight check script already recommended both; the installer is now consistent with it. - **Manual Raspberry Pi install steps now include the BlueZ prerequisite.** On a fresh Raspberry Pi OS Lite image (Trixie in particular) `bluez`, `bluez-tools` and `rfkill` are not present by default and Bluetooth is soft-blocked, which manifested later as "no controller detected" inside the container. The manual install path now installs the BlueZ stack and runs `rfkill unblock` + `systemctl enable --now bluetooth` before the Compose step. The one-liner installer already handled this; only the manual path was missing it. ([#283](https://github.com/trudenboy/sendspin-bt-bridge/discussions/283)) ### Security - **urllib3 bumped from 2.6.3 to 2.7.0** to clear CVE-2026-44431 and CVE-2026-44432, both surfaced by `pip-audit` in CI on 2026-05-12. urllib3 is an indirect dependency pulled in via `requests`/`aiohttp`; no code changes were required on our side. ## [2.70.0] - 2026-05-11 ### Added - **Recovery banner distinguishes "never paired" from "paired but offline"** so the remediation hint is correct out of the box: a device that has never appeared in BlueZ now shows a dedicated card with a Start pairing primary action instead of the generic "needs re-pairing" / "is disconnected" text. ([#260](https://github.com/trudenboy/sendspin-bt-bridge/issues/260)) - **Start pairing button on device cards for never-paired devices.** Visible only when the bridge has flagged the device as never-paired in this session; deep-links to the Bluetooth scan modal with the device MAC pre-highlighted in the paired list. The Reconnect button on the same card is disabled in this state because reconnect is futile until pairing succeeds. ([#261](https://github.com/trudenboy/sendspin-bt-bridge/issues/261)) - **Pre-submit "likely causes" hint in the bug-report modal.** When diagnostics match a known pattern — never-paired device, audio sink missing, Music Assistant not connected, or no Bluetooth adapter detected — the bridge surfaces a hint block above the form with a one-click remediation link. A "Report anyway" dismiss keeps genuine bug paths unblocked. The classifier runs locally on the bridge, so it works offline before the bridge can reach GitHub. ([#262](https://github.com/trudenboy/sendspin-bt-bridge/issues/262)) - **Never-paired devices are now auto-disabled** after the bridge exhausts `BT_MAX_RECONNECT_FAILS` attempts on a configured MAC that BlueZ has no record of. The flip persists to `config.json` (and to the HA addon `options.json`) so the reconnect storm doesn't resume across bridge restarts. The recovery banner surfaces a Re-enable action that clears the state and lets the operator retry pairing. ([#263](https://github.com/trudenboy/sendspin-bt-bridge/issues/263)) ### Changed - **`BT_MAX_RECONNECT_FAILS` default changed from 0 (unlimited) to 5.** Existing configs that still carry the legacy 0 are migrated to 5 automatically on first load so the new never-paired auto-disable feature engages without an explicit operator action. Operators who want unlimited reconnects can set the value back to 0 after the upgrade — the migration is one-shot. ([#263](https://github.com/trudenboy/sendspin-bt-bridge/issues/263)) - Documentation: troubleshooting guide now covers audio stuttering from WiFi+Bluetooth co-existence on hosts with an integrated combo radio such as the Intel NUC N100, with an `rfkill block wifi` workaround for Ethernet-wired hosts. ([#287](https://github.com/trudenboy/sendspin-bt-bridge/pull/287)) - Documentation: PipeWire installation guide now explains that WirePlumber's `with-logind` integration also causes Bluetooth to drop on SSH/desktop logout (in addition to the 10-second endpoint churn already documented), and routes both symptoms to the existing per-user override. ([#286](https://github.com/trudenboy/sendspin-bt-bridge/pull/286)) ### Fixed - **MQTT discovery for per-device configuration controls now works on Home Assistant 2026.5+.** The bridge was publishing a non-standard `entity_category` value (`sendspin_bridge.config`) for the eight settings entities — `Enabled`, `BT management`, `Standby`, `Power save`, `Idle mode`, `Keep-alive method`, `Static delay`, `Power save delay`. Earlier Home Assistant releases silently dropped the value; 2026.5 strictly validates against the `EntityCategory` enum and rejected each discovery message with `expected EntityCategory or one of 'config', 'diagnostic'`, breaking those entities and flooding the HA core log with one error per entity every 30 seconds. The bridge now ships the standard `config` category, which also groups those controls under the *Configuration* section on the HA device card. - **AVDTP-collision reconnect loop on Sony WH-1000XM4 and similar A2DP sinks no longer pins reconnect attempts in a permanent loop on PipeWire stacks.** When the cached sink name was still valid after a Bluetooth reconnect, the bridge took a fast-path that skipped the 3-second A2DP stabilization delay even though BlueZ's underlying AVDTP transport hadn't finished settling. With certain peers the anti-pop sink mute then raced with the peer's own AVDTP-Suspend, producing a cluster of `cancel_request() Start: Operation canceled` and `SEP in bad state` errors in `bluetoothd` and an immediate disconnect roughly fourteen seconds after each reconnect. The bridge now queries `org.bluez.MediaTransport1.State` before taking the fast-path and only does so when the transport is `idle` or unknown; an `active` transport falls back to the regular delayed-discovery path. ([#269](https://github.com/trudenboy/sendspin-bt-bridge/issues/269)) - **The anti-pop sink mute applied during daemon startup is now deferred until the Bluetooth peer has opened (or is opening) its A2DP stream endpoint.** Sony WH-1000XM4 and a few other A2DP sinks treat silence on a half-formed transport as "no inbound stream" and proactively send AVDTP-Suspend; if Music Assistant subsequently starts streaming, PipeWire's AVDTP-Start collides with that Suspend. The daemon now polls `MediaTransport1.State` for up to 1.5 seconds and only mutes when state is `pending` or `active`, with a safety fallback that mutes anyway on timeout so anti-pop coverage isn't lost on adapters where transport state cannot be introspected. ([#269](https://github.com/trudenboy/sendspin-bt-bridge/issues/269)) - **The startup unmute watchdog no longer burns eight seconds on doomed retries after the Bluetooth sink has already been torn down.** Previously, every failed reconnect produced four `aset_sink_mute: sink ... not found` warnings spaced two seconds apart. The watchdog now probes the sink list once after the first failure and short-circuits the retry loop when the sink has disappeared; transient failures with the sink still present keep the existing three-retry behavior. ([#269](https://github.com/trudenboy/sendspin-bt-bridge/issues/269)) - **RSSI refresh task no longer dies on permission-constrained sandboxes.** On Proxmox LXC (or any environment without `CAP_NET_RAW`) the kernel denies the `AF_BLUETOOTH` raw-socket bind and the underlying `btsocket` library raises `BluetoothSocketError("Unable to open PF_BLUETOOTH socket")` — a class that inherits from `BaseException` rather than `Exception`, so the existing safety net let it slip through and kill the per-device RSSI refresh task on its very first tick. The signal-strength chip then stopped updating for the lifetime of the bridge process. The wrapper now degrades silently for every failure mode of the socket open while still propagating cooperative-shutdown signals (task cancellation, `KeyboardInterrupt`, `SystemExit`, `GeneratorExit`). Audio playback was unaffected; this purely restores the RSSI badge on LXC. ([#291](https://github.com/trudenboy/sendspin-bt-bridge/issues/291)) ## [2.69.0] - 2026-05-08 ### Changed - **Bumped the bundled Sendspin audio engine from 7.1.0 to 7.3.0.** Pulls in upstream PulseAudio / PipeWire integration improvements and better audio device discovery (7.2.0), plus two playback-stability fixes that show up regularly in this bridge's flows: mid-stream joins (after a Bluetooth reconnect or a hot config save that triggers a speaker restart) no longer audibly catch up, and changing the per-player sync delay from Music Assistant no longer produces a brief audio glitch from a wrong sync delta. No configuration changes required. ### Fixed - **Speakers no longer crash on every connect after the 2.69.0-rc.3 release.** A signature drift between the bundled Sendspin 7.3.0 audio engine and the bridge's WebSocket-heartbeat override produced a `TypeError` the moment the daemon tried to start its server-initiated listener, so the speaker subprocess died, restarted in 30 s, died again, and never got past the connect step. The override now matches the new upstream signature and a regression test locks the contract so the next upstream bump fails fast in CI instead of in production. - **HA addon: "Release Bluetooth" toggle now survives an addon or Home Assistant restart.** Previously the released state was forgotten on restart, the bridge silently re-grabbed the BT device, and only released it again after the configured idle timeout — which interrupted whatever else was using the speaker (for example a TV soundbar over HDMI/ARC). The released flag now carries forward in the addon's options-to-config rebuild so reclaiming requires an explicit user action. ([#276](https://github.com/trudenboy/sendspin-bt-bridge/issues/276)) - **Hot-apply settings now stay consistent with the speaker process.** When a settings save couldn't reach a speaker's audio process (process exiting, broken pipe), the bridge previously updated its own copy of the value anyway, so the UI showed a value the speaker had never received. The save path now writes the command to the speaker process first and only commits the bridge-side value after that write succeeds — failures are surfaced as an error in the save summary instead of a silent split-brain. - **Changing the log level in the main settings form now switches the bridge process logs immediately.** Previously only the speaker subprocesses picked up the new level, while the bridge itself stayed on the old level until the next restart. The dedicated log-level toggle on the dashboard already worked correctly; the main settings form is now consistent with it. - **Hot-apply settings that don't ack within the response window are no longer flagged as errors.** A busy audio loop (HAOS-VM, multi-speaker churn) could push the IPC ack past the 500 ms cap and the UI would show a red "Errors" banner even though the change later landed. The cap is raised to 1.5 s to absorb realistic latency and slow acks now appear in a neutral "Pending live" group with a wait icon. - **Saving a global setting no longer triggers a simultaneous reconnect storm on every speaker.** When a global field changed, all active speakers had their audio process restarted in parallel; on multi-speaker setups this overwhelmed BlueZ/PulseAudio and produced spurious disconnects. Restarts are now spaced 300 ms apart between starts so the cascade is absorbed smoothly without serializing total wall time. - **Saving the MQTT password concurrently from two browser sessions can no longer lose the new value.** The save path was reading the existing config twice — once before locking, once under the lock — and a parallel save slipping in between could be silently overwritten. The existing config is now read exactly once under the lock and shared by every consumer in the save flow. ## [2.68.0] - 2026-05-02 ### Added - **Music Assistant can now set the per-player sync delay (0–5000 ms) for bridged Bluetooth speakers directly from the player settings panel.** Until now the bridge persisted a per-device static delay internally (configurable via the bridge Web UI), but never advertised the capability to MA — so the "Static playback delay (ms)" slider never appeared next to the bridged BT player. The bridge now advertises `set_static_delay` via `client/state`, applies inbound changes from MA without restarting the audio stream, persists the new value to the device config so it survives a restart, and pushes Web-UI-driven changes back to MA so both UIs stay aligned. On HA addon restarts, the MA-driven delay is preserved across the options-to-config rebuild so the value isn't silently reset on every Supervisor reload. In the bridge Web UI, the per-device delay input tracks the last-applied baseline and refuses to overwrite a typed but unsaved edit when MA pushes a new value. Use it to compensate when a Bluetooth speaker plays audibly later than Sonos / AirPlay players in the same MA sync group: increase the delay on the *other* (faster) players to match the slowest BT one. ([#237](https://github.com/trudenboy/sendspin-bt-bridge/issues/237)) - Project support channels documented in README — Liberapay, Ko-fi, NOWPayments, and direct crypto (Lightning, BTC, USDT). Boosty kept on Russian-language surfaces only. - Documentation site now has a dedicated **Sponsor the project** page (English + Russian) with the full crypto address table, scannable QR codes (including Ko-fi profile QR), FAQ, and non-monetary contribution options. - Web UI header now includes a discreet **Sponsor** link next to Report / Docs / GitHub, opening the documentation site's Sponsor page in a new tab. ### Changed - **Each bridged Bluetooth speaker now shows its own model and manufacturer in Music Assistant's player card.** Previously every bridged speaker (ENEBY20, JBL Charge, WH-1000XM4, …) appeared as the same `Sendspin BT Bridge vX` from the bridge host — there was no way to tell them apart in MA without checking the player ID. The bridge now reads each speaker's BlueZ alias and Modalias at subprocess spawn and surfaces them as the per-player `model` / `manufacturer` in MA, with a curated vendor map covering common consumer brands (Sony, Bose, JBL/Harman, IKEA/Sonos, Apple, Samsung, Yandex, Beats, Skullcandy, Garmin, Logitech, …). Unknown vendor IDs fall back to the bridge host name (the prior behaviour, no regression). The `software_version` field now consistently carries the bridge release plus the underlying `aiosendspin` library version, so operators can correlate behaviour across players from a single glance at the MA UI. ## [2.67.2] - 2026-05-01 ### Fixed - **HA Configuration tab — connection-status banner now repaints immediately on mode switch.** Clicking a different transport radio (Off / MQTT / Direct REST) only updated the form layout; the green/yellow status banner kept showing the previous mode's text ("Connected via REST to " or similar) for up to 10 s until the next periodic poll, which several operators read as "save didn't take" or "mode never changed". The radio-change handler now (a) synthesises a "Configured for but not connected yet" / "Off — pick a transport below to connect." message in-place via ``_setHaStatus`` and (b) kicks an immediate ``_refreshHaIntegrationStatus`` so the banner converges to live publisher / mDNS state without the lag. The bare Save (no restart) submit handler does the same refresh post-save. ## [2.67.1] - 2026-05-01 ### Added - **HACS custom_component now ships its own brand icon.** Home Assistant 2026.3+ supports local brand images for custom integrations via a `brand/` directory inside the integration — bypassing the `home-assistant/brands` repo, which no longer accepts PRs for custom integrations. The icon (256×256 + 512×512) ships in `custom_components/sendspin_bridge/brand/` and appears automatically in the HA UI after a HACS update. Older HA versions render no icon (no functional change). - **HACS custom_component manifest now bumps automatically on each release.** The release workflow's version-sync step writes `VERSION` into `custom_components/sendspin_bridge/manifest.json` alongside the existing `sendspin_bridge.config` update. HACS reads the manifest version to decide whether to offer an update — without this sync the integration sat at v2.66.13 even after the bridge had shipped 2.67.0, so HACS users wouldn't see the update prompt. ### Changed - **HA Configuration tab — REST status card no longer shows MQTT fields.** In REST mode the Broker URL and Last connect rows are hidden — those belong to the MQTT publisher's lifecycle and have no meaning when the bridge is the HTTP server (HA pulls from us, not the other way round). - **HA Configuration tab — Access tokens (HACS only) card hidden in Off / MQTT modes.** Tokens are only consumed by the HACS REST path, so showing the card outside REST was misleading. Tokens persist across mode flips; revoke them inside REST mode. - **HA Configuration tab — friendlier hint right after HACS pairing.** When a token was issued in the last 15 min but hasn't been used yet, the inline transport hint now reads "Pairing detected. Finish setup in Home Assistant → Settings → Devices & Services if the bridge hasn't been added there yet." instead of the alarming "paired before but isn't currently active" — the latter still fires for tokens older than 15 min that have gone quiet. - **Onboarding banner now leads with the host-side fix when the Bluetooth daemon is down.** The bridge already knew bluetoothd was inactive (Service Status section of diagnostics), but the operator- facing onboarding step still suggested generic "Refresh adapters" / "verify passthrough" actions, sending people on a UI wild-goose chase for a problem they could only fix on the host. Preflight now probes `systemctl is-active bluetooth` when no controller surfaces; when the daemon comes back as `inactive` / `failed`, the onboarding step renames to "Bluetooth daemon (bluetoothd) is not running on the host" and the leading action is the actual fix: `sudo systemctl start bluetooth` (plus `enable` for persistence). - **Class of Device override warning text rewritten to clarify the failure isn't blocking adapter detection.** The original "CoD: hci0 Write_Class_Of_Device timed out or failed" looked like a hard blocker — operators on issue #254 misread it as the cause of their adapter-not-detected problem and went down a CoD-tweaking rabbit-hole. Reworded to "no Command Complete event from controller (controller may be unpowered or bluetoothd inactive — fix adapter access first; CoD override is independent)" so the symptom-vs-cause direction is obvious. The CoD picker tooltip in the bridge UI gained a matching note: "Won't help if the adapter itself isn't detected — fix Bluetooth daemon / passthrough first". ### Fixed - **Live demo deployment on Render broken since the v2.62 package move.** The Render service's start command still pointed at the long-removed legacy entry-point script at the repo root, and the build command pulled only a hand-picked subset of deps that was missing several pure-Python packages the bridge expects. The Render Blueprint manifest now sits at the repo root (where Render's discovery actually looks), installs from a curated demo requirements list — the runtime dependency set minus the Linux-BT / D-Bus packages that need apt-installable system headers Render's Native Python runtime can't provide — and starts the bridge with the demo simulator on the import path so the dashboard, scan list, and now-playing fixtures render again. - **Album artwork now renders when the daemon already has its own track metadata.** The artwork-fallback path was gated on ``!daemonHasTrack`` (a guard meant for ynison-style sourceplugin providers where MA's queue might disagree with what the daemon is actually playing) — but the daemon never ships its own ``artwork_url``, so the gate just left an empty placeholder whenever the daemon had a track name. Demo mode hit this every card. Artwork now falls back to MA's image whenever the device is MA-active, regardless of daemon-track presence; text fields keep the original "daemon takes priority" semantics. - **HA → Direct REST: auto-detected host no longer leaks `127.0.1.1`.** On Debian / Ubuntu the system hostname maps to a `127.0.1.1` loopback alias in `/etc/hosts`, so `gethostbyname(hostname)` returned that unreachable address as the auto-detected advertise host. Resolution now filters loopback results and falls back to the standard open-UDP-socket-and-read-`getsockname` trick to discover the LAN IP the kernel would actually use to reach the network. ## [2.67.0] - 2026-05-01 ### Added - **HA → Direct REST: auto-fill of advertised address.** The REST card now mirrors the MQTT card's address-handling flow: a Use auto-detect toggle disables the Bridge host / Bridge port inputs and surfaces the resolved values as a read-only preview; a "Suggest from hostname" button calls a new `GET /api/ha/rest/probe` endpoint and pre-fills the override fields with what the bridge would advertise right now. Use the override fields when the bridge sits behind a reverse proxy or NAT and the auto-detected hostname isn't routable from Home Assistant. Allow Supervisor pairing toggle is now hidden outside HA add-on mode — the Supervisor proxy isn't reachable in standalone Docker / LXC so the toggle had no effect there. - **HA → MQTT broker "Test connection" button.** A pre-flight check inside the HA Configuration tab calls a new `GET /api/ha/mqtt/test` endpoint, which does a TCP probe + full `aiomqtt` CONNACK round-trip against the values currently in the form (not the saved config) with a hard 15 s timeout. Surfaces unreachable-broker / auth-failure / TLS-mismatch errors inline so operators can iterate without saving partial configs and watching the publisher fail in the status panel. - **HA → MQTT publisher status detail in the connection card.** The status banner now renders broker URL, connected-since timestamp, and last error when present, sourced from the existing `/api/ha/mqtt/status` payload. - **HA → Direct REST advertised host/port overrides.** The REST card now exposes optional Bridge host / Bridge port fields, written into the mDNS SRV record at advertise time. Empty values keep the existing auto-detect behaviour. Use these when the bridge sits behind a reverse proxy or NAT and the auto-detected hostname is not reachable from Home Assistant. - **HA → Direct REST: HACS custom_component install indicator.** A new `GET /api/ha/custom_component/status` endpoint and an inline hint next to the Direct REST radio surface whether the HACS integration has paired with the bridge and whether it's currently active — mirrors the Mosquitto add-on indicator for the MQTT path. Detection is heuristic: presence of an issued bearer token implies the integration paired at least once; recent ``last_used`` implies it's currently connected. - **Class of Device dropdown — four documented presets.** The per-adapter CoD override widget now ships preset entries covering the speaker families that filter incoming connections by the initiator's class: Computer/Laptop (`0x00010c` — Samsung Q-series), Computer/generic (`0x000100` — broad fallback), A/V Loudspeaker (`0x000414` — LG-style filters), and A/V Headset (`0x240404` — Anker Soundcore family). Each value corresponds to a documented case where that exact CoD unblocked pairing. The custom-hex field is still available for values outside the list. - **Troubleshooting reference — Class of Device override preset table.** New section in the troubleshooting docs listing which speakers each preset is reported to fix, plus a secondary table of spec-valid but undocumented values for the custom-hex path. Each row links to the upstream bug tracker, ArchWiki entry, or forum thread that surfaced the value, so support tickets can reference the lineage of every override directly. ### Changed - **HA Configuration tab — UX overhaul.** Mode picker replaced with a three-option segmented control (Off / MQTT / Direct REST) so all transports are visible without opening a dropdown. MQTT form regrouped into Broker address (host + port + TLS), Authentication (user + password + Test button), and Advanced (discovery prefix collapsed under a `
` disclosure). TLS toggle moved next to the host with an automatic 8883 port nudge. The `auto` host plain-text trick replaced by an explicit "Use auto-detect" toggle. Mosquitto add-on running indicator collapsed into an inline hint next to the MQTT option (HAOS). Naming card moved to the bottom of the tab. Access tokens card hidden unless mode = Direct REST (HACS) or tokens already exist — they have no role in the MQTT setup flow. Password field no longer pre-fills with `***REDACTED***` — empty + placeholder hint instead, with the wire-protocol marker still preserved on save when the field is left untouched. Card titles renamed for plain English (Direct REST connection / Access tokens). ARIA `role="switch"` + `aria-checked` and explicit `