История архитектурной и функциональной эволюции sendspin-bt-bridge — для тех, кто разбирается в Home Assistant, Music Assistant и организации мультирум-аудио.
Период: 1 января — 17 марта 2026 г. · Всего коммитов: ~974 · Версии: 1.0.0 → 2.32.12
Релиз 2.32.12 — это узкий, но важный follow-up после 2.32.11. Предыдущий релиз осознанно ужесточил CI и release validation, но первые реальные прогоны показали ещё два оставшихся рассинхрона: один — между локальными pre-commit hooks и GitHub Actions lint path, второй — в фактическом составе release image при сборке против pinned-версии sendspin. Этот релиз закрывает оба разрыва, чтобы pipeline был не просто более строгим, но и внутренне согласованным.
В релизе важны две вещи:
- Lint теперь одинаково понимает, что считать корректным кодом — проект больше не опирается на source-level
noqa, который по-разному трактуется разными Ruff entrypoints. Вместо этого локальныйpre-commithook явно ослабляетUP038, что сохраняет runtime-safe синтаксисisinstance(..., (list, tuple))для локальных Python 3.9 окружений и одновременно делает CIruff checkчистым и предсказуемым. - Release images теперь действительно содержат тот runtime, который сами же и проверяют — non-
armv7Docker release path больше не ставит pinnedsendspinчерез--no-deps. В результате smoke-tested образы теперь реально содержатaiosendspin,avи остальную зависимостную graph-цепочку, необходимуюsendspinво время выполнения, вместо сценария «build прошёл, а контейнер падает только при запуске проверки».
Это именно тот тип релиза, который и должен быть небольшим: он не добавляет новую фичу, а убирает последние рассинхроны между локальными ожиданиями, CI validation и фактическим содержимым публикуемого runtime image.
Релиз 2.32.11 — это компактный follow-up после 2.32.10, но он закрывает очень практичный разрыв между состоянием «код уже готов» и состоянием «release pipeline действительно надёжен». После предыдущего релиза GitHub Actions подсветил несколько неявных предположений об окружении: локально всё проходило, а на hosted runners часть шагов падала из-за отсутствующих системных библиотек. Этот релиз делает такие зависимости явными и выравнивает поведение CI с реальными runtime-окружениями.
В релизе важны три вещи:
- Dashboard теперь по умолчанию открывается в наиболее полезном обзорном режиме — новые сессии стартуют в
list view, потому что он плотнее, проще для сканирования и лучше соответствует текущему сценарию использования dashboard как панели мониторинга. При этом пользовательский выбор не ломается: если layout уже сохранён вlocalStorage, он по-прежнему имеет приоритет. - Подготовка релиза теперь явно учитывает native-зависимости для D-Bus — workflow публикации Docker-образов перед определением packaged версии
sendspinставит системные D-Bus development packages, которые нужныdbus-python. Это убирает класс CI-only падений, когда release path ломался ещё до фактической сборки образа. - Smoke-check совместимости теперь запускается в окружении, ближе к реальному — lint/test workflow устанавливает PortAudio runtime library перед проверкой совместимости
sendspin, а Dockerfile переведён на hadolint-friendly ветвление черезelifв release-specific install path. В результате новые prerelease-гейты перестают быть «правильными только теоретически» и становятся устойчивыми на автоматике.
Это не релиз про большую фичу. Это релиз про более честные defaults и более честную доставку: UI по умолчанию показывает наиболее практичный обзор, а CI наконец получает те native-компоненты, которые ему реально нужны для проверки публикуемого пакета.
17 марта 2026 — Дисциплина релизов, более честная observability и финальная полировка playback UX (v2.32.10)
Релиз 2.32.10 собирает вместе несколько линий работы, которые на первый взгляд выглядят разными: часть изменений касается release-process, часть — диагностики и логов, а часть — небольших, но заметных правок dashboard UI. На практике это один и тот же тип улучшения: убрать двусмысленность и сделать bridge понятнее как для оператора, так и для обычного пользователя.
В релизе выделяются четыре темы:
- Создание релиза стало явной операцией, а не побочным эффектом tag push — GitHub release теперь выпускается отдельным manual workflow. Он по умолчанию берёт последний тег, но позволяет выбрать любой нужный тег вручную, генерирует накопительные release notes от предыдущего опубликованного релиза и обновляет
ha-addon/config.yamlтолько в момент самого релиза. Обычный push тега больше не меняет metadata add-on автоматически. - Совместимость зависимостей стала и видимой, и проверяемой заранее — bridge теперь публикует resolved versions критичных runtime-зависимостей в startup logs, diagnostics, bugreport и
/api/version, а CI и Docker release path получили реальный smoke-check против установленногоsendspin. Это заметно уменьшает шанс, что upstream drift снова дойдёт до пользователя как сюрприз. - Severity логов стала ближе к реальному operational impact — crash-like
stderrsubprocess больше не выглядит как обычное предупреждение. Runtime logging, bugreport summary, diagnostics и индикаторReport an Issueтеперь опираются на одну и ту же модель issue-worthy log lines и лучше отражают реальную серьёзность проблемы. - Playback UI получил ещё один проход на доверие к состоянию — filter toolbar теперь остаётся видимым даже при одном плеере, кнопки
shuffle/repeatвcard viewнаконец заметно показывают активное состояние, а режимrepeat oneиспользует цельную иконку с единицей внутри вместо внешнего бейджа. Это небольшие правки, но именно они делают transport-состояние мгновенно читаемым.
Это не релиз «про одну большую фичу». Это релиз про устранение тихих источников путаницы: путаницы в release ownership, в packaged dependency state, в серьёзности логов и в визуальной обратной связи playback-контролов. Обычно именно такие релизы лучше всего стареют.
Релиз 2.32.9 — это узкий, но важный follow-up hotfix после 2.32.8. Сама логика Music Assistant routing в bridge не ломалась, но Home Assistant add-on мог падать ещё до того, как поднимался хотя бы один player subprocess. Корневая причина находилась на границе упаковки зависимостей: наш launcher для daemon-процесса всё ещё предполагал, что sendspin.daemon.daemon.DaemonArgs принимает use_hardware_volume, тогда как в установленной версии sendspin в части HA add-on окружений этот keyword уже отсутствовал.
В релизе важны три вещи:
- Совместимость запуска вместо жёстких предположений о kwargs — daemon subprocess теперь формирует payload для
DaemonArgsзащитно: bridge фильтрует startup kwargs по фактической сигнатуре, которую предоставляет установленный пакетsendspin. Если в более старом или более новом build нет поля вродеuse_hardware_volume, bridge просто пропускает этот kwarg, а не падает во время старта. - Восстановление HA add-on после немедленного boot failure — это именно фикс boot-path. Он возвращает add-on возможность запускаться после обновления до
2.32.8в тех окружениях, где API-поверхность упакованногоsendspinуже разошлась с ожиданиями bridge. - Регрессионное покрытие на compatibility filtering — релиз добавляет тесты на фильтрацию kwargs, чтобы будущий drift API Sendspin с большей вероятностью ловился как точечное падение теста, а не как production-crash при запуске.
Это релиз того типа, который нужен для возвращения скучной надёжности: никаких новых UI-изменений, никакой новой transport-семантики — только более аккуратная граница совместимости между bridge и packaged daemon API, от которого он зависит.
17 марта 2026 — Восстановление MA transport для solo-player и более спокойный apply-state UX (v2.32.8)
Релиз 2.32.8 — это follow-up hotfix к недавней работе над Music Assistant transport-контролами, но по сути он чинит реальную эксплуатационную проблему, а не просто шлифует интерфейс. На уровне MA monitor команды и так доходили быстро — ack приходил за считанные миллисекунды, — но на живом Proxmox deployment transport-кнопки всё равно могли выглядеть сломанными. Корневая причина оказалась в drift идентичностей: dashboard, bridge-кэш и Music Assistant не всегда имели в виду один и тот же queue-object.
В релизе выделяются три темы:
- Маршрутизация в solo-player queue вместо повторного использования stale identity — bridge теперь различает локальный state key для dashboard/cache и реальный MA queue/player ID, в который нужно отправлять команду. Это критично для solo universal-player bridge-устройств, у которых UI-visible bridge player ID не совпадает с фактическим MA queue ID.
- Устойчивость к stale-вкладкам на живом deployment — если уже открытая страница после hotfix rollout продолжает слать устаревший MA target metadata, backend теперь умеет восстановиться и сам вывести корректный active solo player queue вместо слепого доверия stale syncgroup hint. На практике это значит, что transport-контролы восстанавливаются не только после hard refresh браузера, а и в более реалистичных live-сценариях.
- Более тихое поведение apply-state — queue-кнопки по-прежнему блокируются, пока команда pending, но временное состояние
applyбольше не даёт дополнительной визуальной подсветки. Теперь кнопка просто становится неактивной до завершения команды, из-за чего иcard, иlistview ощущаются спокойнее и чище.
Это релиз из тех, что по diff выглядят небольшими, а по эффекту — очень заметными: сам monitor-path уже был быстрым, но операторский опыт всё равно казался сломанным, потому что команды попадали не в тот target, либо stale runtime metadata переживали hotfix дольше, чем нужно. 2.32.8 закрывает этот разрыв за счёт более явной queue-routing логики, более backend-authoritative поведения и лучшей устойчивости к реальным условиям live deployment.
Релиз 2.32.7 переводит последнюю работу над Music Assistant-контролами из режима «UI выглядит быстрее» в режим «backend действительно стал надёжнее как источник истины». Главная тема здесь — authority: shuffle / repeat / queue-действия больше не должны зависеть от ad-hoc мутаций во фронтенде или от fresh per-request WebSocket, который конкурирует с долгоживущим monitor-подключением. Вместо этого bridge теперь считает backend-кэш и persistent MA monitor единственным источником истины и уже оттуда отдаёт в dashboard typed predicted-state.
В релизе выделяются четыре темы:
- Pending-state под управлением backend —
/api/ma/queue/cmdтеперь возвращает структурированный результат сop_id,syncgroup_id, pending-метаданными и backend-generated predicted snapshot. Благодаря этому UI может реагировать сразу, не изобретая собственную приватную версиюma_now_playing. - Monitor-first command flow — queue-команды MA теперь идут через persistent monitor connection как через основной hot path. Interleaved события
player_queue_updated/player_updatedбольше не теряются молча во время ожидания ack, а откладываются и reconcile-ятся сразу после подтверждения команды. - Сохранение состояния на reconnect — короткие disconnect MA monitor больше не стирают видимое playback-состояние. Bridge сохраняет последний confirmed snapshot, помечает его stale/disconnected и удерживает command/error metadata, чтобы dashboard оставался понятным во время переподключения.
- Честный armv7 release pipeline — отдельный armv7 Docker workflow больше не краснеет из-за GitHub Actions cache export, если сам образ уже был успешно собран и запушен. Cache-export теперь best-effort, а не ложный release gate.
Это релиз, у которого главный эффект — уменьшение двусмысленности: меньше split-brain между frontend и backend, меньше гонок между command ack и MA events, меньше потери видимого состояния при reconnect и меньше ложных CI-падений там, где image build на самом деле уже успешен.
Релиз 2.32.6 — небольшой follow-up по dashboard UI, но именно он доводит до ума несколько деталей, которые после крупных playback-редизайнов ещё выглядели не до конца согласованными. Главная тема здесь — консистентность: card mini-player, expanded row в списке и общий action bar дашборда должны ощущаться частями одного Music Assistant-подобного интерфейса, а не тремя соседними итерациями.
В релизе выделяются четыре темы:
- Более чистый playback-flow в карточке — индикатор прогресса в
card viewтеперь расположен под метаданными текущего трека, а не сбоку от них. Благодаря этому длинные названия и строка исполнителя получают больше горизонтального пространства, а сам блок читается в более естественном вертикальном порядке. - Более информативный expanded list — expanded row в списке теперь показывает text-only badge
Now playingнад активным треком, использует более крупную обложку и переводит equalizer в более медленный, Music Assistant-like ритм. В сумме это делает live-состояние заметнее и понятнее без лишнего декоративного шума. - Визуальный паритет bulk-действий —
Reconnect allиRelease allв toolbar теперь используют тот же action-button язык, что и per-device кнопкиReconnect/Release. Это убирает ещё одну небольшую, но заметную непоследовательность в представлении Bluetooth-действий. - Проверка на живом Proxmox deployment — UI-правки были подтверждены на реальном Proxmox target, включая отдельную cache-busting проверку, которая показала, что runtime действительно обновлён, а проблема была только в stale HTML.
Это не архитектурный headline-релиз, а именно релиз доводки. Но именно такие выпуски повышают доверие к интерфейсу: playback-иерархия читается легче, статусные affordance выражены яснее, а одни и те же действия теперь выглядят более последовательно по всему UI.
Релиз 2.32.5 — это follow-up после 2.32.2, но его главная ценность не только в косметике. Основная тема здесь — заставить playback-контролы в dashboard отображать реальное runtime-состояние, а не выглядеть доступными «по привычке». Параллельно expanded row в списке получил ещё один проход в сторону более компактного Music Assistant-style mini-player: меньше пустого горизонтального воздуха, понятнее queue-контекст и меньше ложных affordance, когда действие на самом деле выполнить нельзя.
В релизе выделяются три сквозные темы:
- Stateful transport-контролы — shuffle/repeat теперь обновляются в UI оптимистично, repeat визуально различает режимы
off/all/one, а transport/queue/mute/volume действия блокируются, если для них реально недоступен нужный Sendspin, Music Assistant или sink-path. Дополнительные guard-проверки в handlers закрывают stale-click/race-сценарии, а не оставляют защиту только на уровне disabled-кнопок. - Более плотная геометрия expanded-list playback — artwork и информация о текущем треке теперь собраны в один компактный левый блок, а previous/current/next playback-контекст вместе с progress вынесены во второй, тоже прижатый влево блок. Благодаря этому expanded row воспринимается как цельный mini-player, а не как два слабо связанных контейнера с зарезервированным пустым пространством между ними.
- Более читаемый queue-контекст — previous и next элементы очереди теперь показывают трек, исполнителя и альбом отдельными строками вместо одной склеенной secondary metadata-строки. На живом playback это заметно упрощает чтение длинных названий и понимание контекста очереди с первого взгляда.
Это небольшой релиз, но очень правильный по характеру: он повышает доверие к интерфейсу именно там, где это важно для ежедневной эксплуатации. Кнопки выглядят доступными только тогда, когда действие действительно можно выполнить, repeat честно показывает свой режим, а expanded list player даёт больше контекста при меньшем визуальном шуме.
Релиз 2.32.2 — это прежде всего эксплуатационный hardening-релиз. Его спровоцировал реальный native-LXC инцидент: автообновление принесло новый Python-код, который уже импортировал дополнительные модули, но локальный updater по-прежнему скачивал устаревший hand-made список файлов и отправлял сервис в restart loop. Этот релиз закрывает проблему не точечным «добавили ещё два файла», а на уровне архитектуры обновления.
В релизе выделяются пять тем:
- Публичная видимость состояния репозитория — архиватор GitHub traffic теперь сохраняет более богатую repository/release статистику, а docs-site публикует этот архив как простой stats-dashboard. В результате внутренняя release/traffic телеметрия превратилась в то, что можно посмотреть без раскопок workflow artifacts.
- Release snapshot вместо дрейфа файловых списков —
lxc/install.shиlxc/upgrade.shтеперь скачивают GitHub archive snapshot и синхронизируют runtime-tree рекурсивно. То есть из update-path убран хрупкий паттерн «не забыть дописать каждый новый файл в два shell-цикла», который и привёл к падению Turris. - Detached-обновление, переживающее рестарт — one-click update и фоновый auto-update теперь запускаются через
systemd-run --no-block, вне cgroup самогоsendspin-client. Это критично, потому что restart, smoke-check и rollback теперь могут действительно дожить до конца, даже если основной сервис в процессе обновления перезапускается. - Транзакционное поведение апдейта — LXC-updater теперь staging-ит новую tree, валидирует импорты до swap, перезапускает сервис, выполняет локальные health-checks и автоматически откатывается, если новый runtime не поднимается чисто. Иными словами, у update-path теперь есть полноценный recovery-сценарий, а не только сценарий замены файлов.
- Небольшие, но практичные follow-up фиксы по краям релиза — armv7 Docker build снова согласован с текущим контрактом зависимостей
aiosendspin/av, а увеличенный preview обложки больше не прячется под toolbar/group actions ни вcard, ни вlistview.
Это релиз, ценность которого операторы в идеале замечают именно тем, что им потом не приходится ничего экстренно чинить: меньше updater-assumptions, меньше script drift и заметно безопаснее путь для unattended native-LXC обновлений.
Релиз 2.32.0 переводит цикл redesign-полировки из состояния «визуально уже похоже на Music Assistant» в состояние «похоже ещё и по поведению». Его главная тема — паритет: card и list теперь всё чаще опираются на одну и ту же playback-логику вместо двух почти независимых UI-реализаций, которые только выглядят родственными. Это заметно в видимой полировке — более плотная связка названия трека с equalizer, более чистые header-ы карточек, hover-only secondary actions, более тонкие volume sliders, числовые уровни громкости без % — но важнее то, что одинаковыми становятся и queue/progress semantics.
В релизе выделяются четыре сквозные темы:
- Схождение card/list playback-модели — expanded row в списке теперь ведёт себя гораздо ближе к MA mini-player: текущий трек и его метаданные прижаты к artwork, prev/next queue preview встроены прямо в playback-lane, а transport/shuffle/repeat controls расположены вокруг текущего контекста воспроизведения. Параллельно был отполирован
card view: чекбокс выбора ушёл в крайний левый край header-а, secondary actions теперь раскрываются по hover вместо постоянного расхода высоты, а compact equalizer переиспользуется между представлениями последовательно. - Корректность queue-контекста — bridge больше не доверяет слепо
player_queues/all, если тот не прислал соседние элементы очереди. Для missing previous/next теперь выполняется адресная гидрация черезplayer_queues/items, благодаря чему исчезают ложныеQueue start/Queue endplaceholders там, где у трека на самом деле есть реальные соседи. - Стабильность прогресса вместо UI-скачков — playback progress теперь инициализируется детерминированно и умеет сливать stale MA elapsed snapshots без отката назад. На практике это убирает сразу оба класса багов, всплывших в этой итерации: flash progress bar на полную ширину при первом рендере и backward-jump по времени/полосе, когда поздний MA payload приходит со старой elapsed-базой.
- Более полезная runtime-диагностика — diagnostics теперь показывают, находится ли устройство в состоянии активного воспроизведения, и дополнительно разбирают PulseAudio sink-input metadata (
application_*,media_*). Это не headline-фича, но именно она делает live-debugging аудиомаршрутизации заметно практичнее.
Это также релиз про reuse как стратегию сопровождения. Всё больше equalizer/progress/queue-поведения строится через общие helper-ы для card и list, и ценность здесь не только в меньшем количестве дублированного кода, но и в том, что снижается UI drift и число сценариев “исправили тут, но забыли во втором представлении”.
Релиз 2.31.11 — это узкий, но очень прикладной follow-up после 2.31.10. Он чинит заметную регрессию redesigned dashboard: Music Assistant уже отдавал метаданные обложек, но web UI корректно отказывался показывать большинство картинок, потому что URL вели на другой origin или приходили как сырые относительные пути MA. То есть поломка находилась ровно на границе между «безопасность фронтенда» и «качество backend-контракта».
Исправление в этом релизе закрывает именно эту границу, а не ослабляет защиту:
- Same-origin доставка обложек — album art теперь проходит через bridge-owned endpoint
/api/ma/artwork, поэтому браузер получает URL с того же origin, что и сам dashboard, а существующий фронтенд-фильтр можно оставить без ослабления. - Корректное разрешение MA URL — сырые пути изображений от Music Assistant оборачиваются ещё до попадания во фронтенд. Относительные пути резолвятся относительно настроенного MA base URL, а абсолютные URL разрешаются только если они всё ещё указывают на тот же origin Music Assistant.
- Проксирование с токеном, но без open proxy — если Music Assistant требует авторизацию, bridge подставляет сохранённый MA bearer token при запросе обложки. Одновременно любые чужие хосты явно отклоняются, чтобы новый маршрут нельзя было использовать как универсальный fetch tunnel.
Это также намеренно test-backed hotfix. Добавлены регрессионные тесты и для оборачивания image_url в now-playing metadata, и для happy/reject-path нового proxy endpoint. Поэтому 2.31.11 лучше воспринимать как маленький релиз, который возвращает заметную пользовательскую функцию, не жертвуя более строгой security posture интерфейса.
Релиз 2.31.10 — это следующий stabilisation-step после 2.31.9: та же общая цель «сделать bridge безопаснее на краях системы», но уже с более прямым акцентом на корректность жизненного цикла в реальных инсталляциях — выбор адаптера, дубликаты устройств, восстановление после zombie playback и долгосрочную чистоту persisted-state в конфиге.
В релизе выделяются четыре прикладные темы:
- Fail-safe работа с адаптерами — bridge больше не «угадывает»
hci0, если не смог корректно сопоставить адаптер. Для multi-adapter систем это критично: новый режим делает поведение деградировавшим, но понятным — D-Bus monitoring для устройства отключается, а вместо ложного пути используется уже существующий polling fallback через bluetoothctl. - Безопаснее startup-идентичность — дубликаты Bluetooth MAC теперь фильтруются до создания runtime-объектов. Это защищает от очень простой конфигурационной ошибки, которая раньше могла поднять два конкурирующих клиента против одной колонки со всеми побочными эффектами: конфликтные reconnect-ы, двусмысленное владение sink-ом и странное состояние UI.
- Watchdog с awareness playback-сессии — восстановление zombie playback теперь ориентируется на текущую playback-сессию, а не считает subprocess «навсегда безопасным» после первого успешного стрима. На практике это возвращает авто-восстановление для сценария, когда колонка уже играла раньше, но позже снова попадает в состояние «playing, но без звука».
- Гигиена конфига со временем — повреждённый
config.jsonтеперь оставляет recovery-копию (config.json.corrupt-*) перед запуском с defaults, а устаревшие записи вLAST_VOLUMESочищаются, чтобы удалённые устройства не продолжали тянуть за собой мусорный persisted-state.
Это также релиз усиления корректности, а не расширения scope. Добавлены регрессионные тесты для fallback-пути при неразрешённом адаптере, фильтрации дубликатов MAC, reset-логики zombie watchdog, backup-поведения для corrupt config и путей нормализации config/volume state. Поэтому 2.31.10 лучше воспринимать как релиз, в котором bridge стал честнее деградировать, предсказуемее восстанавливаться и аккуратнее жить на длинной дистанции эксплуатации.
Релиз 2.31.9 — это типичный stabilisation-follow-up без одной большой headline-фичи, но с очень плотной проработкой тех мест, где зрелый bridge чаще всего «сыпется» на практике: диагностика на шумном выводе хоста, безопасный экспорт конфигурации, shutdown-гонки и учёт Bluetooth-реконнектов. Это релиз не про расширение scope, а про повышение доверия к уже выросшей UI/config surface.
В релизе выделяются четыре сквозные темы:
- Защитная диагностика — разбор вывода
pactl,bluetoothctlи/proc/meminfoбольше не предполагает идеально сформированные строки. Если внешний вывод обрезан или деформирован, bridge не падает наIndexError, а продолжает собирать диагностику настолько полно, насколько возможно. - Безопаснее работа с конфигом — скачивание
config.jsonиз веб-UI теперь отдаёт share-safe экспорт: без password hash, secret key и токенов Music Assistant. Параллельно путь сохранения конфигурации нормализует известные числовые поля до корректныхint, чтобы UI-ввод и on-disk типы не расходились со временем. - Чище runtime-границы — отправка команд подпроцессу теперь сначала берёт snapshot process/stdin handle, а graceful shutdown проходит по стабильному snapshot списка клиентов вместо живой общей коллекции. Это небольшие изменения в коде, но именно они убирают трудноуловимые race-condition сценарии при рестарте и остановке.
- Надёжнее churn-isolation — timestamps Bluetooth-реконнектов теперь синхронизированы lock-ом, поэтому очистка окна и пороговая проверка reconnect storm работают по одной консистентной выборке, а не по частично обновлённому состоянию.
Это одновременно и релиз усиления тестов. Добавлены точечные регрессионные тесты для defensive parsing в diagnostics, redaction экспорта конфига, нормализации числовых настроек, TOCTOU-сценария в subprocess-командах и Bluetooth churn isolation. Поэтому 2.31.9 лучше понимать как релиз эксплуатационной надёжности: меньше эффектных изменений снаружи, больше уверенности в поведении на краях системы.
Релиз 2.31.8 — это узкий UI-hotfix после большого редизайна 2.31.6 и auth-фикса 2.31.7. Он исправляет две ссылки в пустом состоянии dashboard, которые после редизайна всё ещё опирались на старую структуру интерфейса.
Если Bluetooth-адаптер не обнаружен, CTA теперь открывает Configuration → Bluetooth, приводит пользователя прямо к карточке адаптеров и заранее создаёт пустую строку для ручного добавления адаптера. Если адаптер уже есть, но устройства ещё не настроены, CTA сканирования теперь открывает Configuration → Devices → Discovery & import и запускает Bluetooth scan из правильной redesigned-секции. Иными словами, пустой dashboard снова стал рабочей точкой входа, а не тупиковой подсказкой.
Релиз 2.31.7 — это точечный auth-hotfix сразу после 2.31.6. Он исправляет регрессию в прямом Home Assistant login flow: после ввода TOTP-кода на втором шаге MFA мост рендерил форму без корректного CSRF-токена, из-за чего POST-проверка отвергалась как невалидная сессия.
В этом релизе CSRF-токен корректно сохраняется и передаётся между шагами MFA, а также добавлен регрессионный тест, проходящий весь путь логин/пароль -> MFA -> успешный вход. На практике это возвращает штатный вход в веб-интерфейс для пользователей Home Assistant с включённым TOTP.
Релиз 2.31.6 завершает первый большой цикл полировки после редизайна 2.31.0. Здесь акцент уже не на новых крупных примитивах, а на внутренней согласованности интерфейса: Configuration перестроен в карточную settings-систему, dashboard-бейджи сведены к единому chip-паттерну, а list и card снова приведены к визуальному и функциональному паритету.
В релизе доминируют три темы:
- Зрелость Configuration — появилась кнопка
Cancelс реальным откатом к последнему сохранённому состоянию, расширены настройки безопасности и runtime-поведения (session timeout, brute-force protection, MA WebSocket monitor), а иерархия секций General / Security / Bluetooth / Devices / Music Assistant стала заметно чище. - Эргономика управления устройствами — бейджи адаптеров ведут прямо в
Configuration → Bluetooth, имена адаптеров можно редактировать, бейджи MA sync-group открывают правильную страницу настроек в Music Assistant, а режим отображения теперь по умолчанию переключается в список при большом количестве устройств и запоминает выбор пользователя. - Очистка runtime-бейджей — delay показывается и в
list, и вcard, строки списка выводят тот же основной runtime-контекст, что и карточки, удалены пустые placeholder-бейджи, устранены наезды и вертикальные рассинхроны chip-ов, а сортировка списка теперь поддерживает адаптеры и использует те же adapter/status chips, что иcard view.
По сути это релиз «согласованности» для нового интерфейса: концептуально он скромнее, чем 2.31.0, но именно он доводит макет, runtime-логику и финальную UI-реализацию до единого состояния.
1 января 2026 Loryan Strant (Австралия, AEDT +1100) создал и опубликовал сервис SendspinClient — Docker-контейнер, связывающий Music Assistant (через протокол Sendspin) с Bluetooth-колонкой на Linux-машине.
Поводом послужил сугубо личный сценарий: инфракрасная сауна с Bluetooth-колонками, Surface Pro 4 на Ubuntu рядом — уже знакомый паттерн домашней автоматизации. Loryan попробовал Squeezelite, но ESP32 + WiFi + A2DP давали нестабильное соединение. Идея оказалась элегантной: раз MA умеет стримить по Sendspin (WebSocket + FLAC/RAW), а sendspin-бинарник воспроизводит на любом PulseAudio-устройстве — нужен только мост в контейнере.
Исходный код: один Python-скрипт, sendspin как дочерний процесс, bluetoothctl для подключения к колонке, простая HTML-страница статуса. Loryan опубликовал проект в MA Community в треде #4677 и за один день закрыл 4 PR — улучшение детекции обрыва BT, расширение README.
Я нашёл тред #4677. У меня была похожая задача — подключить Bluetooth-колонки через Proxmox LXC, что loryanstrant'овский Docker-вариант не поддерживал: LXC-контейнеры не имеют доступа к AF_BLUETOOTH-сокетам из-за ограничений kernel namespaces.
27 февраля 2026 я оставил первый комментарий в дискуссии с описанием решения и отправил PR #6 в оригинальный репозиторий: новая директория lxc/ с proxmox-create.sh (запускается на хосте PVE — создаёт LXC, bind-mount D-Bus сокета, настраивает Bluetooth-passthrough) и install.sh для установки внутри контейнера. Проверено на PVE 8.4.16 с Sony WH-1000XM4.
28 февраля 2026 я опубликовал расширенный форк sendspin-bt-bridge с принципиальными новинками и в том же треде спросил Loryan'а — не против ли он, что я развиваю проект самостоятельно и публикую как отдельный HA addon, пообещав, что он навсегда останется указан как автор-основатель. Среди нового в первом релизе форка:
- Мульти-устройство: несколько Bluetooth-колонок одновременно, каждая — отдельный плеер в MA
- Home Assistant addon с Ingress (веб-UI в боковой панели HA без пробрасывания портов)
static_delay_ms— компенсация A2DP-задержки на уровне устройства/api/diagnostics— структурированный healthcheck по адаптерам, синкам, D-Bus- Аудиоформат в статусе (кодек, частота, битность — например
flac 48000Hz/24-bit/2ch) - Сохранение громкости по MAC в
LAST_VOLUMESи автовосстановление при реконнекте
Явный разрыв с upstream зафиксирован коммитом от 1 марта 2026:
chore: detach from loryanstrant/Sendspin-client upstreamПосле этого момента проект развивается полностью независимо. История коммитов с 1 января унаследована — 14 коммитов loryanstrant'а остаются частью git-истории репозитория.
Состояние кода: один файл sendspin_client.py ≈ 400 строк.
Схема предельно простая:
MA Server ──(WebSocket/Sendspin)──► sendspin CLI ──(PulseAudio)──► bluetoothctl ──► BT SpeakerBluetooth-менеджер опрашивает соединение раз в 10 секунд через bluetoothctl info <MAC>. Разрыв обнаруживается с задержкой до 10 с. Веб-интерфейс — минимальный статус, ничего настраивать нельзя.
Первые PR из родительского репозитория добавляют реальный мониторинг D-Bus вместо таймерного пинга — BT-статус обновляется мгновенно при событии системы.
Ключевая проблема этого этапа: невозможность работы с несколькими колонками. В PulseAudio единственный PULSE_SINK — куда льётся звук из sendspin, туда и идёт. Два динамика = неопределённость.
Самый стремительный период разработки. 73 коммита только 28 февраля.
Репозиторий переименован из sendspin-client в sendspin-bt-bridge — название отражает новую роль: не клиент, а мост.
Ключевые добавления за один день:
- Мультиустройственность: каждый элемент
BLUETOOTH_DEVICESв конфиге запускает отдельную паруBluetoothManager+SendspinClient. В MA появляются несколько независимых плееров. - Home Assistant addon (
ha-addon/): манифест, Dockerfile,run.sh. Мост интегрируется в Ingress-панель HA, тема подтягивается через postMessage API. - Proxmox LXC: скрипт
proxmox-create.shразворачивает нативный контейнер одной командой. Внутри — собственныйbluetoothdчерез D-Bus bridge,pulseaudio --system,avahi-daemon. - Полноценный веб-интерфейс: карточки устройств, BT-сканирование, регулятор громкости, кнопки переподключения/перепривязки, диагностика.
- Управление BT-адаптерами: автоопределение, ручной выбор, привязка колонки к конкретному
hci.
Первичная задача — поддержка нескольких bridge-инстанций, подключённых к одному MA-серверу. Когда два bridge регистрируют плеер с именем, например "Living Room", MA не может отличить их по имени — при появлении второго он сбрасывал очередь первого или путал их между собой. player_id должен быть глобально уникальным и стабильным независимо от имени.
Решение: UUID5 из MAC-адреса (v1.3.0). UUID детерминирован (одинаковый при каждом перезапуске), уникален глобально (MAC уникален физически), и не зависит от имени плеера. Два bridge с разными колонками → два разных player_id → MA видит их как полностью независимых плееров, даже если имена совпадают.
Это же решило вторичную, но тоже ощутимую проблему: до этого MA терял плеер при перезапуске моста или переименовании — очереди и группы слетали. После v1.3.0 player_id не меняется никогда.
Параллельно — MPRIS D-Bus интеграция (v1.3.16): мост регистрируется как MediaPlayer2-объект на session bus. MA может читать статус воспроизведения и управлять плеером через стандартный интерфейс. При остановке сервиса сначала отправляется MPRIS Pause — MA корректно останавливает группу перед тем, как плеер исчезает из сети.
Идентификация в MA-группах (v1.3.19): проблема в том, что MA строит syncgroup по именам плееров. Добавлена логика BRIDGE_NAME + суффикс + MPRIS Identity, чтобы имя плеера в MA совпадало с именем MPRIS-объекта — иначе группа не собирается.
До этой версии веб-интерфейс выглядел как generic-дашборд: фиолетовый градиентный хедер (#667eea), жёсткие HSL-цвета, системный шрифт. При открытии через HA Ingress он визуально выбивался из экосистемы.
В v1.3.7 UI полностью переписан под визуальный язык Home Assistant и Music Assistant:
CSS custom properties вместо хардкода
/* было */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #28a745;
/* стало */
background: var(--app-header-background-color, #03a9f4);
color: var(--success-color, #4CAF50);Все цвета — через HA design tokens (--primary-color, --error-color, --success-color, --warning-color, --ha-card-border-radius, --ha-card-box-shadow). Хедер стилизован как app-toolbar HA. Шрифт — Roboto (тот же, что в HA).
Двойная тема: media query + Ingress postMessage
/* статическая тема — работает везде */
@media (prefers-color-scheme: dark) {
:root { --primary-background-color: #111; ... }
}// живая инъекция темы из HA — только в Ingress
window.addEventListener('message', (e) => {
if (e.data?.type === 'setTheme') applyTheme(e.data.theme);
});Когда пользователь открывает UI через HA sidebar, HA посылает postMessage с текущей темой. Переключение темы в HA → мгновенно меняется в веб-UI без перезагрузки страницы. Если UI открыт напрямую (не через Ingress) — тема определяется системным prefers-color-scheme.
Итог: с v1.3.7 веб-интерфейс неотличим по стилю от нативных HA-панелей. Пользователи, которые добавили мост в HA sidebar, видят единый дизайн.
Последующие итерации UI (v2.6.5, v2.6.6, v2.7.x) продолжили полировку: прогресс-бар трека, транспортные кнопки, обложка альбома, hover-действия, анимированный BT-scan, мобильная адаптация, UX-аудит с 20 улучшениями (v2.10.x).
- Модуляризация (v1.4.0): монолитный
sendspin_client.pyразбит наconfig.py,mpris.py,bluetooth_manager.py. - Документационный сайт (v1.4.2): Astro Starlight, двуязычный (EN/RU), деплой на GitHub Pages.
- Аутентификация веб-интерфейса (v1.6.0): PBKDF2-SHA256 для standalone-режима; в HA addon — проксирование через HA Core login_flow с поддержкой 2FA/TOTP.
- D-Bus BT-монитор (v1.7.0): переход с polling к event-driven подходу для мониторинга Bluetooth — мост узнаёт о разрыве в момент события, а не через 10 секунд.
- Настраиваемый интервал проверки BT и автоотключение на N неудачных попытках переподключения.
Это самый технически насыщенный период. Решалась одна проблема — детерминированная маршрутизация звука в PulseAudio при нескольких динамиках — и было пройдено четыре принципиально разных архитектурных подхода.
До v2.0 каждый BT-динамик управлялся отдельным системным процессом sendspin:
main process
├── subprocess: sendspin (PID A, env PULSE_SINK=bt_sink_A) → Speaker A
└── subprocess: sendspin (PID B, env PULSE_SINK=bt_sink_B) → Speaker BКаждый sendspin-процесс имел собственный PulseAudio-контекст и свою переменную PULSE_SINK. Маршрутизация работала — но ценой хрупкости: статус воспроизведения парсился из stdout по регулярным выражениям (~230 строк парсинга), трек и метаданные опрашивались через MPRIS с задержкой до 10 секунд.
В v2.0 (2 марта) sendspin CLI заменён на прямой вызов Python-библиотеки:
# До v2.0: subprocess + stdout parsing
process = subprocess.Popen(['sendspin', '--headless', ...])
# ~230 строк парсинга stdout по regex
# С v2.0: in-process BridgeDaemon
class BridgeDaemon(SendspinDaemon): # из пакета aiosendspin
def on_stream_start(self, ...): ... # typed callback
def on_volume_change(self, ...): ...SendspinDaemon — asyncio-класс из PyPI-пакета sendspin (внутренняя реализация — aiosendspin). Все события через typed callbacks, без парсинга. Убрано ~230 строк хрупкого кода, метаданные трека теперь приходят мгновенно.
Но: теперь все BridgeDaemon-экземпляры живут в одном Python-процессе с единым PulseAudio-контекстом. PULSE_SINK — переменная окружения процесса: задать разные значения для разных daemons внутри одного процесса невозможно.
main process (единый PA-контекст)
├── BridgeDaemon A → PA stream → default sink → Speaker ???
└── BridgeDaemon B → PA stream → default sink → Speaker ???PA выбирает default sink — обычно последний подключённый BT-динамик. Гарантии нет: поток мог попасть в любую из колонок. Это и стало корнем всех последующих проблем.
sendspin process
└─► PA stream ──(move-sink-input on stream event)──► correct BT sinkBridgeDaemon подписывается на PA-события потоков. Как только появляется новый sink-input — перемещает его командой pactl move-sink-input в нужный синк.
Проблема: гонка условий. Между появлением потока и его перемещением успевало воспроизводиться 0.5–2 секунды не в ту колонку. При быстрой смене треков — нестабильно.
sendspin ──► PA null-sink (virtual) ──(loopback module)──► real BT sinkСоздаётся виртуальный синк через module-null-sink, а module-loopback соединяет его с реальным BT-синком. sendspin направляется в виртуальный синк — он всегда стабилен.
Проблема: module-loopback добавляет дополнительную буферную задержку. Синхронизация в мультирум-группе ломается. Плюс — хрупкость: PA мог сбросить модуль при переподключении BT.
Ключевой инсайт: вместо того чтобы реагировать на неправильно направленный поток, нужно задать направление до его создания.
PULSE_SINK=bluez_sink.AA_BB_CC_DD_EE_FF.a2dp_sink sendspin ...Переменная окружения PULSE_SINK говорит PA-клиенту использовать конкретный синк при создании любого потока. Никакой реактивности, никаких гонок.
Проблема: всё ещё один процесс. При нескольких sendspin-подпроцессах переменная окружения унаследовалась не так, как ожидалось.
main process
├── subprocess (env: PULSE_SINK=bluez_sink.AA...) → daemon_process.py
│ └── BridgeDaemon → sendspin CLI → PA stream → Speaker A
├── subprocess (env: PULSE_SINK=bluez_sink.BB...) → daemon_process.py
│ └── BridgeDaemon → sendspin CLI → PA stream → Speaker B
└── ...Каждый динамик получает собственный Python-процесс с PULSE_SINK в os.environ. Каждый процесс создаёт независимый PA-контекст. Потоки физически изолированы — невозможно, чтобы звук попал не туда.
IPC: subprocess → main через JSON-строки в stdout; main → subprocess через JSON в stdin (set_volume, stop).
Доработки на этом же этапе:
- v2.5.1: PA
module-rescue-streams— при переподключении BT-устройства PA перемещает осиротевшие потоки на fallback-синк. Добавлена коррекция: обнаруживаем переезд и возвращаем поток обратно черезpactl move-sink-inputпо PID. - v2.5.2: защита от feedback loop — коррекция
move-sink-inputсама генерирует событие потока, которое снова запускает коррекцию. Добавлен флаг_sink_routed, блокирующий повторный вход.
После решения проблемы маршрутизации — период полировки и расширения.
| Модуль | Что внутри |
|---|---|
services/daemon_process.py |
Точка входа subprocess |
services/bridge_daemon.py |
BridgeDaemon — Sendspin + PA events |
services/pulse.py |
Async PulseAudio helpers |
services/bluetooth.py |
BT утилиты |
services/ma_monitor.py |
MA WebSocket monitor |
services/ma_client.py |
MA REST API клиент |
routes/api.py |
REST API Flask blueprint |
routes/views.py |
HTML-страницы |
routes/auth.py |
Аутентификация |
state.py |
Shared runtime state, SSE |
config.py |
Конфигурация, VERSION |
- Preferred audio format per device (v2.5.5): поле
preferred_formatв конфиге устройства. MA может пытаться применить ресемплинг при мультирум-синхронизации — если прибить формат, ресемплинг исчезает. - Track progress bar (v2.6.6): полоса прогрека с интерполяцией на клиенте (JS). Позиция трека из MPRIS-метаданных.
- Sync status: счётчик re-anchor событий, предупреждение при частых переключениях.
- Sink name в колонке Volume по hover — для диагностики без
/api/diagnostics.
- v2.6.0–2.6.1: аудит безопасности — проверка входных данных, защита от path traversal в конфиге, корректная инвалидация Flask-сессий.
pause_allотправляет команду раз на MA-группу, не по одному клиенту.
К этому моменту накопились две задачи, важные для нетривиальных deployments:
1. Поддержка 100+ колонок в одном bridge
При большом числе устройств монопроцессная модель начинала испытывать проблемы с параллелизмом. Целевой рефакторинг (v2.7.x) включал несколько изменений:
- SSE-батчинг:
notify_status_changed()накапливает обновления в 100-миллисекундном окне перед отправкой клиентам. При массовом переподключении (например, все 50 колонок встали после ночи) без батчинга генерируется шторм из 50 SSE-событий подряд — браузеры не успевали обрабатывать. Батчинг снижает число событий примерно в 10 раз. - ThreadPoolExecutor с явным размером пула:
min(64, N_devices×2+4)воркеров. При 100+ устройствах дефолтный пул Python (os.cpu_count()*5) мог выстраивать в очередь BT-операции — переподключение одного устройства блокировало остальные. - Переиспользование D-Bus MessageBus: ранее при каждой итерации внешнего цикла переподключения создавался и уничтожался новый bus-объект. При 100 устройствах это 100 параллельных bus-соединений с D-Bus daemon'ом — избыточно. Теперь соединение переиспользуется; новое создаётся только если bus перестал отвечать.
- Keepalive jitter: при старте все устройства могли одновременно запустить
paplay silence.wav— пик CPU. Добавлен случайный старт в диапазоне 0..interval секунд. _status_monitor_loopsleep увеличен с 2 с до 5 с: при 100 устройствах 50 asyncio-пробуждений в секунду (2 с × 100 / ... = нагрузка) без реальной пользы — D-Bus сигналы ловят разрыв мгновенно.WEB_THREADS: конфигурируемое число воркеров Waitress (по умолчанию 8, рекомендуется 16 при 20+ устройствах). Каждый браузер держит SSE-соединение на своём воркере — при нескольких вкладках пул заканчивается.
2. Несколько bridge на один MA-сервер
Сценарий: большой дом, несколько зон Bluetooth-покрытия. Один bridge физически не достаёт до всех колонок. Решение — несколько bridge-инстанций (Docker-контейнеров, LXC-контейнеров, HA-аддонов) против одного MA.
Проблема: если два bridge регистрируют плеер с одинаковым именем → MA считает их одним плеером и сбрасывает очередь.
Решение (v1.3.0, заложено заранее): UUID5 из MAC-адреса в качестве player_id в Sendspin:
player_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, mac_address))UUID детерминирован (одинаковый при каждом перезапуске), уникален глобально (MAC уникален), и не зависит от имени. Два bridge с разными MAC'ами → два разных player_id → MA видит двух независимых плееров даже с одинаковыми именами.
Имя bridge по умолчанию — Sendspin-<hostname> — тоже уникализировано, чтобы в MA сразу было понятно, с какой машины пришёл плеер.
BT-колонки автоматически отключаются после ~30 секунд тишины. Для мультирум-сценария это критично: если между треками в очереди пауза, колонка уходит в режим сна, и следующий трек начинается с задержкой переподключения (2–5 секунд) — группа рассинхронизируется.
Решение: генерировать беззвучный PCM-сигнал (silence_stream) через PulseAudio, пока колонка считается "активной". Это удерживает A2DP-соединение без реального аудио.
Первая реализация паузы отправляла команду stop непосредственно в subprocess каждого устройства. Проблема: MA при этом не знал о паузе — статус плееров в MA оставался "Playing", syncgroup не синхронизировался.
Правильное решение: пауза через MPRIS D-Bus интерфейс (org.mpris.MediaPlayer2.Player.Pause). MA является инициатором через MPRIS → MA корректно обновляет статус всей группы.
Кнопка паузы для устройства-участника группы (2+ участников) управляет всей группой, а не только одним плеером — чтобы нельзя было случайно рассинхронизировать группу из веб-интерфейса.
REST API для управления группами: POST /api/group/pause, POST /api/group/play, POST /api/group/volume. Групповые элементы управления в веб-UI — устанавливают громкость и мьют на всех участниках группы одновременно.
До этой версии мост «не знал» о группах MA — только видел несколько своих плееров. Если MA объединял их в syncgroup, возобновление воспроизведения после паузы могло дать рассинхронизацию, так как мост пробовал возобновить каждый плеер независимо.
С v2.9.0 мост подключается к MA REST API:
- Находит syncgroup, в которую входят его плееры, по нечёткому совпадению имён.
- При возобновлении воспроизведения вызывает
POST /api/players/player_queues/{group_id}/play— MA возобновляет группу как единое целое. MA_API_URLиMA_API_TOKEN— новые поля конфигурации.
Серия исправлений v2.9.1–2.9.4: настройки API не сохранялись через перезапуск аддона (translate_ha_config.py не переносил ключи), URL не нормализовывался, конфигурация не попадала в allowed_keys.
Самое значительное добавление функционала после subprocess-изоляции.
services/ma_monitor.py устанавливает постоянное WebSocket-соединение к MA (/api/ws) и подписывается на события player_queue_updated. При изменении очереди плеера — мост немедленно получает обновление.
Что это даёт:
- Now-playing в веб-интерфейсе: трек, исполнитель, альбом, обложка, позиция в очереди.
- Transport controls: prev/next/shuffle/repeat кнопки в карточке устройства — через MA REST API.
- Album art: tooltip при наведении на название трека.
- Прогресс-бар: синхронизирован с позицией из MA.
- Автообновление метаданных: при подключении монитора запрашиваются актуальные данные всех активных плееров.
При первой реализации MA-интеграции all now-playing кэш был глобальным — один объект на весь мост. Если мост управлял двумя MA-syncgroup (например, «Гостиная + Кухня» и «Спальня»), данные второй группы перезаписывали первую.
Рефакторинг на per-group кэш: dict[group_id, NowPlayingData]. Каждая карточка устройства показывает метаданные своей группы.
Solo-плееры (не входящие в syncgroup) получают own queue_id по формату up<uuid_без_дефисов>.
| Версия | Дата | Архитектурное решение | Проблема, которую решало |
|---|---|---|---|
| v0 (origin) | 1 янв | Один процесс, один BT, polling | — |
| v1.3.0 | 1 мар | UUID player_id из MAC | Несколько bridge на один MA + стабильный ID при перезапуске |
| v1.3.16 | 1 мар | MPRIS D-Bus MediaPlayer2 | Нет связи MA ↔ bridge через стандартный интерфейс |
| v1.4.0 | 2 мар | Разбивка монолита на модули | Неуправляемый рост единого файла |
| v1.7.0 | 2 мар | D-Bus event BT monitor | 10-секундная задержка детекции разрыва |
| v2.0 | 2 мар | sendspin CLI → in-process aiosendspin | Хрупкий stdout-парсинг, задержка метаданных — породило проблему default sink |
| v2.1 | 3 мар | Реактивный move-sink-input |
Звук шёл в default sink (не тот динамик) |
| v2.2 | 3 мар | null-sink + loopback | Гонка на move-sink-input |
| v2.4 | 3 мар | Проактивный PULSE_SINK env |
Задержка loopback ломала синхронизацию |
| v2.5 | 3 мар | Subprocess-изоляция per speaker | PULSE_SINK неприменим внутри единого процесса |
| v2.5.1 | 3 мар | PA rescue-streams коррекция | BT reconnect перебрасывал потоки на fallback |
| v2.5.5 | 4 мар | preferred_format per device |
Ресемплинг в мультирум-группах |
| v2.6.0 | 4 мар | routes/, services/, state.py | Монолитный web_interface.py |
| v2.7.x | 5 мар | Keepalive silence stream | BT disconnects при тишине между треками |
| v2.7.x | 5 мар | Групповая пауза через MPRIS | MA не знал о паузе, группа не синхронизировалась |
| v2.8.0 | 5 мар | Group REST API | Нет API для управления группами |
| v2.9.0 | 5 мар | MA REST API integration | Возобновление группы без MA как инициатора |
| v2.9.5 | 5 мар | Persistent MA WebSocket monitor | Нет real-time данных о воспроизведении |
| v2.9.9 | 5 мар | Per-group MA now-playing cache | Один глобальный кэш ломал несколько syncgroup |
| Период | Коммитов | Главный вектор |
|---|---|---|
| 1 января | 14 | Сервис loryanstrant'а создан и опубликован (+1100 AEDT) |
| 27–28 фев | ~80 | Первые собственные коммиты (+0300 MSK): Proxmox LXC, мультиустройственность, HA addon |
| 1 марта | ~49 | Идентификация в MA, MPRIS, HA Ingress, аутентификация, detach от upstream |
| 2 марта | ~55 | Модуляризация, D-Bus BT monitor, первые audio routing попытки |
| 3 марта | ~91 | 4 итерации audio routing → subprocess isolation |
| 4 марта | ~77 | Полировка, preferred_format, UI, безопасность |
| 5 марта | ~141 | Keepalive, группы, MA API, real-time monitor |
| 6 марта | ~41 | MA multi-syncgroup, solo players, документация |
За 7 дней активной разработки проект прошёл путь от однофайлового скрипта с одной колонкой до production-ready решения с subprocess-изоляцией звука, нативной интеграцией в экосистему Music Assistant и Home Assistant, поддержкой мультирум-конфигураций с синхронизацией через MA syncgroup.
| Метрика | Значение |
|---|---|
| Всего коммитов | ~466 |
| Автор (Mikhail Nevskiy) | ~414 коммитов |
| Loryan Strant (основа) | 14 коммитов |
| GitHub Actions (CI/CD) | 38 коммитов |
| Активных дней разработки | 9 (27 фев — 6 мар 2026) |
| Выпущено версий | ~135 (v1.0.0 → v2.13.1) |
| Pull Requests | 54 |
| Самый насыщенный день | 5 марта: 119 коммитов |
| Метрика | Значение |
|---|---|
| Python-файлов | 44 |
| Строк Python-кода | ~12 700 |
| Наиболее изменяемый файл | sendspin_client.py (108 правок) |
| Следующие по частоте | config.py (102), web_interface.py (100) |
| Python-зависимостей | 11 |
| Пакет | Назначение |
|---|---|
sendspin / aiosendspin |
Sendspin-протокол, BridgeDaemon, SendspinDaemon |
music-assistant-client |
MA REST API и WebSocket |
flask + waitress |
Веб-интерфейс и HTTP-сервер |
pulsectl-asyncio |
Управление PulseAudio из asyncio |
dbus-fast + dbus-python |
D-Bus: BT-мониторинг и MPRIS |
websockets |
Обмен данными с MA WebSocket |
psutil |
Системная информация |
python-dotenv |
Переменные окружения |
flask-cors |
CORS для REST API |
Гибридный путь громкости — маршрутизация команд через MA WebSocket API для синхронизации UI MA — создал тройную петлю обратной связи: API, эхо sendspin-протокола и событие MA-монитора одновременно устанавливали громкость PulseAudio-раковины. Результат — «прыгающая» громкость (установил 40 → прыгнуло на 47 → остановилось на 55) и неожиданные скачки при смене трека.
Исправление было архитектурным: bridge_daemon стал единственным писателем громкости PulseAudio-раковины. API больше не обновляет локальный статус оптимистично на MA-пути — ждёт реальное эхо от MA через sendspin-протокол. _handle_player_updated в MA-мониторе удалён как избыточный третий путь. Новая опция VOLUME_VIA_MA (по умолчанию: true) позволяет полностью отключить прокси через MA, направляя все изменения громкости через прямой pactl.
Все 27 молчаливых блоков except: pass заменены на логирование уровня DEBUG — проблемы видны при LOG_LEVEL=DEBUG без изменения поведения. Потокобезопасность усилена: вызовы run_coroutine_threadsafe получили 5-секундные тайм-ауты, а fire-and-forget asyncio-задачи — done_callback для логирования исключений. Проект получил первые автоматические тесты: pytest с 9 юнит-тестами для загрузки конфигурации, сохранения громкости, маппинга MAC→player-ID и хеширования паролей (позднее расширено до 15 тестов).
LXC-установщик обновлён для загрузки всех модулей приложения (config, state, routes, services, templates, static) вместо изначальных 2 файлов. Конфигурация PulseAudio исправлена для PA 17+ на Ubuntu 24.04: устаревший enable-lfe-remixing заменён на remixing-produce-lfe/remixing-consume-lfe, systemd-юнит больше не устанавливает User=pulse/Group=pulse (PA --system требует root), а tmpfiles.d обеспечивает сохранение /var/run/pulse после перезагрузки.
Новый lxc/install-openwrt.sh добавил поддержку маршрутизаторов на базе OpenWrt (Turris Omnia и др.) с управлением сервисами через procd — расширив варианты развёртывания с 3 (Docker, HA addon, Proxmox LXC) до 4.
Добавлены два механизма надёжности:
- Watchdog зомби-воспроизведения: автоматически перезапускает подпроцесс через 15 секунд
playing=Trueбез аудиоданных (streaming=False), до 3 попыток. Отлавливает ситуации, когда соединение sendspin живо, но аудио-пайплайн сломан. - Изоляция BT churn (опционально): автоматически отключает BT-управление для устройств, которые переподключаются слишком часто в скользящем окне, настраивается через
BT_CHURN_THRESHOLD(0 = отключено, по умолчанию) иBT_CHURN_WINDOW(по умолчанию 300 с). Предотвращает ситуацию, когда нестабильное Bluetooth-устройство занимает адаптер и дестабилизирует другие колонки.
Новый индикатор «застывшего» эквалайзера показывает замороженные красные полоски, когда MA сообщает о воспроизведении, но аудиопоток отсутствует, с текстом «▶ No Audio».
Серия быстрых релизов решала последовательно обнаруживаемые особенности поведения HA Ingress прокси: cache-busting статики через query string (?v=) не работал, потому что Ingress обрезает query-параметры — переключились на версионирование через путь (/static/v2.12.5/app.js). HTML-ответы получили заголовки Cache-Control: no-cache. SSE-поток получил 2 КБ начального padding для проталкивания через буферы прокси, а клиентская логика переподключения SSE была обновлена с «одна ошибка → polling навсегда» на экспоненциальный backoff с 5 попытками.
Демон sendspin теперь запускается только после реального подключения Bluetooth, устраняя фантомные плееры в Music Assistant при старте контейнера.
Глубокий анализ сценария мульти-бридж (несколько бриджей → один MA, кросс-бриджевые sync groups) выявил 6 потенциальных проблем и привёл к двум ключевым доработкам:
-
Авто-заполнение BRIDGE_NAME: при первом запуске hostname машины записывается в
config.json["BRIDGE_NAME"], чтобы пользователь видел предзаполненное значение в Web UI до добавления устройств. Старая опцияBRIDGE_NAME_SUFFIXудалена — больше не нужна при авто-заполнении. Это предотвращает дублирование имён плееров (например, два «JBL Flip 6» с разных хостов), которое путало список плееров MA. -
Видимость кросс-бриджевых sync group: когда плееры с нескольких бриджей входят в одну sync group MA, бейдж группы показывает
🔗 Kitchen Music +2(где +2 = плееры с других бриджей). При наведении раскрывается полный список участников: ✓ для локальных и 🌐 для внешних плееров. Данные берутся из кэша MA API (/api/players→ списки участников sync group), который бридж уже поддерживает.
Деплой v2.13.0 на два LXC-бриджа (Proxmox + Turris OpenWrt) выявил цепочку проблем:
- Waitress 3.x сломал SSE: обновление
waitressподтянуло v3.x, который строго проверяет PEP 3333 и отклоняет hop-by-hop заголовки.Connection: keep-aliveв SSE-ответе вызывалAssertionError— заголовок убран. - Ошибка имени переменной в JS: оба обработчика (polling и SSE) в
app.jsссылались наdata.groups, хотя распарсенная переменная называетсяstatus— устройства не рендерились. Исправлено наstatus.groups. - Несовпадение ID при обогащении групп:
_build_groups_summary()сравнивалgroup_idSendspin (UUID) с syncgroup ID MA (syncgroup_XXX) — разные системы ID, которые никогда не совпадали. Исправлено через резолв MA syncgroup по имени плеера. - Группы отсутствовали в polling-ответе:
/api/statusдля single-device бриджей не включал полеgroups(только SSE), поэтому бейдж не появлялся при polling. - Инцидент с bluetooth.service в LXC: случайный перезапуск
bluetooth.serviceвнутри контейнера Turris (где bluetoothd не может работать) сломал A2DP-состояние PulseAudio, потребовав re-pair с хоста. Усилено:bluetooth.serviceтеперь замаскирован (не просто отключён), аsendspin-client.serviceполучилTimeoutStopSec=15для предотвращения зависаний при остановке.
Проект получил структурированное управление обращениями: 3 YAML-шаблона issue forms (Bug Report с выпадающими списками для среды/аудио, специализированная форма Bluetooth/Audio, Feature Request), 16 меток проекта (type:bug, area:bluetooth, deploy:ha-addon и др.) и Welcome-пост в Discussions с маршрутизацией (Issues для багов/фич, Discussions для помощи/идей).
Полное код-ревью кодовой базы выявило 42 проблемы в безопасности, потокобезопасности, обработке ошибок, надёжности и тестовом покрытии. Все исправлены в одном координированном релизе:
Безопасность (5 исправлений): SSRF через flow_id path traversal в HA auth flow; SSE-эндпоинт мог исчерпать все потоки Waitress (ограничено до 4); неограниченная громкость от сервера могла перегрузить динамики на 200%+; инъекция MAC-адреса в stdin bluetoothctl; /api/status раскрывал MAC, IP и метаданные плееров без авторизации.
Потокобезопасность (6 исправлений): список _clients итерировался без блокировки в ~15 API-эндпоинтах; stop_sendspin() не отправлял уведомление SSE; гонка счётчика zombie restart; чтение конфига без config_lock; несинхронизированная запись учётных данных MA API; пул BT executor слишком мал (2→4) для одновременного переподключения устройств.
Обработка ошибок и валидация ввода (7 исправлений): крэш request.get_json() на не-JSON POST; утечка внутренних строк исключений в 15 ответах об ошибках; крэш IPC-команды громкости на нечисловом вводе; path traversal через сконструированный client_id; путаница типов player_names (строка vs список); set_log_level принимал произвольные цели getattr; force=True ослаблял CSRF-защиту эндпоинта пароля.
Тестовое покрытие (65 новых тестов): с 42 до 107 тестов. Новые тест-файлы для services/bluetooth.py, services/pulse.py, bluetooth_manager.py, services/daemon_process.py, scripts/translate_ha_config.py и routes/api.py. Добавлен общий conftest.py. datetime.UTC заменён на timezone.utc в 4 файлах для совместимости тестов с Python 3.9.
Совместимость с armv7l (хотфикс после релиза): PyAV 12.3.0 (единственная версия, компилирующаяся на armv7l) не имеет AudioLayout.nb_channels, что вызывает крэш FLAC-декодера sendspin с AttributeError — полная тишина. Monkey-patch в services/daemon_process.py заменяет FlacDecoder._append_frame_to_pcm на версию, использующую len(frame.layout.channels). Патч автоматически определяет версию PyAV при запуске и не активируется на PyAV 13+.
Raspberry Pi и UX Docker-установки (v2.16.2): После того как первый пользователь из сообщества попробовал Docker на Raspberry Pi и столкнулся с проблемами конфигурации, добавлены: диагностический скрипт pre-flight (scripts/rpi-check.sh), проверяющий Docker, Bluetooth, аудио, UID и архитектуру перед docker compose up; эндпоинт /api/preflight без авторизации для программной проверки настройки; структурированная таблица диагностики при запуске в entrypoint.sh (видна в docker logs); отдельное руководство по установке на Raspberry Pi (en/ru); исправлена устаревшая документация Docker, которая ещё содержала удалённую capability SYS_ADMIN и не указывала переменные PULSE_SERVER/XDG_RUNTIME_DIR.
В режиме аддона MA находится в приватной Docker-сети — недоступен из браузера пользователя. Бридж добавил HA OAuth popup-поток: веб-UI открывает popup к HA OAuth authorize endpoint, HA аутентифицирует пользователя (включая 2FA/TOTP), а бридж обменивает полученный код на MA session-токен через серверные HTTP-запросы через HA Ingress. Это избавляет от необходимости вручную настраивать MA_API_TOKEN.
Popup-поток требовал взаимодействия пользователя. В Ingress-режиме HA session-токен уже доступен в localStorage (hassTokens). Бридж теперь читает его автоматически при загрузке страницы, вызывает /api/ma/ha-silent-auth, который выполняет полный OAuth-обмен серверно — ноль кликов. Auto-discover тоже запускается при загрузке, так что подключение к MA устанавливается прозрачно.
Расследование постоянных ошибок «authentication failed» в MA monitor выявило фундаментальную проблему: OAuth callback возвращает short-lived session JWT (30-дневный скользящий срок, is_long_lived=False), а не API-токен. Дополнительно, баг в регулярном выражении захватывал #/ (Vue Router hash fragment) как часть JWT, повреждая его.
Исправление: после получения session JWT через OAuth, бридж подключается к WebSocket API MA, аутентифицируется session-токеном и вызывает auth/token/create для получения long-lived JWT (срок 10 лет). Session-токен никогда не сохраняется.
Идемпотентность: перед инициацией OAuth _validate_ma_token() проверяет, валиден ли существующий токен для целевого MA URL — предотвращая создание дублирующих long-lived токенов при перезагрузке страницы или рестарте аддона.
В addon-режиме с SENDSPIN_SERVER=auto обнаружение MA-сервера полагалось на mDNS как последнее средство — но изменение API zeroconf (kwargs вместо позиционных аргументов) сломало callback. Исправление: перед фоллбэком на mDNS бридж теперь извлекает адрес MA-сервера из разрешённого sendspin WebSocket-соединения (connected_server_url). Поскольку sendspin уже обнаружил MA-сервер через собственный mDNS, бридж переиспользует этот адрес для MA API (тот же хост, порт 8095). В большинстве случаев это устраняет необходимость в отдельном mDNS-сканировании.
Предыдущий подход имел фундаментальную проблему: определение addon-режима зависело от поля homeassistant_addon в ответе MA-сервера (/info) — но когда discovery шёл через mDNS-путь (через _enrich_with_server_info вместо validate_ma_url), это поле отсутствовало, addon-режим не определялся и silent auth никогда не срабатывал.
Исправление упростило весь процесс. Бридж теперь отдаёт собственный флаг is_addon (из _detect_runtime()) в ответе discover — без зависимости от метаданных MA-сервера. В addon-режиме discovery пробует http://homeassistant.local:8095 первым (внутренний DNS Supervisor — практически мгновенно), пропуская эвристики SENDSPIN_SERVER и mDNS. Полностью автоматическая silent auth при загрузке страницы заменена полуавтоматическим подходом: кнопка «Sign in with Home Assistant» показывается после обнаружения addon-режима, пользователь нажимает её явно. В Ingress-режиме это выполняет авторизацию одним кликом (без popup); вне Ingress — открывает OAuth popup.
Silent auth из v2.17.4–v2.17.12 пытался делать POST на HA /auth/authorize с Bearer-токеном для получения OAuth-кода — но authorize endpoint HA работает только по GET (отдаёт HTML-страницу согласия) и возвращает HTTP 405. Popup-фоллбэк работал, но требовал ввода логина и пароля.
Подход v2.18.0 полностью обходит HA OAuth. Ingress-сервер MA (порт 8094) автоматически аутентифицирует запросы по заголовкам X-Remote-User-ID / X-Remote-User-Name — тот же механизм, который HA использует внутренне для Ingress-трафика. Оба аддона используют host_network: true, поэтому бридж может обращаться к Ingress-порту MA по localhost:8094. Схема: (1) фронтенд отправляет HA access token из hassTokens в localStorage; (2) бэкенд подключается к WebSocket API HA и вызывает auth/current_user для получения ID и имени пользователя; (3) бэкенд отправляет JSONRPC-запрос к Ingress-эндпоинту MA (http://localhost:8094/api) с заголовками пользователя, вызывая auth/token/create; (4) MA автоматически аутентифицирует Ingress-запрос и создаёт long-lived JWT со сроком 10 лет. Весь процесс невидим для пользователя — один клик кнопки, никаких логинов, никаких popup.
Три патча устранили проблемы, обнаруженные при верификации на реальном HAOS:
v2.18.1 — совместимость websockets. Docker-образ аддона HAOS содержит старую версию websockets (<14), которая не принимает аргумент proxy=None. Добавлена обёртка _ws_connect(), которая пробует с proxy=None, перехватывает TypeError и повторяет без него.
v2.18.2 — сетевая изоляция аддонов HAOS. В HAOS каждый аддон работает в собственном Docker-контейнере с отдельным сетевым namespace — localhost:8094 из контейнера бриджа не достигает Ingress-порта MA. Решение: _find_ma_ingress_url() запрашивает Supervisor API (http://supervisor/addons/{slug}/info) для обнаружения Docker-имени хоста аддона MA и Ingress-порта, затем подключается через Docker DNS (например http://d5369777-music-assistant:8094). Известные slug'и аддона MA (d5369777_music_assistant, _beta, _dev) перебираются по порядку. В конфигурацию аддона добавлены разрешения hassio_api: true и homeassistant_api: true.
v2.18.3 — формат JSONRPC-ответа. MA auth/token/create возвращает токен как сырую JSON-строку при вызове через Ingress-порт, а не в обёртке {"result": "..."}. Парсер ответа теперь обрабатывает оба формата и логирует сырой ответ для диагностики.
Раздел Configuration разрастался органически и нуждался в реструктуризации. Кнопки сохранения стояли посередине формы, Music Assistant Integration был закопан внутри Advanced settings (два клика), таблица BT-устройств имела 9 колонок с горизонтальным скроллом 700px на мобильных, а лейблы были абзацами текста.
Переработка организовала форму в чётко маркированные секции — General, Bluetooth, Music Assistant (вынесен на верхний уровень), Advanced и Authentication — каждая с иконкой и визуальным разделением. Sticky-панель сохранения появляется внизу при наличии несохранённых изменений. Таблица BT-устройств разделена на основную строку (Name, MAC, Adapter, Format) и раскрываемую строку деталей (Listen Address, Port, Delay, Keep-alive), которая автоматически открывается при наличии нестандартных значений.
Обратная связь от пользователей после релиза v2.19.0 привела ко второму раунду полировки. Пользователи отметили, что кнопка Add в списке найденных/спаренных устройств слишком далеко от имени — сложно прицелиться. Панель Advanced settings (в которой осталось всего 4 поля) была ликвидирована — поля перенесены в соответствующие секции.
Ключевые изменения: форма MA теперь сворачивается в саммари при подключении (ссылка «Reconfigure» раскрывает обратно); поля авторизации скрываются при выключенной аутентификации; шеврон раскрытия BT-устройств перенесён влево для привычного tree-style взаимодействия; устройства стартуют свёрнутыми по умолчанию; строки найденных/спаренных устройств стали полностью кликабельными с hover-подсветкой; кнопка Scan перемещена перед +Add Device для discovery-first воркфлоу. Добавлен guard _configLoading, предотвращающий ложный dirty-индикатор при программном заполнении полей формы.
Комплексный код-ревью всей кодовой базы (~10 700 строк в 35 Python-файлах) выявил две критические проблемы: мёртвый эндпоинт /api/bt/reconnect (функция существовала, но отсутствовал декоратор @route — ни один HTTP-запрос не мог до неё дойти) и postMessage('*') с wildcard-origin в OAuth-попапе HA, что нарушало принцип same-origin. Оба исправлены немедленно.
Основной результат — разделение монолита routes/api.py (3 178 строк) — самого большого файла в проекте — на пять сфокусированных модулей: ядро volume/mute/pause осталось в api.py (581 строка); Bluetooth-сканирование/пэйринг/реконнект переехали в api_bt.py (396); интеграция Music Assistant и OAuth-флоу — в api_ma.py (1 216); конфигурация и настройки — в api_config.py (502); статус, SSE-стриминг и диагностика — в api_status.py (647). Каждый модуль регистрирует собственный Flask Blueprint; web_interface.py подключает все пять. Для обратной совместимости добавлены ре-экспорты, чтобы существующие тесты и внешние вызовы продолжали работать без изменений.
Потокобезопасность получила точечные исправления: шесть мест, где глобальный список _clients итерировался без захвата _clients_lock, были исправлены — три в ma_monitor.py через новый хелпер state.get_clients_snapshot(), два в маршрутах конфигурации и MA. Счётчик MaMonitor._msg_id, ранее простой int с инкрементом из нескольких потоков, заменён на itertools.count(1) — атомарный под CPython. Дублирующееся регулярное выражение для MAC-адресов консолидировано в services/bluetooth.py как каноническая функция is_valid_mac().
Все 138 тестов прошли после рефакторинга; ruff check оставался чистым на протяжении всего процесса.
В хедер добавлена кнопка Report, автоматизирующая весь процесс создания баг-репорта. При нажатии вызывается /api/bugreport, собирающий все диагностические данные (устройства, адаптеры, синки, подпроцессы, интеграция MA, окружение, конфиг, логи), маскирующий чувствительные данные (MAC-адреса частично, IP, токены) и возвращающий два артефакта: короткое Markdown-резюме (<4 КБ, пригодное для параметра ?body= URL GitHub issue) и подробный текстовый файл для ручного прикрепления.
Реализация прошла несколько итераций: полный отчёт начинался как Markdown с таблицами и collapsible-секциями, но был упрощён до plain text с выровненными колонками для универсальной читаемости. В короткое резюме добавлены последние 3 строки WARNING/ERROR/CRITICAL из логов и версия MA-сервера (из WS handshake server_info). Реализована валидация формы — кнопка submit неактивна до заполнения заголовка и описания, пустые поля подсвечиваются красной рамкой при нажатии.
Раздел Diagnostics ранее показывал только текущий статус соединений (bluetoothd, D-Bus, синки, устройства, MA-группы). Теперь он включает блок версий/окружения (версия бриджа, тип рантайма, uptime, версия Python, платформа, BlueZ, аудиосервер, RSS-память, версия MA) и статус подпроцессов (pid, alive, zombie restarts, last error). Сборка текстового отчёта вынесена из эндпоинта баг-репорта в _build_full_text_report() и переиспользуется для нового /api/diagnostics/download. Аналогично чтение логов вынесено в _read_log_lines() и переиспользуется для /api/logs/download (500 строк в текстовый файл с таймстампом).
Баннер рестарта переработан: вместо компактных счётчиков статуса (BT ✓ · PA ✓ · SS ✓ · MA …) с раскрываемыми деталями по устройствам — последовательное отображение шагов с полоской прогресса. Статусы устройств и так видны в карточках, поэтому баннер теперь фокусируется на том, что происходит (сохранение → остановка → запуск → подключение устройств → подключение MA → готово). Добавлен предупреждающий баннер при выключенной аутентификации — жёлтая полоса со ссылкой, которая прокручивает к чекбоксу авторизации в Configuration и подсвечивает его. Ссылки в хедере (Report, Docs, GitHub) получили монохромные inline SVG-иконки с currentColor для совместимости с темами.
Следующий патч (v2.20.4) исправил маркер раскрытия секции JWT-токена — нативный ▼ заменён на CSS-псевдоэлемент ::before ▶ с вращением при открытии, как в остальных сворачиваемых секциях — и скорректировал подсказку поля API-токена Music Assistant на «Settings → Profile → Long-lived access tokens».
Аудит документации (v2.20.5) обновил весь документальный корпус: ссылки на версии обновлены с 2.10.6/2.12.2 до 2.20.4, разбиение API-маршрутов отражено в CLAUDE.md, README и contributing-гайдах, web-ui.md переписан (ликвидирована устаревшая секция «Advanced Settings»), 6 скриншотов пересняты с актуального UI HAOS (индикаторы заряда, обновлённые панели конфигурации, диагностика). Также исправлен чекбокс «Show all» в заголовке списка спаренных устройств — он выходил за границу контейнера; надпись перемещена перед чекбоксом с правильным отступом.
Систематический аудит исторических конфиг-ключей и мёртвого кода убрал накопившееся наследие 20+ версий органического роста.
Конфиг-ключ BLUETOOTH_MAC — изначальный параметр проекта с первого коммита — полностью устарел. Автомиграция в load_config() конвертирует его в элемент массива BLUETOOTH_DEVICES при запуске, затем удаляет старый ключ из config.json. Миграция распространена на 23 файла: конфиг-схема, белый список API, JavaScript веб-UI, Docker Compose, скрипт запуска, установщики (RPi, LXC, OpenWrt) и вся документация на английском и русском.
Удалены пять дополнительных устаревших ключей: BRIDGE_NAME_SUFFIX (мёртвый с v2.13.0, когда авто-заполнение BRIDGE_NAME сделало его ненужным), LAST_VOLUME (старое единичное значение громкости, заменённое per-MAC словарём LAST_VOLUMES), keepalive_silence (булевый переключатель, заменённый условием keepalive_interval > 0) и ключ устройства port (переименован в listen_port). Каждое удаление включает автомиграцию для старых конфигов.
Вычищен мёртвый код: get_client_status() (обёртка обратной совместимости из модуляризации API v2.20.3, которая не вызывалась извне), неиспользуемые ре-экспорты в routes/api.py и внутренний алиас _save_device_volume. Конфиг-схема дополнена: TRUSTED_PROXIES и MA_USERNAME добавлены в allowed_keys — оба ключа уже использовались в рантайме, но могли молча удаляться при цикле чтения-записи конфига.
Music Assistant beta 2.8.0b19 изменил формат /auth/login API с плоского {"username", "password"} на вложенный {"credentials": {"username", "password"}, "provider_id": "builtin"} — это сломало процесс логина бриджа. Новый хелпер _ma_http_login() в routes/api_ma.py сначала пробует старый формат (совместимость со stable MA), затем новый вложенный, обрабатывая оба поля ответа — access_token и token. Также исправлен критический баг: библиотека music_assistant_client при любом 401 выдавала "Invalid username or password", что вызывало short-circuit и блокировало fallback через прямой HTTP.
Веб-интерфейс получил серию исправлений: гонка при сохранении токена (скрытое поле MA_API_TOKEN формы содержало старое значение после логина, и "Save & Restart" перезаписывал новый токен) устранена вызовом loadConfig() после логина перед пометкой формы как изменённой. Индикатор "unsaved changes" теперь появляется после всех пяти сценариев логина. Панель аутентификации MA переработана — поля URL и токена перенесены из отдельного коллапсируемого раздела "Advanced" под ссылку Reconfigure, дублирующееся поле URL убрано (оставлено как hidden input), кнопки переименованы в "🔑 Get token" и "🔑 Get token automatically".
Дополнительные UX-улучшения: контекстно-зависимый пустой экран при отсутствии устройств (определяет наличие BT-адаптера и ведёт либо в раздел Adapters с авто-обновлением, либо запускает сканирование устройств), статическая кнопка Save в подвале конфигурации, исправление фантомных карточек плееров при нулевом количестве клиентов и отслеживание dirty-состояния конфига при добавлении/удалении устройств.
Добавлен полнофункциональный демо-режим — при DEMO_MODE=true мост запускается с эмулированными BT-устройствами и симуляцией воспроизведения MA, без оборудования. Пять реалистичных устройств (JBL Flip 6, Sony WH-1000XM4, Marshall Stanmore, Bose SoundLink, Harman Kardon Onyx) циклически проигрывают настоящие метаданные треков из курированного плейлиста с корректной длительностью и прогрессом. Демо развёрнуто на Render.com: sendspin-bt-bridge.onrender.com.
Универсальная проверка обновлений работает как фоновая asyncio-задача, опрашивая GitHub releases API каждый час. При обнаружении новой версии в шапке UI появляется зелёный бейдж со ссылкой на release notes. Три новых API-эндпоинта (/api/update/check, /api/update/info, /api/update/apply) предоставляют инструкции с учётом платформы: LXC-инсталляции получают кнопку «Обновить сейчас» (запускает upgrade.sh); Docker показывает команду docker compose pull; HA addon направляет в Supervisor.
Скрипт LXC upgrade.sh исправлен — добавлена загрузка всех модулей маршрутов (api_bt.py, api_config.py, api_ma.py, api_status.py) и новых файлов (update_checker.py, demo/).
S6 overlay (v2.23.1) заменил Docker --init полноценным PID 1 менеджером процессов через S6 overlay v3.2.0.2 — сбор зомби-процессов, проброс сигналов, автоматический рестарт при падении. Dockerfile HA addon упрощён до тонкой обёртки, отдельный run.sh удалён.
Режим enforce AppArmor (v2.23.6–v2.23.8) оказался сложнее, чем ожидалось. Первая попытка использовала гранулярные правила путей (/app/** rixm, /bin/** rix) — стандартная практика AppArmor — но Docker overlayfs сделала их ненадёжными: AppArmor молча блокировал выполнение без записей в audit-лог на HAOS. Потребовалось три релиза для диагностики (нет доступа к dmesg, ошибка выглядела как проблема файловых прав). Решение пришло из изучения AppArmor-профиля аддона Music Assistant: обобщённые правила file, + signal, вместо гранулярных путей. Границы безопасности обеспечиваются через capabilities, сетевые правила и ограничения сигналов.
Рефакторинг аутентификации (v2.23.9) упростил авторизацию для HA addon. В режиме аддона аутентификация теперь всегда включена — переключатель auth_enabled удалён из опций аддона. Предлагается только HA Core login_flow (с полной поддержкой 2FA/MFA); методы MA credentials и локального пароля скрыты. Имя пользователя HA сохраняется в сессии и отображается рядом с кнопкой «Sign out». Auto-auth через Ingress (обход авторизации по X-Ingress-Path от доверенных прокси) работает без изменений. Docker/standalone-режим сохраняет полный набор методов аутентификации.
Редизайн шапки (v2.23.10) переработал шапку веб-интерфейса в компактный двухстрочный формат. Первая строка: название, inline-версия с тултипом даты сборки, интерактивный бейдж обновления (серый «⟳ up to date» для ручной проверки, превращается в зелёный «⬆ vX.Y.Z» при наличии обновления), ссылки Docs/GitHub/Sign out. Вторая строка: бейдж типа запуска (LXC / Docker / HA Addon), hostname, IP, uptime и цветовые индикаторы состояния (BT x/n · MA x/n с зелёными/жёлтыми/красными точками, плюс ▶ количество воспроизведений).
Модал обновления (v2.23.11) заменил браузерный confirm() кастомным модальным окном. Клик по бейджу обновления теперь показывает карточку с превью релиз-ноутов (markdown очищен до буллет-поинтов) и двумя кнопками: «📋 Release Notes» (открывает GitHub-релиз) и платформо-зависимая кнопка — «⬆ Update Now» для LXC/systemd (вызывает /api/update/apply, сервис перезапускается автоматически), «🏠 Update in HA» для аддона или «📋 Show Instructions» для Docker.
Автообновление (v2.23.12) добавило опцию AUTO_UPDATE для LXC-инсталляций. Тогл в разделе Configuration → Updates (выключен по умолчанию). Когда включён, ежечасная проверка обновлений при обнаружении новой версии автоматически запускает upgrade.sh — сервис обновляется и перезапускается без участия пользователя. Работает только на LXC/systemd (не Docker, не HA addon).
Конфигурация аддона получила tmpfs: true (in-memory temp для продления жизни SD-карты), backup_exclude (исключает логи и кэш из снапшотов HA), auth_api: true (формальное объявление доступа к auth API) и panel_admin: false.
Комплексное экспертное код-ревью всей кодовой базы (~17K строк, 30+ файлов) выявило 3 критических проблемы безопасности, 7 крупных улучшений и 7 мелких исправлений. Все рекомендации реализованы в одной сессии с использованием fleet-mode параллелизма (17 задач в 4 волнах).
Исправления безопасности (v2.25.0): MFA-переменная сессии _ha_login_user утекала между пользователями в одном браузере — теперь очищается во всех 7 путях успешной аутентификации и при GET /login. MAC-адреса из вывода bluetoothctl scan передавались в subprocess-вызовы без повторной валидации — добавлена строгая регулярка _MAC_RE. Три API-эндпоинта молча откатывались к первому устройству при отсутствии player_name в мульти-устройственных конфигурациях — заменено на корректные 400-ответы.
Архитектурные улучшения: монолитный 260-строчный обработчик login() разбит на 4 отдельные функции по типу потока (_handle_ma_login, _handle_ha_via_ma_login, _handle_ha_direct_login, _handle_local_password_login). Дублированная логика поиска клиентов в BT-эндпоинтах вынесена в общие хелперы get_client_or_error() и validate_mac() в routes/_helpers.py. Запись конфига получила атомарный tempfile+rename. 27 широких except Exception заменены на специфичные типы в 6 модулях.
Расширение тестового покрытия: 30 новых тестов на поиск клиентов (мульти-устройство, попытки инъекций), жизненный цикл MFA-сессии (очистка переменных, утечка между пользователями) и кулдаун BT-сканирования (коды 429/409). Общее количество тестов выросло со 150 до 180.
Авто-подтверждение SSP passkey (v2.26.0): TWS-наушники типа HUAWEI FreeClip требуют подтверждения Simple Secure Pairing (SSP) — запрос «Confirm passkey XXXXXX?» от bluetoothctl, на который надо ответить «yes». Функция pair_device() переписана для чтения stdout bluetoothctl в реальном времени через selectors, обнаружения запросов подтверждения passkey и автоматической отправки «yes». Ранний выход при «Pairing successful» для ускорения.
Устойчивость D-Bus для TWS (v2.26.0): TWS-наушники в зарядном кейсе оставляют устаревшие D-Bus-объекты BlueZ, которые выбрасывают DBusException при чтении свойств. Обработка исключений расширена в _dbus_get_device_property(), _dbus_get_battery_level(), _dbus_call_device_method() и is_device_connected(). Добавлен путь авто-реконнекта: когда цикл опроса обнаруживает устройство, подключённое извне (наушники вынуты из кейса), но плеер не запущен — автоматически настраивается аудио и запускается плеер.
Имя пользователя HA в заголовке (v2.26.0): Ingress-сессии (боковая панель HA) ранее не показывали имя — Supervisor не передаёт заголовки идентификации. Теперь _check_auth получает отображаемое имя при первом Ingress-запросе и кэширует его в сессии. Первоначальная реализация (v2.26.0) обращалась к core/api/auth/current_user через SUPERVISOR_TOKEN, но addon-токены получают 401 на этом эндпоинте — исправлено в v2.26.2: имя читается из MA_USERNAME в config.json (сохраняется при авторизации через HA).
Кнопка «Перепроверить» в диалоге обновления (v2.26.0): бейдж версии в заголовке теперь открывает диалог обновления с кнопкой 🔄 «Перепроверить» — полезно после применения обновления или когда вышла новая версия после последней часовой проверки.
Плавный рестарт (v2.26.1): перезапуск бриджа ранее вызывал слышимые глитчи — PA-синки уничтожались и пересоздавались, sendspin перестраивал потоки, звук заикался несколько секунд. Три улучшения устраняют проблему:
- Мьют перед рестартом:
saveAndRestart()в веб-интерфейсе мьютит все локальные PA-синки через флагforce_localперед инициацией рестарта. Это не трогает MA (плееры в sync-группах на других бриджах продолжают играть) — мьют только на уровне PulseAudio. - Мьют при старте + авто-анмьют:
daemon_process.pyмьютит PA-синк сразу после созданияBridgeDaemon. Корутина_startup_unmute_watcherждётaudio_streaming=True, выжидает дополнительные 1,5 с для стабилизации, затем снимает мьют. Если аудио не стримится 60 с, unmute пропускается (фикс v2.26.3 — ранее завершение watcher'а по таймауту убивало daemon черезFIRST_COMPLETED). - Кеш имён синков:
LAST_SINKS[mac]сохраняется вconfig.json(аналогичноLAST_VOLUMES[mac]). При рестартеconfigure_bluetooth_audio()сначала пробует кешированный синк через проверкуget_sink_volume()— если валидный, пропускает 3-секундную задержку профиля A2DP и цикл повторных попыток.
Серверный graceful shutdown (v2.26.4): _graceful_shutdown() ранее отправлял {"cmd": "pause"} в stdin подпроцесса, что ставило плеер в MA на паузу — затрагивая участников sync-группы на других бриджах. Теперь мьютит PA-синки напрямую через aset_sink_mute() перед остановкой подпроцессов. Работает для всех способов рестарта (systemd, Docker restart, HA auto-update, CLI), а не только через saveAndRestart() веб-интерфейса.
Переработка детекции зомби-воспроизведения (v2.26.4): watchdog зомби-состояния (красный эквалайзер → рестарт подпроцесса) ранее срабатывал при сохранении playing=True и audio_streaming=False более 15 с. Это вызывало ложные рестарты при ре-анкоринге, калибровке sync-группы и смене треков — PA-буферы ещё играли звук, пока флаг был на мгновение False. Теперь watchdog отслеживает _has_streamed для каждой сессии подпроцесса: срабатывает только когда аудио никогда не приходило в текущей сессии, ловя реально зависшие подпроцессы без прерывания нормальных пауз воспроизведения.
Удаление устаревшего move-sink-input (v2.26.1): _ensure_sink_routing() и флаг _sink_routed удалены из BridgeDaemon. Этот код был пережитком архитектуры до PULSE_SINK (Итерация 1, v2.1), где потоки приходилось реактивно перемещать в нужный синк. При архитектуре subprocess-per-speaker (каждый процесс имеет PULSE_SINK в env) PA направляет новые sink-input'ы в правильный синк с первого сэмпла. Вызов move-sink-input был не только лишним, но и вредным — он вызывал PA-глитч, запускающий ре-анкоринг и создающий потенциальный feedback loop (защищённый _sink_routed, но всё равно добавляющий задержку). amove_pid_sink_inputs() остаётся в services/pulse.py как диагностическая утилита.
Коррекция маршрутизации синков после старта (v2.26.5): несмотря на корректно выставленный PULSE_SINK в окружении подпроцесса, PulseAudio может направить sink-input на default sink. Все подпроцессы имеют одинаковый application.name (ALSA plug-in [python3.12]), и PA запоминает последний использованный синк для этого имени — даже с restore_device=false. Фикс возвращает amove_pid_sink_inputs() как одноразовую коррекцию в _startup_unmute_watcher: после audio_streaming=True подпроцесс перемещает свои sink-input'ы на правильный синк перед снятием мьюта. В отличие от удалённого _ensure_sink_routing() (который срабатывал реактивно при каждом format change внутри BridgeDaemon), этот вызов однократный — на старте, после подтверждения потока.
Точность индикатора эквалайзера (v2.26.5): audio_streaming выставлялся в True только в _handle_format_change(), который вызывается при первом аудио-чанке с метаданными кодека. При ре-анкоре или смене трека с тем же форматом _handle_format_change повторно не вызывается — но _on_stream_event("stop") уже сбросил audio_streaming=False. Результат: звук играет, эквалайзер красный (stale). Исправлено: audio_streaming=True ставится также в _on_stream_event("start") когда audio_format уже настроен.
Глобальное включение/отключение устройства (v2.27.0): флаг enabled переосмыслен из BT-подсказки в полноценное управление жизненным циклом устройства. При enabled=false устройство полностью удаляется из всех стеков: не создаётся SendspinClient, нет BluetoothManager, нет подпроцесса, нет регистрации плеера в MA. Метаданные устройства (имя, MAC, адаптер) сохраняются в конфиге и отображаются как затемнённый чекбокс в Configuration → Devices. Повторное включение требует рестарта контейнера для пересоздания полного стека.
Это отличается от BT Release/Reclaim (set_bt_management_enabled), который затрагивает только Bluetooth-уровень — клиентский объект остаётся в памяти, его можно вернуть без рестарта, и устройство остаётся видимым в дашборде.
Очистка MA-плеера при отключении (v2.27.0): при отключении устройства через чекбокс в конфигурации, API-обработчик вызывает set_bt_management_enabled(False) на активном клиенте перед пометкой устройства как отключённого. Это останавливает подпроцесс демона, который отключает WebSocket к MA, вызывая ClientRemovedEvent — плеер снимается с регистрации немедленно, а не остаётся «недоступным» до следующего цикла очистки MA.
Умные индикаторы здоровья (v2.27.0): новое поле bt_released_by в DeviceStatus отслеживает причину освобождения устройства — "user" для ручной кнопки Release, "auto" для детекции churn'а (_check_reconnect_churn) или порога переподключений (_handle_reconnect_failure), null при включённом состоянии. Индикатор здоровья в шапке теперь полностью исключает вручную освобождённые устройства из подсчётов BT/MA (они показываются отдельным серым счётчиком — «N released»). Автоматически отключённые устройства по-прежнему учитываются как нездоровые, сохраняя индикатор жёлтым/красным для сигнализации о необходимости внимания. Бейдж на карточке устройства соответственно меняется: серый «Released» для ручного, оранжевый «Auto-disabled» для автоматического.
Удаление BT-пары из UI (v2.27.1): список «Already paired» в Configuration теперь имеет кнопку ✕ Remove на каждой строке. Клик вызывает POST /api/bt/remove → bt_remove_device() → bluetoothctl remove <MAC>. Строка затухает и список обновляется через 1,5 с. Ранее удаление устаревших сопряжений требовало SSH-доступа для ручного запуска bluetoothctl remove.
Редизайн индикатора рестарта (v2.27.1): индикатор прогресса рестарта перенесён из отдельного полноширинного баннера (между шапкой и контентом) внутрь карточки шапки как третью строку. Визуальные изменения: эмодзи-иконки статуса (💾🔇🔄⏳🔗🎵✅#fef3c7, #d1fae5, #fee2e2) на нативные для темы белый-на-primary, что корректно работает в обоих режимах (светлом и тёмном). Полоса прогресса использует rgba(255,255,255,0.15) для трека и rgba(255,255,255,0.7) для заливки — тонко, но заметно на синем фоне шапки. Layout shift для контента страницы отсутствует, так как баннер растёт внутри карточки шапки.
Исправление эндпоинта BT remove (v2.28.0): POST /api/bt/remove падал с ошибкой 500 на Proxmox LXC, потому что validate_mac() в routes/_helpers.py возвращает bool, а код эндпоинта использовал паттерн err = validate_mac(mac); if err: return err — возвращая True как Flask-ответ, что Flask отклонял с TypeError. Исправлено на if not validate_mac(mac): return jsonify({"error": "Invalid MAC address"}), 400.
Имя пользователя HA из Ingress-заголовков (v2.28.0): HA addon всегда показывал «HA User» вместо реального имени залогиненного пользователя. Причина: _resolve_ingress_user() использовала захардкоженную строку, когда MA_USERNAME не указан в конфиге. Исправлено: _check_auth() теперь читает заголовки X-Remote-User-Display-Name и X-Remote-User-Name, которые Ingress-прокси HA Supervisor отправляет с HA 2024.x. Заголовки доверяются только от IP прокси Supervisor'а (172.30.32.2/127.0.0.1/::1) — подделанные версии от внешних клиентов отбрасываются.
Редизайн модального окна баг-репорта (v2.28.0): модальное окно было визуально несогласовано с остальным UI — эмодзи-иконки, захардкоженный #1a73e8 синий цвет, отсутствие кнопки закрытия, отсутствие поддержки клавиатуры. Редизайн: акцентная полоса --primary-color в шапке с кнопкой ✕, инлайновые SVG-иконки (баг, GitHub, копирование, инфо) с currentColor, CSS-спиннер для загрузки, инлайновые сообщения об ошибках валидации, закрытие по Escape, анимация fade-in/slide-up, тёмная тема для превью диагностики.
Компактный столбец Connection (v2.28.0): столбец Connection занимал ~176px с дублирующим текстом «Connected»/«Disconnected» при наличии цветных точек. Редизайн: текст скрыт (.conn-text { display: none }), точки (зелёная/красная/янтарная/серая) самодостаточны, полный текст доступен через нативный title-тултип. MAC-адрес и URI сервера полностью удалены. Столбец сужен до 85px, освобождая ~100px для столбца identity. На мобильных (≤840px) текст всегда видим.
Оптимизация столбца identity (v2.28.0): все элементы (чекбокс, имя, released, eq-bars, батарея) были в одной flex-строке, которая неопрятно переносилась при длинных именах. Перестроен в две чистые строки: строка 1 (identity-title-row) — чекбокс + имя плеера (flex:1; text-overflow:ellipsis) + eq-bars; строка 2 (identity-meta-row) — бейджи released, батарея и группа инлайново. MAC-адрес и WebSocket URL полностью убраны с дашборда.
Редизайн модала обновления (v2.28.1): диалог обновления был визуально несогласован — эмодзи-иконки, захардкоженный #2e7d32 зелёный, отсутствие кнопки закрытия и поддержки клавиатуры. Редизайн по паттерну баг-репорта: зелёная (--success-color) акцентная полоса с SVG-иконкой и ✕, строка сравнения версий v2.28.0 → v2.28.1, SVG-иконки на всех кнопках, Escape для закрытия, анимации brFadeIn/brSlideUp, тематические CSS-переменные.
Бейдж адаптера (v2.28.1): имя BT-адаптера (hci0) в столбце Connection было обычным текстом 11px. Переоформлен как компактный нейтральный бейдж — 9px uppercase, фон и рамка --divider-color, радиус 3px — по паттерну фиолетового бейджа api, но в серо-белом варианте.
Расположение эквалайзера (v2.28.1): eq-bars были прижаты к правому краю столбца identity из-за flex:1 на .device-card-title. Убран flex:1, теперь eq-bars располагаются сразу после текста имени плеера.
Убраны заголовки столбцов (v2.28.1): заголовки Playback, Volume и Sync удалены — содержимое столбцов (транспортные кнопки, слайдер громкости, смещение синхронизации) очевидно без подписей. Заголовок Connection был уже скрыт через CSS в v2.28.0.
Сопоставление групп по player-id (v2.29.0): бейджи MA-групп сопоставлялись по нечёткому сравнению имён плееров — "ENEBY 30 @ Proxmox" сопоставлялся с "ENEBY 30" — что ломалось на хостах с другими суффиксами бриджа или когда MA возвращал полное квалифицированное имя. Рефакторинг: используется стабильный player_id (UUID, генерируемый из MAC) для сопоставления: state.py хранит player_id для каждого клиента, api_ma.py разрешает группы по player_id вместо подстроки имени. Player_id детерминистичен (_player_id_from_mac() в config.py) и никогда не меняется для данного устройства.
Редизайн карточек устройств (v2.29.0): карточки переработаны из 5-столбцовой CSS-сетки в строчный лейаут. Статус-индикаторы заменены с status-indicator дивов на компактные status-dot спаны с цветовыми классами (green/red/orange/grey). Отображение синхронной группы — в формате чипа. Задержка — в формате ±Nms. Кнопка паузы — символ ⏸. Кнопки shuffle и repeat всегда видимы при активном MA (ранее — только при наведении).
Подсветка ошибок в Report (v2.29.0): ссылка Report в шапке теперь становится жёлтой (#f59e0b), когда последние 20 записей лога содержат ERROR или CRITICAL. CSS-класс .has-errors переключается в renderLogs() на элементе #report-link, в формате предупреждающего amber-паттерна.
Жёлтый акцент модала баг-репорта (v2.29.0): шапка модала баг-репорта изменена с синего (--primary-color) на amber (#f59e0b), кнопка отправки — с синей на amber с hover #d97706. Визуально отличает от зелёного модала обновления — жёлтый для «внимание/предупреждение», зелёный для «позитивное действие».
Баг released → disabled (v2.29.0): при перезапуске цикл синхронизации при старте вызывал persist_device_enabled(name, bt_management_enabled) для всех клиентов. Для «released» устройств bt_management_enabled=False записывался как enabled: false в config.json, из-за чего устройство полностью пропускалось при следующем запуске. Исправлено: цикл синхронизации теперь пишет enabled=true только для не-released устройств, сохраняя различие между «BT released» (загружается, но не управляет BT) и «globally disabled» (полностью пропускается).
Кнопка Disable (v2.29.0): добавлена кнопка ⛔ Disable в строку действий карточки устройства (после Release), вызывающая confirmDisableDevice() с диалогом подтверждения перед переключением enabled-состояния через существующий эндпоинт /api/device/enabled.
Модал BT Info (v2.30.0): showBtDeviceInfo() ранее вызывал bluetoothctl info <MAC> и выводил сырой текст через браузерный alert() — функционально, но некрасиво, нельзя выделить текст, визуально не вписывается в интерфейс. Заменён на стилизованное модальное окно, использующее CSS-классы модала баг-репорта (.br-overlay, .br-modal, акцентная полоса в шапке с кнопкой ✕). Вывод рендерится в блоке предформатированного текста с кнопкой Copy. Модал закрывается по Escape и доступен с клавиатуры.
Перезагрузка BT-адаптера (v2.30.0): добавлена кнопка ↻ Reboot рядом с каждым обнаруженным BT-адаптером в Configuration. Первоначально был дизайн с парой кнопок On/Off, но цикл переподключений BluetoothManager автоматически включает адаптер обратно после power-off — кнопка Off оказалась бесполезной. Итоговое решение — одна операция Reboot (выключение → задержка 3 с → включение) с блокировкой кнопки на время операции. UI-эквивалент bluetoothctl power off && sleep 3 && bluetoothctl power on — полезен для восстановления зависшего BT-стека без SSH-доступа.
Обратный отсчёт кулдауна сканирования (v2.30.1): 30-секундный кулдаун BT-сканирования ранее не давал обратной связи — кнопка Scan просто возвращала 429 с общим сообщением. Теперь бэкенд включает retry_after секунд в тело ответа 429, а фронтенд запускает видимый обратный отсчёт на лейбле кнопки (🔍 Scan (28s) → 🔍 Scan (27s) → ... → 🔍 Scan). Отсчёт стартует и при отклонённой попытке сканирования — пользователь всегда видит, сколько осталось ждать, даже если пропустил момент исходного запуска скана.
Скачивание/загрузка конфига (v2.30.2): две новые кнопки в подвале секции Configuration обеспечивают портабельность конфигурации. ⬇ Download сохраняет сырой config.json с именем, содержащим временную метку ({bridge_name}_SBB_Config_{YYYYMMDD_HHMMSS}.json) — удобно для бэкапов перед рискованными изменениями или клонирования настроек на другой хост. ⬆ Upload заменяет текущий конфиг из JSON-файла, но сохраняет чувствительные к безопасности ключи (AUTH_PASSWORD_HASH, SECRET_KEY, MA_ACCESS_TOKEN, MA_REFRESH_TOKEN) из текущего конфига — загрузка бэкапа с другого инстанса не стирает учётные данные. Эндпоинт загрузки валидирует JSON-структуру, формат MAC-адресов и диапазоны портов перед записью.
Фикс индикатора мьюта (v2.30.3): после работы над плавным рестартом (v2.26.1) _startup_unmute_watcher в daemon_process.py мьютит PA-синк при старте подпроцесса (чтобы скрыть щелчки ре-анкоринга), затем снимает мьют после стабилизации аудио или по таймауту. Баг: после снятия мьюта watcher устанавливал status["sink_muted"] = False, но никогда не вызывал _on_status_change() для отправки обновлённого статуса родительскому процессу через JSON-line IPC. Родительский процесс сохранял устаревший sink_muted=True со старта, и веб-интерфейс показывал все плееры как замьюченные бессрочно — иконка мьюта не снималась. Исправлено: колбэк _on_status_change передаётся в watcher и вызывается после снятия мьюта, что отправляет скорректированный статус родителю и триггерит SSE-пуш в браузер.
Сокращение таймаута startup unmute (v2.30.3): таймаут _startup_unmute_watcher уменьшен с 60 с до 15 с. Значение 60 с осталось со времён ранней разработки, когда настройка BT-аудио была ненадёжной. На практике неактивные плееры (без стриминга) сидели в замьюченном состоянии целую минуту после каждого рестарта, пока watcher не сдавался. 15 с более чем достаточно для начала потока аудио, если он собирается начаться.
Реорганизация UI (v2.30.4): порядок кнопок был непоследовательным — в одних секциях основное действие было первым, в других — последним. Стандартизировано: секция Adapters: + Add Adapter перед ↺ Refresh. Секция Devices: + Add Device перед 🔍 Scan. Результаты сканирования: Add перед Add & Pair (переименован из «Pair & Add» для соответствия фактическому порядку операций). Сопряжённые устройства: кнопка Add первая, затем кнопки действий (BT Info, Reset & Reconnect, ✕) сгруппированы справа с CSS :has() изоляцией hover — наведение на одну кнопку не подсвечивает всю строку. Подвал конфигурации: левая группа (Save, Save & Restart), правая группа (⬇ Download, ⬆ Upload).
BT-информация об устройствах в баг-репорте (v2.30.5): _collect_bt_device_info() теперь запускает bluetoothctl info <MAC> для каждого настроенного устройства и добавляет флаги состояния paired/trusted/connected/bonded/blocked в текст диагностики баг-репорта. Ранее для отладки BT-проблем по баг-репорту приходилось просить пользователя подключиться по SSH и запустить bluetoothctl info вручную — теперь отчёт содержит всё необходимое для удалённого анализа.
Фиксы вёрстки дашборда (v2.30.6): три CSS-проблемы — блок «No Bluetooth devices configured» в пустом состоянии занимал только одну колонку грида вместо полной ширины (grid-column: 1 / -1); наведение на любую карточку устройства вызывало расширение всех карточек в ряду из-за дефолтного align-items: stretch в CSS Grid (исправлено на align-items: start); всплывающая обложка альбома при наведении на название трека обрезалась overflow: hidden родительских контейнеров.
Бейдж версии → release notes (v2.30.6): бейдж версии в шапке (например v2.30.6) теперь является ссылкой <a> на соответствующую страницу релиза на GitHub — быстрый способ проверить, что изменилось в запущенной версии, без ручного перехода на GitHub.
Имя пользователя → ссылка на профиль (v2.30.6): имя пользователя в шапке теперь кликабельно и ведёт на страницу профиля. В режиме HA addon — ссылка на профиль HA (/profile). В standalone-режиме имя перемещается из строки иконок в строку статусов (рядом с BT 3/3 · MA 3/3) и ссылается на профиль MA при подключённом MA или на профиль HA при аутентификации через HA. Метод аутентификации (ma, ha, ha_via_ma, password) сохраняется в Flask-сессии и передаётся в шаблон как data-auth-method, который JS-обработчик статуса использует для вычисления правильного URL профиля.
Полный код-ревью (v2.30.7): проведён всесторонний код-ревью всей кодовой базы (~22K строк, 71 файл) — выявлено 66 потенциальных проблем. После верификации каждой находки на реальном коде подтверждено 53 — 13 оказались ложными или уже устранёнными. Подтверждённые находки сгруппированы в 15 задач, охватывающих безопасность, конкурентность, целостность данных и инфраструктуру.
Фикс XSS в странице HA-авторизации (v2.30.7): эндпоинт api_ma_ha_auth_page подставлял query-параметр ma_url напрямую в инлайн-JavaScript шаблон через замену строки — классическая отражённая XSS. Атакующий мог сконструировать URL с ma_url=';alert(document.cookie)// для выполнения произвольного JS в контексте popup-окна авторизации. Исправлено экранированием через json.dumps() и добавлением валидации схемы URL (допускаются только http/https или пустая строка; javascript: и другие опасные схемы отклоняются с кодом 400).
Инъекция команд через параметр adapter (v2.30.7): пять эндпоинтов в api_bt.py передавали поле adapter из пользовательского ввода напрямую в stdin-команды bluetoothctl без валидации. Поскольку bluetoothctl обрабатывает команды, разделённые переводами строк, значение вида hci0\nremove AA:BB:CC:DD:EE:FF внедряло бы дополнительные команды. Исправлено хелпером validate_adapter() в _helpers.py, который применяет строгое регулярное выражение (^(hci\d+|MAC_FORMAT)$) и отклоняет всё, содержащее переводы строк, точки с запятой или shell-метасимволы.
CSRF-защита (v2.30.7): форма логина (пароль, HA login flow, MFA — всего пять тегов <form> в login.html) отправляла POST-запросы без CSRF-токенов. Хотя JSON API эндпоинты имеют неявную защиту (браузеры не отправят Content-Type: application/json cross-origin без CORS preflight), HTML-форма была уязвима к cross-site form submission. Добавлена генерация per-session CSRF-токена (secrets.token_hex(32)), хранящегося в Flask-сессии, скрытый <input> в каждой форме и timing-safe валидация через hmac.compare_digest() на POST. Невалидный или отсутствующий токен возвращает 403.
Content Security Policy (v2.30.7): CSP-заголовок не был установлен, что означало — любая XSS-уязвимость могла загрузить внешние скрипты, эксфильтровать данные или произвольно модифицировать страницу. Добавлен Content-Security-Policy с ограничением default-src до 'self', script-src и style-src допускают 'unsafe-inline' (необходимо из-за инлайн-обработчиков onclick в app.js), img-src допускает data: URI (для инлайн SVG-иконок), connect-src допускает ws:/wss: (для SSE и WebSocket). Также добавлен X-Content-Type-Options: nosniff на все ответы для предотвращения MIME-type sniffing.
Потеря событий MA monitor (v2.30.7): три метода в ma_monitor.py — _drain_cmd_queue, _send_queue_cmd и _refresh_stale_player_metadata — читали WebSocket-сообщения в цикле, ища ответ с определённым message_id. Несовпадающие сообщения (real-time события от MA: изменения состояния воспроизведения, обновления очереди, статус плееров) молча отбрасывались. В нагруженном инстансе MA это могло привести к потере секунд real-time обновлений. Исправлено логированием несовпадающих сообщений на уровне DEBUG.
Потокобезопасность mDNS-дискавери (v2.30.7): zeroconf-колбэк _on_service_state_change использовал asyncio.ensure_future() для планирования асинхронной работы по разрешению. Этот колбэк выполняется во внутреннем потоке zeroconf, а не в потоке asyncio event loop — ensure_future требует запущенного loop в текущем потоке. Заменено на asyncio.run_coroutine_threadsafe(coro, loop), где loop захватывается перед стартом zeroconf.
Фиксы конкурентности (v2.30.7): исправлены две проблемы потокобезопасности. В sendspin_client.py метод _read_subprocess_output читал prev_volume внутри _status_lock, а new_volume — за его пределами. Между двумя чтениями другой поток мог изменить громкость, делая сравнение невалидным. Оба чтения теперь внутри одной области lock. В state.py функция get_scan_job() возвращала прямую ссылку на внутренний dict вместо копии — вызывающий код мог мутировать внутреннее состояние после снятия lock. Теперь возвращает dict(job).
Санитизация сообщений об ошибках (v2.30.7): 18 API-эндпоинтов в api_bt.py, api_status.py, api_config.py и auth.py возвращали str(e) в JSON-ответах ошибок, раскрывая внутренние пути файлов, детали subprocess-команд и Python-трейсбеки API-клиентам. Заменено на обобщённые контекстно-уместные сообщения (например, «Failed to list adapters», «Bluetooth operation failed»); реальные исключения логируются на стороне сервера через logger.exception().
Инфраструктура (v2.30.7): добавлен запуск pytest в CI-пайплайн (ранее запускались только ruff и mypy — 178 тестов существовали, но не проверялись в CI). Закреплены верхние границы зависимостей (zeroconf<1.0, ruff<1.0, mypy<2.0) для предотвращения неожиданных поломок при мажорных обновлениях. Исправлено предупреждение о deprecation asyncio.get_event_loop() (Python 3.12+) заменой на get_running_loop(). Исправлен shallow copy DEFAULT_CONFIG, который мог вызвать разделяемые мутабельные ссылки между экземплярами конфига. Удалены 8 мёртвых regex-паттернов из routes/api.py (артефакты копирования из api_bt.py). Добавлен лимит размера загружаемого конфига в 1 МБ.
Весь проект разработан человеком совместно с AI-агентами — от архитектурных решений и отладки до документации.
| AI-агент | Роль | Коммитов (Co-authored-by) |
|---|---|---|
| GitHub Copilot (Claude Sonnet 4.6) | Основной рабочий агент: рефакторинг, код, код-ревью, документация | ~540 |
| Claude Code (Anthropic, Claude Sonnet 4.6) | Архитектурный дизайн, сложный дебаггинг, итерации audio routing | ~168 |
Copilot использовался как интерактивный CLI-агент прямо в терминале (gh copilot), Claude Code — для сессий глубокого рефакторинга и диагностики. Фраза «с определённым AI-другом» в первом анонсе в MA-дискуссии отсылает именно к этому рабочему процессу.
Часть коммитов содержит оба тега одновременно — в сессиях, где решение вырабатывалось в Claude Code, а финальный PR проходил ревью в Copilot CLI.