Перейти к содержимому

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 parsing
process = subprocess.Popen(['sendspin', '--headless', ...])
# ~230 lines of stdout parsing via regex
# From v2.0: in-process BridgeDaemon
class 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 sink

BridgeDaemon подписывается на события PA-потоков. Как только появляется новый sink-input, он перемещается через pactl move-sink-input на нужный sink.

Проблема: race condition. Между появлением потока и его перемещением 0.5–2 секунды аудио могут воспроизводиться через неправильную колонку. Нестабильно при быстрой смене треков.

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.

Ключевое озарение: вместо реакции на неправильно направленный поток, задать направление до его создания.

Окно терминала
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.pyBridgeDaemon — Sendspin + PA-события
services/pulse.pyАсинхронные PulseAudio-хелперы
services/bluetooth.pyBT-утилиты
services/ma_monitor.pyWebSocket-монитор MA
services/ma_client.pyREST API клиент MA
routes/api.pyREST API Flask blueprint
routes/views.pyHTML-страницы
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_loop sleep увеличен с 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 коммит)»

BT-колонки автоматически отключаются после ~30 секунд тишины. В multiroom-сценарии это критично: если между треками очереди есть пауза, колонка уходит в сон и следующий трек начинается с задержкой переподключения 2–5 секунд — группа рассинхронизируется.

Решение: генерировать тихий PCM-сигнал (silence_stream) через PulseAudio, пока колонка считается «активной». Это поддерживает A2DP-соединение без реального аудио.

Первая реализация паузы отправляла команду stop напрямую в подпроцесс каждого устройства. Проблема: 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. Групповые элементы управления в веб-интерфейсе — установка громкости и mute на всех участниках группы одновременно.

До этой версии мост «не знал» о 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.

Самое значительное функциональное дополнение со времён изоляции подпроцессов.

services/ma_monitor.py устанавливает персистентное WebSocket-соединение с MA (/api/ws) и подписывается на события player_queue_updated. Когда очередь плеера меняется, мост получает обновление мгновенно.

Что это даёт:

  • Now-playing в веб-интерфейсе: трек, исполнитель, альбом, обложка, позиция в очереди.
  • Транспортные кнопки: prev/next/shuffle/repeat на карточке устройства — через MA REST API.
  • Обложка альбома: тултип при наведении на название трека.
  • Прогресс-бар: синхронизирован с позицией из MA.
  • Авто-обновление метаданных: при подключении монитора загружаются актуальные данные для всех активных плееров.

В первоначальной реализации интеграции с MA кэш now-playing был глобальным — один объект на весь мост. Если мост управлял двумя MA syncgroup-ами (например, «Гостиная + Кухня» и «Спальня»), данные второй группы перезаписывали первую.

Рефакторинг — кэш по группам: dict[group_id, NowPlayingData]. Каждая карточка устройства показывает метаданные своей группы.

Соло-плееры (не входящие ни в один syncgroup) получают собственный queue_id в формате up<uuid_without_hyphens>.


ВерсияДатаАрхитектурное решениеКакую проблему решило
v0 (origin)1 янвОдин процесс, один BT, опрос
v1.3.01 марUUID player_id из MACНесколько мостов к одному MA + стабильный ID между перезапусками
v1.3.161 марMPRIS D-Bus MediaPlayer2Нет стандартного интерфейса между MA ↔ мостом
v1.4.02 марРазбиение монолита на модулиНеуправляемый рост одного файла
v1.7.02 марD-Bus event BT-монитор10-секундная задержка при обнаружении отключения
v2.02 марCLI sendspin → in-process aiosendspinХрупкий парсинг stdout, задержка метаданных — появилась проблема default sink
v2.13 марРеактивный move-sink-inputАудио шло на default sink (не ту колонку)
v2.23 марnull-sink + loopbackRace condition при move-sink-input
v2.43 марПроактивный PULSE_SINK envЗадержка loopback ломала синхронизацию
v2.53 марИзоляция подпроцессов по колонкамPULSE_SINK неприменим внутри одного процесса
v2.5.13 марКоррекция PA rescue-streamsПереподключение BT перемещало потоки на fallback
v2.5.54 марpreferred_format для устройстваРесемплирование в multiroom-группах
v2.6.04 марroutes/, services/, state.pyМонолитный web_interface.py
v2.7.x5 марKeepalive silence streamBT отключается в тишине между треками
v2.7.x5 марГрупповая пауза через MPRISMA не знал о паузе, группа не синхронизирована
v2.8.05 марGroup REST APIНет API для управления группами
v2.9.05 марИнтеграция с MA REST APIВозобновление группы без MA как инициатора
v2.9.55 марПерсистентный MA WebSocket-мониторНет данных о воспроизведении в реальном времени
v2.9.95 марКэш 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 марта~914 итерации маршрутизации аудио → изоляция подпроцессов
4 марта~77Полировка, preferred_format, UI, безопасность
5 марта~141Keepalive, группы, MA API, real-time монитор
6 марта~41MA multi-syncgroup, соло-плееры, документация

За 7 дней активной разработки проект прошёл путь от однофайлового скрипта для одной колонки до production-ready решения с изоляцией аудио по подпроцессам, нативной интеграцией в экосистемы Music Assistant и Home Assistant и поддержкой multiroom с синхронизацией MA syncgroup-ов.


МетрикаЗначение
Всего коммитов~466
Автор (Михаил Невский)~414 коммитов
Loryan Strant (фундамент)14 коммитов
GitHub Actions (CI/CD)38 коммитов
Дней активной разработки9 (27 фев – 6 мар 2026)
Выпущено версий~135 (v1.0.0 → v2.13.1)
Pull Requests54
Самый насыщенный день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-clientMA REST API и WebSocket
flask + waitressВеб-интерфейс и HTTP-сервер
pulsectl-asyncioУправление PulseAudio из asyncio
dbus-fast + dbus-pythonD-Bus: мониторинг BT и MPRIS
websocketsСвязь с MA WebSocket
psutilСистемная информация
python-dotenvПеременные окружения