2–7 марта: Архитектурная перестройка и фундамент v2
2–3 марта 2026 — Архитектурная перестройка: 4 итерации за 2 дня (v2.0–v2.5)
Заголовок раздела «2–3 марта 2026 — Архитектурная перестройка: 4 итерации за 2 дня (v2.0–v2.5)»Это самый технически насыщенный период. Решалась одна задача — детерминированная маршрутизация аудио в PulseAudio при нескольких колонках — и было исследовано четыре принципиально разных архитектурных подхода.
Переход от CLI sendspin к in-process aiosendspin (v2.0, 2 марта) — корень проблемы
Заголовок раздела «Переход от CLI sendspin к in-process aiosendspin (v2.0, 2 марта) — корень проблемы»До 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 марта) CLI sendspin заменяется на прямой вызов Python-библиотеки:
# Before v2.0: subprocess + stdout parsingprocess = subprocess.Popen(['sendspin', '--headless', ...])# ~230 lines of stdout parsing via regex
# From v2.0: in-process BridgeDaemonclass BridgeDaemon(SendspinDaemon): # from the aiosendspin package def on_stream_start(self, ...): ... # typed callback def on_volume_change(self, ...): ...SendspinDaemon — это asyncio-класс из PyPI-пакета sendspin (внутренне aiosendspin). Все события приходят через типизированные коллбэки, парсинг не нужен. ~230 строк хрупкого кода удалены; метаданные треков поступают мгновенно.
Однако: теперь все экземпляры BridgeDaemon живут в одном Python-процессе с единым PulseAudio-контекстом. PULSE_SINK — это переменная окружения процесса: задать разные значения для разных демонов внутри одного процесса невозможно.
main process (single PA context) ├── BridgeDaemon A → PA stream → default sink → Speaker ??? └── BridgeDaemon B → PA stream → default sink → Speaker ???PA выбирает default sink — обычно последнюю подключённую BT-колонку. Гарантий нет: поток может попасть в любую колонку. Это стало корнем всех последующих проблем.
Итерация 1: реактивный move-sink-input (v2.1, 3 марта)
Заголовок раздела «Итерация 1: реактивный move-sink-input (v2.1, 3 марта)»sendspin process └─► PA stream ──(move-sink-input on stream event)──► correct BT sinkBridgeDaemon подписывается на события PA-потоков. Как только появляется новый sink-input, он перемещается через pactl move-sink-input на нужный sink.
Проблема: race condition. Между появлением потока и его перемещением 0.5–2 секунды аудио могут воспроизводиться через неправильную колонку. Нестабильно при быстрой смене треков.
Итерация 2: null-sink + loopback (v2.2, 3 марта)
Заголовок раздела «Итерация 2: null-sink + loopback (v2.2, 3 марта)»sendspin ──► PA null-sink (virtual) ──(loopback module)──► real BT sinkСоздаётся виртуальный sink через module-null-sink, и module-loopback соединяет его с реальным BT-sink. sendspin направляется на виртуальный sink — всегда стабильно.
Проблема: module-loopback добавляет дополнительную буферную задержку. Синхронизация в multiroom-группе ломается. К тому же хрупко: PA может сбросить модуль при переподключении BT.
Итерация 3: проактивный PULSE_SINK (v2.4, 3 марта)
Заголовок раздела «Итерация 3: проактивный PULSE_SINK (v2.4, 3 марта)»Ключевое озарение: вместо реакции на неправильно направленный поток, задать направление до его создания.
PULSE_SINK=bluez_sink.AA_BB_CC_DD_EE_FF.a2dp_sink sendspin ...Переменная окружения PULSE_SINK указывает PA-клиенту использовать конкретный sink при создании любого потока. Никакой реактивности, никаких race condition.
Проблема: по-прежнему единый процесс. При нескольких подпроцессах sendspin переменная окружения не наследовалась как ожидалось.
Итерация 4: изоляция подпроцессов (v2.5, 3 марта) — финальное решение
Заголовок раздела «Итерация 4: изоляция подпроцессов (v2.5, 3 марта) — финальное решение»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: подпроцесс → главный через JSON lines на stdout; главный → подпроцесс через JSON на stdin (set_volume, stop).
Доработки на этом же этапе:
- v2.5.1: PA
module-rescue-streams— при переподключении BT-устройства PA перемещает осиротевшие потоки на fallback-sink. Добавлена коррекция: обнаружить перемещение и вернуть поток черезpactl move-sink-inputпо PID. - v2.5.2: защита от feedback loop — корректирующий
move-sink-inputсам генерирует stream event, который снова запускает коррекцию. Добавлен флаг_sink_routedдля блокировки повторного входа.
4 марта 2026 — Модуляризация и UI (v2.5.5–2.6.10, ~77 коммитов)
Заголовок раздела «4 марта 2026 — Модуляризация и UI (v2.5.5–2.6.10, ~77 коммитов)»После решения проблемы маршрутизации — период полировки и расширения.
Разбиение кода на модули
Заголовок раздела «Разбиение кода на модули»| Модуль | Содержание |
|---|---|
services/daemon_process.py | Точка входа подпроцесса |
services/bridge_daemon.py | BridgeDaemon — Sendspin + PA-события |
services/pulse.py | Асинхронные PulseAudio-хелперы |
services/bluetooth.py | BT-утилиты |
services/ma_monitor.py | WebSocket-монитор MA |
services/ma_client.py | REST API клиент MA |
routes/api.py | REST API Flask blueprint |
routes/views.py | HTML-страницы |
routes/auth.py | Аутентификация |
state.py | Общее runtime-состояние, SSE |
config.py | Конфигурация, VERSION |
Пользовательский интерфейс
Заголовок раздела «Пользовательский интерфейс»- Предпочтительный аудиоформат для устройства (v2.5.5): поле
preferred_formatв конфиге устройства. MA может пытаться ресемплировать при multiroom-синхронизации — фиксация формата устраняет ресемплирование. - Прогресс-бар трека (v2.6.6): прогресс-бар с клиентской интерполяцией (JS). Позиция трека из MPRIS-метаданных.
- Статус синхронизации: счётчик re-anchor событий, предупреждение при частых переключениях.
- Имя sink в столбце Volume при наведении — для диагностики без
/api/diagnostics.
Безопасность и производительность
Заголовок раздела «Безопасность и производительность»- v2.6.0–2.6.1: аудит безопасности — валидация входных данных, защита от path traversal в конфиге, корректная инвалидация Flask-сессий.
pause_allотправляет команду один раз на MA-группу, а не на каждого клиента.
Масштабирование и поддержка нескольких мостов (v2.7.x)
Заголовок раздела «Масштабирование и поддержка нескольких мостов (v2.7.x)»К этому моменту накопилось два требования, важных для нетривиальных развёртываний:
1. Поддержка 100+ колонок в одном мосте
При большом количестве устройств однопроцессная модель стала упираться в проблемы параллелизма. Целевой рефакторинг (v2.7.x) включал несколько изменений:
- SSE batching:
notify_status_changed()накапливает обновления в окне 100 мс перед отправкой клиентам. При массовом переподключении (например, все 50 колонок возвращаются после ночи) без батчинга шторм из 50 SSE-событий выстреливает подряд — браузеры не успевают. Батчинг снижает количество событий примерно в десять раз. - ThreadPoolExecutor с явным размером пула:
min(64, N_devices×2+4)воркеров. При 100+ устройствах дефолтный пул Python (os.cpu_count()*5) мог ставить BT-операции в очередь — переподключение одного устройства блокировало остальные. - Переиспользование D-Bus MessageBus: раньше каждая итерация внешнего цикла переподключения создавала и уничтожала новый bus-объект. При 100 устройствах это 100 параллельных bus-соединений к D-Bus daemon — избыточно. Соединение теперь переиспользуется; новое создаётся только когда шина перестаёт отвечать.
- 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. Несколько мостов против одного MA-сервера
Сценарий: большой дом, несколько зон покрытия Bluetooth. Один мост физически не может достать до всех колонок. Решение — несколько экземпляров моста (Docker-контейнеры, LXC-контейнеры, HA addon-ы) против одного MA-сервера.
Проблема: если два моста регистрируют плеер с одним именем → MA считает их одним плеером и сбрасывает очередь.
Решение (v1.3.0, заложено заранее): UUID5 из MAC-адреса как player_id в Sendspin:
player_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, mac_address))UUID детерминирован (идентичен при каждом перезапуске), глобально уникален (MAC уникален) и не зависит от имени. Два моста с разными MAC → два разных player_id → MA видит два независимых плеера даже при одинаковых именах.
Имя моста по умолчанию — Sendspin-<hostname> — тоже сделано уникальным, чтобы в MA сразу было видно, с какой машины пришёл плеер.
5 марта 2026 — Группы и интеграция с MA (v2.7–v2.10, ~141 коммит)
Заголовок раздела «5 марта 2026 — Группы и интеграция с MA (v2.7–v2.10, ~141 коммит)»Keepalive silence (v2.7.x)
Заголовок раздела «Keepalive silence (v2.7.x)»BT-колонки автоматически отключаются после ~30 секунд тишины. В multiroom-сценарии это критично: если между треками очереди есть пауза, колонка уходит в сон и следующий трек начинается с задержкой переподключения 2–5 секунд — группа рассинхронизируется.
Решение: генерировать тихий PCM-сигнал (silence_stream) через PulseAudio, пока колонка считается «активной». Это поддерживает A2DP-соединение без реального аудио.
Групповая пауза через MPRIS (v2.7.x)
Заголовок раздела «Групповая пауза через MPRIS (v2.7.x)»Первая реализация паузы отправляла команду stop напрямую в подпроцесс каждого устройства. Проблема: MA не знал о паузе — статус плеера в MA оставался «Playing», и syncgroup не синхронизировался.
Правильное решение: пауза через MPRIS D-Bus интерфейс (org.mpris.MediaPlayer2.Player.Pause). MA — инициатор через MPRIS → MA корректно обновляет статус всей группы.
Кнопка паузы для устройства, входящего в группу (2+ участника), управляет всей группой, а не одним плеером — невозможно случайно рассинхронизировать группу из веб-интерфейса.
Group API (v2.8.0)
Заголовок раздела «Group API (v2.8.0)»REST API для управления группами: POST /api/group/pause, POST /api/group/play, POST /api/group/volume. Групповые элементы управления в веб-интерфейсе — установка громкости и mute на всех участниках группы одновременно.
Нативная интеграция с MA REST API (v2.9.0)
Заголовок раздела «Нативная интеграция с MA REST API (v2.9.0)»До этой версии мост «не знал» о MA-группах — он видел только своих плееров. Если MA объединял их в syncgroup, возобновление воспроизведения после паузы могло вызвать рассинхронизацию, потому что мост пытался возобновить каждый плеер независимо.
Начиная с v2.9.0, мост подключается к MA REST API:
- Находит syncgroup, содержащий его плееров, через fuzzy name matching.
- При возобновлении воспроизведения вызывает
POST /api/players/player_queues/{group_id}/play— MA возобновляет группу целиком. MA_API_URLиMA_API_TOKEN— новые поля конфигурации.
Серия фиксов в v2.9.1–2.9.4: API-настройки не сохранялись при перезапуске addon-а (translate_ha_config.py не переносил ключи), URL не нормализовался, конфигурация не включалась в allowed_keys.
Персистентный MA WebSocket-монитор (v2.9.5)
Заголовок раздела «Персистентный MA WebSocket-монитор (v2.9.5)»Самое значительное функциональное дополнение со времён изоляции подпроцессов.
services/ma_monitor.py устанавливает персистентное WebSocket-соединение с MA (/api/ws) и подписывается на события player_queue_updated. Когда очередь плеера меняется, мост получает обновление мгновенно.
Что это даёт:
- Now-playing в веб-интерфейсе: трек, исполнитель, альбом, обложка, позиция в очереди.
- Транспортные кнопки: prev/next/shuffle/repeat на карточке устройства — через MA REST API.
- Обложка альбома: тултип при наведении на название трека.
- Прогресс-бар: синхронизирован с позицией из MA.
- Авто-обновление метаданных: при подключении монитора загружаются актуальные данные для всех активных плееров.
Поддержка нескольких групп (v2.9.9–2.10.x)
Заголовок раздела «Поддержка нескольких групп (v2.9.9–2.10.x)»В первоначальной реализации интеграции с MA кэш now-playing был глобальным — один объект на весь мост. Если мост управлял двумя MA syncgroup-ами (например, «Гостиная + Кухня» и «Спальня»), данные второй группы перезаписывали первую.
Рефакторинг — кэш по группам: dict[group_id, NowPlayingData]. Каждая карточка устройства показывает метаданные своей группы.
Соло-плееры (не входящие ни в один syncgroup) получают собственный queue_id в формате up<uuid_without_hyphens>.
Хронология архитектурных решений
Заголовок раздела «Хронология архитектурных решений»| Версия | Дата | Архитектурное решение | Какую проблему решило |
|---|---|---|---|
| v0 (origin) | 1 янв | Один процесс, один BT, опрос | — |
| v1.3.0 | 1 мар | UUID player_id из MAC | Несколько мостов к одному MA + стабильный ID между перезапусками |
| v1.3.16 | 1 мар | MPRIS D-Bus MediaPlayer2 | Нет стандартного интерфейса между MA ↔ мостом |
| v1.4.0 | 2 мар | Разбиение монолита на модули | Неуправляемый рост одного файла |
| v1.7.0 | 2 мар | D-Bus event BT-монитор | 10-секундная задержка при обнаружении отключения |
| v2.0 | 2 мар | CLI sendspin → in-process aiosendspin | Хрупкий парсинг stdout, задержка метаданных — появилась проблема default sink |
| v2.1 | 3 мар | Реактивный move-sink-input | Аудио шло на default sink (не ту колонку) |
| v2.2 | 3 мар | null-sink + loopback | Race condition при move-sink-input |
| v2.4 | 3 мар | Проактивный PULSE_SINK env | Задержка loopback ломала синхронизацию |
| v2.5 | 3 мар | Изоляция подпроцессов по колонкам | PULSE_SINK неприменим внутри одного процесса |
| v2.5.1 | 3 мар | Коррекция PA rescue-streams | Переподключение BT перемещало потоки на fallback |
| v2.5.5 | 4 мар | preferred_format для устройства | Ресемплирование в multiroom-группах |
| v2.6.0 | 4 мар | routes/, services/, state.py | Монолитный web_interface.py |
| v2.7.x | 5 мар | Keepalive silence stream | BT отключается в тишине между треками |
| v2.7.x | 5 мар | Групповая пауза через MPRIS | MA не знал о паузе, группа не синхронизирована |
| v2.8.0 | 5 мар | Group REST API | Нет API для управления группами |
| v2.9.0 | 5 мар | Интеграция с MA REST API | Возобновление группы без MA как инициатора |
| v2.9.5 | 5 мар | Персистентный MA WebSocket-монитор | Нет данных о воспроизведении в реальном времени |
| v2.9.9 | 5 мар | Кэш now-playing по группам | Единый глобальный кэш ломал несколько syncgroup-ов |
| Период | Коммиты | Основной фокус |
|---|---|---|
| 1 января | 14 | Сервис loryanstrant создан и опубликован (+1100 AEDT) |
| 27–28 фев | ~80 | Первые личные коммиты (+0300 MSK): Proxmox LXC, multi-device, HA addon |
| 1 марта | ~49 | Идентификация в MA, MPRIS, HA Ingress, аутентификация, отделение от upstream |
| 2 марта | ~55 | Модуляризация, D-Bus BT-монитор, первые попытки маршрутизации аудио |
| 3 марта | ~91 | 4 итерации маршрутизации аудио → изоляция подпроцессов |
| 4 марта | ~77 | Полировка, preferred_format, UI, безопасность |
| 5 марта | ~141 | Keepalive, группы, MA API, real-time монитор |
| 6 марта | ~41 | MA multi-syncgroup, соло-плееры, документация |
За 7 дней активной разработки проект прошёл путь от однофайлового скрипта для одной колонки до production-ready решения с изоляцией аудио по подпроцессам, нативной интеграцией в экосистемы Music Assistant и Home Assistant и поддержкой multiroom с синхронизацией MA syncgroup-ов.
Статистика проекта
Заголовок раздела «Статистика проекта»Git и релизы
Заголовок раздела «Git и релизы»| Метрика | Значение |
|---|---|
| Всего коммитов | ~466 |
| Автор (Михаил Невский) | ~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 |
Python-зависимости
Заголовок раздела «Python-зависимости»| Пакет | Назначение |
|---|---|
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 | Переменные окружения |