/ ROADMAP_V3.md
ROADMAP_V3.md
1 # sendspin-audio-bridge — Roadmap v3.0+ 2 3 ## Контекст 4 5 Текущий `ROADMAP.md` (фазы 1–6) закрывает внутренний рефакторинг BT-bridge: snapshot-модели, 6 оркестратор, versioned IPC, lifecycle-сервисы, события. Это фундамент. **v3.0 строится поверх него** 7 и расширяет проект до универсального аудио-bridge с несколькими backend-типами. 8 9 ### Что MA уже закрывает — не дублируем 10 11 Snapcast output, SlimProto/Squeezelite, AirPlay 1+2, Chromecast+Sendspin, DLNA, WiiM (MA 2.9.0). 12 13 ### Незакрытые ниши, которые занимает bridge 14 15 1. **A2DP Bluetooth** — ядро проекта, MA не имеет нативного BT-вывода вообще 16 2. **Любой локальный аудио-выход** (ALSA, PA sink, USB DAC, HDMI) как MA-плеер 17 3. **Snapcast → любой выход** — bridge как клиент, а не сервер (другое направление потока) 18 4. **VBAN** — MA VBAN alpha и только receive; bridge добавляет send→output путь 19 5. **LE Audio / LC3** — DIY-готовность ~2027, закладываем инфраструктуру сейчас 20 6. **Auracast broadcast** — RPi как TX без паринга, очень долгосрочно 21 22 ### Интеграционная модель 23 24 MA придерживается принципа "не привязываться к hardware". Bridge физически управляет BT-адаптерами, 25 PulseAudio синками и переподключением устройств — это hardware-специфично по природе. Поэтому: 26 27 - **Правильный путь интеграции:** bridge → HA addon → `media_player` entities → MA через 28 `hass_players` provider 29 - MA player provider в upstream **не планируется** — противоречит философии MA 30 - **HA Custom Component (HACS)** — центральный механизм интеграции, не "nice to have" 31 - OpenHome OHRenderer — для standalone discovery (Linn/Kazoo/Lumin) без MA 32 33 --- 34 35 ## Архитектурная эволюция 36 37 ### Сейчас (v2.x) 38 39 ``` 40 [MA/Sendspin Server] 41 ↓ Sendspin protocol 42 [sendspin-bt-bridge] 43 ├── BluetoothManager × N (per device) 44 ├── SendspinClient × N → subprocess(PULSE_SINK=bluez_sink.MAC) 45 ├── Flask API 46 └── state.py (shared mutable state) 47 ``` 48 49 ### v3.0 — Backend Abstraction Layer 50 51 ``` 52 [MA / Sendspin Server] 53 ↓ Sendspin protocol (per registered player) 54 [sendspin-audio-bridge] 55 ├── BackendOrchestrator 56 │ ├── BluetoothA2DPBackend ← текущее, обёрнутое 57 │ ├── LocalSinkBackend ← v3.1: ALSA/PA/PW синки 58 │ ├── USBAudioBackend ← v3.1: USB DAC auto-discover 59 │ ├── VirtualSinkBackend ← v3.1: null-sink, loopback 60 │ ├── SnapcastClientBackend ← v3.2: receive от Snapcast-сервера 61 │ ├── VBANBackend ← v3.2: VB-Audio UDP 62 │ ├── LEAudioBackend ← v3.4: BLE LC3 (experimental) 63 │ └── AuracastTXBackend ← v3.5: BLE broadcast 64 ├── PlayerRegistry ← единый реестр, backend-агностик 65 ├── Bridge Core ← Flask, config v2, HA/MA integration 66 └── SubprocessPool ← оптимизированный, lazy spawn 67 ``` 68 69 ### `AudioBackend` интерфейс 70 71 ```python 72 class AudioBackend(ABC): 73 async def connect(self) -> bool: ... 74 async def disconnect(self): ... 75 def get_sink_name(self) -> str | None: ... # PA sink, если применимо 76 def get_capabilities(self) -> BackendCapabilities: ... 77 async def set_volume(self, level: int): ... 78 async def get_volume(self) -> int: ... 79 def get_status(self) -> BackendStatus: ... 80 ``` 81 82 Subprocess (`daemon_process.py`) остаётся backend-агностичным: получает `PULSE_SINK` или 83 `AUDIO_DEVICE` и запускает sendspin-плеер. Backend обеспечивает готовность назначения до старта 84 subprocess. 85 86 --- 87 88 ## Config schema v2 89 90 ```json 91 { 92 "CONFIG_SCHEMA_VERSION": 2, 93 "players": [ 94 { 95 "id": "living-room-bt", 96 "player_name": "Living Room", 97 "backend": { 98 "type": "bluetooth_a2dp", 99 "mac": "FC:58:FA:EB:08:6C", 100 "adapter": "hci0", 101 "prefer_sbc_codec": false 102 }, 103 "static_delay_ms": -600, 104 "listen_port": 8928, 105 "enabled": true 106 }, 107 { 108 "id": "kitchen-local", 109 "player_name": "Kitchen DAC", 110 "backend": { 111 "type": "local_sink", 112 "sink_name": "alsa_output.usb-Focusrite_2i2.analog-stereo" 113 }, 114 "static_delay_ms": 0, 115 "enabled": true 116 } 117 ], 118 "adapters": [] 119 } 120 ``` 121 122 Миграция: `scripts/migrate_v2_to_v3.py` — автоматически конвертирует `bluetooth_devices[]` → 123 `players[]` с `backend.type=bluetooth_a2dp`. 124 125 --- 126 127 ## Phase 0: Rebrand & Foundation — v3.0.0 128 129 **Ветка:** `dev/v3.0` (нестабильная, breaking changes разрешены) 130 131 **Prerequisite:** завершение фаз 1–3 текущего `ROADMAP.md` (snapshot-модели, оркестратор, 132 versioned IPC). 133 134 ### Переименование 135 136 | Было | Стало | 137 |------|-------| 138 | `sendspin-bt-bridge` | `sendspin-audio-bridge` | 139 | `ghcr.io/trudenboy/sendspin-bt-bridge` | `ghcr.io/trudenboy/sendspin-audio-bridge` | 140 | HA addon slug `sendspin_bt_bridge` | `sendspin_audio_bridge` | 141 142 ### Deliverables 143 144 - `AudioBackend` ABC + `BackendCapabilities` + `BackendStatus` 145 - `BluetoothA2DPBackend` — обёртка вокруг существующего `BluetoothManager` 146 - `BackendFactory.from_config(player_config)` → `AudioBackend` 147 - `PlayerRegistry` — единый реестр вместо `bluetooth_devices` в `state.py` 148 - Config schema v2 + `scripts/migrate_v2_to_v3.py` 149 - Новый HA addon manifest, переименованный Docker image 150 - Обновлённая архитектурная документация в `docs-site` 151 152 --- 153 154 ## Phase 1: Local Audio Backends — v3.1.x 155 156 **Цель:** любой локальный аудио-выход хоста становится MA-плеером. 157 **Целевое железо:** x86 LXC (Proxmox), RPi с DAC/HDMI. 158 159 ### v3.1.0 — LocalSinkBackend (PulseAudio / PipeWire) 160 161 Самый близкий к текущему коду backend: использует `pulsectl-asyncio` (уже в проекте). 162 163 ```python 164 class LocalSinkBackend(AudioBackend): 165 sink_name: str # "alsa_output.usb-Focusrite_2i2.analog-stereo" 166 auto_discover: bool # переподключать если синк исчез и появился снова 167 168 async def connect(self) -> bool: 169 sink = await afind_sink_by_name(self.sink_name) 170 return sink is not None 171 172 def get_sink_name(self) -> str: 173 return self.sink_name # subprocess получает PULSE_SINK=this 174 ``` 175 176 - Нет reconnect loop (синк присутствует пока система работает) 177 - Нет `BluetoothManager` — subprocess стартует сразу 178 - Volume: тот же `aset_sink_volume()` из `services/pulse.py` 179 - Web UI: dropdown «Выбрать синк» через `pactl list sinks short` 180 181 Референс: Squeezelite + ALSA — стандартный паттерн для RPi плееров. Bridge делает то же через 182 Sendspin/MA. 183 184 ### v3.1.1 — ALSA Direct Backend 185 186 Для контейнеров без PA/PW (OpenWrt LXC, stripped containers): 187 188 ```python 189 class ALSADirectBackend(AudioBackend): 190 device: str # "hw:0,0" или "plughw:CARD=U192k" 191 192 def get_sink_name(self) -> None: 193 return None # нет PA sink 194 195 # subprocess запускается с AUDIO_DEVICE=hw:0,0 196 # через sendspin --audio-device флаг 197 ``` 198 199 ### v3.1.2 — USB Audio Auto-Discovery 200 201 ``` 202 USB DAC подключён → udev event → bridge обнаруживает → UI уведомляет → опционально авторегистрирует 203 ``` 204 205 - `pyudev` для мониторинга udev событий (добавление/удаление USB audio) 206 - `pactl list sinks` после события → сопоставление нового синка с USB-устройством 207 - UX-цель: «Подключи USB DAC → появляется в MA за 5 секунд» 208 - `auto_register: true` — автоматически создаёт плеер в конфиге 209 210 ### v3.1.3 — VirtualSinkBackend 211 212 Кейсы: тестирование без железа, мониторинг/запись, loopback. 213 214 ```python 215 class VirtualSinkBackend(AudioBackend): 216 sink_type: Literal["null", "loopback", "combine"] 217 stream_output: bool # если True — поднять HTTP stream (IceCast/OGG) 218 219 async def connect(self) -> bool: 220 # pactl load-module module-null-sink sink_name=bridge_virtual_0 221 # опционально: ffmpeg → IceCast на http://bridge:8080/stream/<player_id> 222 ``` 223 224 Референс: `module-null-sink` + `parec` — стандартная техника в Snapcast и Mopidy стеках. 225 226 --- 227 228 ## Phase 2: Subprocess Optimization — v3.1.x (параллельно) 229 230 **Цель:** поддержка 20–50 плееров на bridge без деградации ресурсов. 231 232 ### Текущие затраты на subprocess 233 234 | Компонент | ~RSS | 235 |-----------|------| 236 | Python interpreter | ~30 MB | 237 | sendspin binary | ~40 MB | 238 | PA context | ~10 MB | 239 | **Итого per subprocess** | **~80–150 MB** | 240 241 При 20 плеерах: 1.6–3 GB. При 50: 4–7.5 GB. 242 243 ### Оптимизации 244 245 **Lazy spawn** — subprocess стартует только при первом подключении MA или команде play, 246 не при запуске bridge. 247 248 **Idle timeout** — per-backend политика: 249 250 ```python 251 IDLE_TIMEOUT_SECONDS = { 252 "bluetooth_a2dp": None, # всегда живой (reconnect loop) 253 "local_sink": 300, # 5 минут тишины → exit, respawn при следующем connect 254 "virtual_sink": None, # управляется явно 255 "snapcast_client": 60, 256 "vban": 120, 257 } 258 ``` 259 260 **Pre-forked warm pool** для local sinks: N subprocess'ов предзапущены, новый плеер 261 «захватывает» готовый вместо cold start. 262 263 **Thinner subprocess** — backend-специфичный entrypoint вместо одного универсального 264 `daemon_process.py`. Цель: cold start < 500ms для local sink (сейчас ~2–3s). 265 266 **Метрики:** 267 - RSS/CPU per subprocess → в diagnostics API 268 - Суммарный footprint bridge → `/api/status/resources` 269 - Предупреждение при > 80% доступной памяти 270 271 --- 272 273 ## Phase 3: Network Protocol Backends — v3.2.x 274 275 ### v3.2.0 — SnapcastClientBackend 276 277 Bridge как Snapcast-клиент. Смысл: у многих энтузиастов есть Snapcast-инфраструктура с 278 Volumio, Mopidy, librespot. Bridge → универсальный BT/local output для любого Snapcast-стека. 279 280 ``` 281 [Volumio / Mopidy / Spotifyd → Snapcast Server] 282 ↓ TCP (python-snapcast) 283 [SnapcastClientBackend] 284 ↓ PCM chunks → PA sink 285 [BT Speaker / Local DAC] 286 ``` 287 288 - Библиотека: `python-snapcast` (уже используется MA) 289 - Sync precision: < 0.2ms в LAN — Snapcast NTP-style timestamp per chunk 290 - Несколько bridge-экземпляров как Snapcast-клиенты = идеальная мульти-рум синхронизация 291 без дополнительной координации 292 293 ### v3.2.1 — VBANBackend 294 295 VBAN = открытый UDP-протокол VB-Audio (vbaudio.com/vban/spec). Порт 6980. 296 Используется в Windows-аудио-экосистеме: OBS, Voicemeeter, VB-Cable. 297 298 ``` 299 [Windows PC: Voicemeeter / OBS → VBAN sender] 300 ↓ UDP 6980 (PCM 16/24/32-bit, up to 48kHz) 301 [VBANBackend → PA sink → BT Speaker / Local DAC] 302 ``` 303 304 ```python 305 class VBANBackend(AudioBackend): 306 bind_address: str = "0.0.0.0" 307 port: int = 6980 308 stream_name: str # VBAN stream name filter 309 310 async def _receive_loop(self): 311 while True: 312 data, addr = await self._sock.recvfrom(65536) 313 header = VBANHeader.parse(data[:28]) # 28-byte fixed header 314 pcm = data[28:] 315 await self._pa_writer.write(pcm) 316 ``` 317 318 ~200 строк реализации. Открытый протокол. MA VBAN — только alpha receive-only; 319 bridge добавляет полноценный receive → any output путь. 320 321 ### v3.2.2 — LE Audio / LC3 Backend (experimental tracker) 322 323 Инфраструктурная заготовка, не для продакшна. 324 325 **Состояние экосистемы (2026):** 326 - BlueZ 5.86: BAP/BASS/VCP/MCP реализованы 327 - liblc3 (Google): открытый кодек, packaging на ARM/Linux = последний барьер 328 - PipeWire 1.0+: spa-codec-lc3 есть 329 - Реальная DIY-готовность: ~2027 330 331 ```python 332 class LEAudioBackend(AudioBackend): 333 mac: str 334 experimental: bool = True # guard flag 335 336 def get_sink_name(self) -> str: 337 return f"bluez_le_sink.{self._normalized_mac}" 338 ``` 339 340 Включается через `"experimental_backends": ["le_audio"]` в конфиге. 341 342 ### v3.2.3 — Auracast TX Backend (very long-term placeholder) 343 344 RPi как BLE broadcast transmitter без паринга → любые Auracast-наушники. 345 346 ``` 347 [PA sink] → [AuracastTXBackend] → BLE Periodic Advertising + LC3 broadcast 348 → [Auracast receiver 1] 349 → [Auracast receiver N] 350 ``` 351 352 Требует: BlueZ 5.84+ BASS, liblc3, Python BLE bindings. Architectural placeholder, ETA не определён. 353 354 --- 355 356 ## Phase 4: Scale & Multi-Bridge Federation — v3.3.x 357 358 **Цель:** десятки плееров на bridge, десятки bridge'ей на MA-сервер. 359 360 ### Bridge Discovery (mDNS) 361 362 ``` 363 _sendspin-bridge._tcp.local 364 → name: "Living Room Bridge" 365 → version: "3.3.0" 366 → player_count: 8 367 → api_port: 8080 368 ``` 369 370 MA и другие bridge'и видят друг друга без ручной настройки. 371 372 ### Cross-Bridge Groups 373 374 MA sync groups работают поверх player_id с bridge-префиксом: 375 376 ``` 377 bridge-living-room::bt-eneby20 378 bridge-bedroom::local-hdmi 379 bridge-kitchen::bt-jbl 380 ``` 381 382 Синхронизация через MA — не требует межбриджевой координации на уровне bridge. 383 384 ### Central Bridge Dashboard 385 386 Hub mode: агрегированный статус всех bridge'ей через mDNS discovery, 387 единый SSE поток, `/api/federation/bridges`. 388 389 ### Sync Quality Telemetry 390 391 ```python 392 @dataclass 393 class SyncTelemetry: 394 player_id: str 395 measured_delay_ms: float # реально измеренная задержка 396 target_delay_ms: float # static_delay_ms из конфига 397 drift_ms: float # разница 398 reanchor_count_1h: int 399 sync_quality: Literal["good", "degraded", "poor"] 400 ``` 401 402 Авто-тюнинг `static_delay_ms`: если drift стабильно > 20ms в течение 10 минут → 403 предложить корректировку через UI/API. 404 405 --- 406 407 ## Phase 5: HA + AI Automation — v3.4.x 408 409 ### 5A — HA Custom Component (HACS, монорепо) 410 411 Расположение: `custom_components/sendspin_audio_bridge/` 412 413 ```yaml 414 # HA entities per bridge player: 415 media_player.sendspin_living_room: 416 state: playing 417 attributes: 418 source: Music Assistant 419 sync_drift_ms: 3.2 420 backend_type: bluetooth_a2dp 421 bt_battery: 78 422 423 sensor.sendspin_living_room_connection: 424 state: connected 425 attributes: { rssi: -62, sink: "bluez_sink.FC_58..." } 426 427 button.sendspin_living_room_reconnect: ~ 428 button.sendspin_living_room_calibrate: ~ 429 ``` 430 431 HA triggers: 432 - `sendspin_audio_bridge.device_connected` 433 - `sendspin_audio_bridge.device_disconnected` 434 - `sendspin_audio_bridge.sync_degraded` 435 436 Dashboard cards для статуса bridge в Lovelace. 437 438 ### 5B — Presence-Based Zone Management 439 440 ```json 441 "zone_management": { 442 "enabled": true, 443 "zones": [ 444 { 445 "ha_area": "kitchen", 446 "players": ["kitchen-local", "kitchen-bt"], 447 "auto_enable_when_occupied": true, 448 "idle_subprocess_when_empty": true, 449 "idle_delay_seconds": 60 450 } 451 ] 452 } 453 ``` 454 455 - HA person/device_tracker → bridge zone state via webhook 456 - Occupied: subprocess warm, плеер доступен в MA 457 - Empty: idle timeout ускорен до 60s, subprocess выходит 458 459 ### 5C — Auto-Delay Calibration 460 461 ``` 462 1. Bridge воспроизводит reference tone через player (1kHz, 100ms) 463 2. ESPHome-микрофон (INMP441) в той же комнате детектирует тон 464 3. Bridge получает timestamp детекции через HA entity или webhook 465 4. Вычисляет roundtrip → вычитает известные задержки → static_delay_ms 466 5. Предлагает применить через UI 467 ``` 468 469 ```http 470 POST /api/calibrate/start 471 {"player_id": "living-room-bt", "mic_entity": "sensor.kitchen_mic_trigger"} 472 → {"job_id": "cal-001"} 473 474 GET /api/calibrate/result/cal-001 475 → {"measured_delay_ms": 187, "current_static_ms": -600, "suggested_ms": -413} 476 ``` 477 478 ### 5D — LLM-Driven Playback Control 479 480 Bridge как слой между HA Assist и MA: 481 482 ``` 483 "Включи что-нибудь джазовое в кухне" 484 ↓ HA Assist (intent extraction) 485 ↓ POST /api/ma/intent {"text": "...", "context": {"area": "kitchen"}} 486 ↓ Bridge: resolve area → MA group → MA play_media 487 ↓ MA: поиск + воспроизведение 488 ``` 489 490 Интеграция с HA Conversation agent (LLM tool call или простая heuristics). 491 492 ### 5E — Adaptive Quality 493 494 ```python 495 class AdaptiveQualityManager: 496 async def tick(self, player: Player): 497 t = get_sync_telemetry(player.id) 498 if t.reanchor_count_1h > 5: 499 await update_pulse_latency(player, current + 100) 500 if t.drift_ms > 30: 501 await force_codec(player, "sbc") 502 elif t.sync_quality == "good" and current_codec == "sbc": 503 await try_codec(player, "aac") 504 ``` 505 506 --- 507 508 ## Phase 6: Ecosystem & Platform — v3.5.x+ 509 510 ### HA Custom Component — полная зрелость 511 512 - HACS listed 513 - HA Brand в brands.home-assistant.io 514 - Config flow с UI-wizard 515 - Translations (EN/RU минимум) 516 - Полное покрытие тестами (pytest-homeassistant-custom-component) 517 518 ### Backend Plugin SDK 519 520 ```python 521 # Третьи стороны добавляют backends без форка: 522 # pip install sendspin-audio-backend-roc 523 # pip install sendspin-audio-backend-dante 524 525 from sendspin_audio_bridge.backends import AudioBackend, register_backend 526 527 @register_backend("roc") 528 class ROCBackend(AudioBackend): 529 ... 530 ``` 531 532 `AudioBackend` base class публикуется как отдельный PyPI пакет: `sendspin-audio-backend`. 533 534 ### OpenHome OHRenderer 535 536 Bridge регистрируется как UPnP MediaRenderer с OpenHome services: 537 - OHPlaylist / OHVolume / OHTime / OHProduct 538 - Linn/Kazoo/Lumin приложения управляют bridge напрямую без MA 539 - Открытая спецификация openhome.org + `async-upnp-client` (уже в MA) 540 541 Целевая аудитория: нишевая audiophile. Ценность: credibility в OpenHome экосистеме и 542 режим работы без MA вообще. 543 544 ### ESPHome Sendspin Component (совместная разработка) 545 546 Прямое подключение вместо ESPHome → HA → hass_players → MA (500ms+ latency): 547 548 ``` 549 [MA] → Sendspin protocol → [ESP32 ESPHome component] → I2S DAC → Speaker 550 ``` 551 552 Требует Sendspin C++ client library для ESP32. Совместная работа с MA/Sendspin командой. 553 554 --- 555 556 ## Версионная карта 557 558 | Версия | Фаза | Ключевое | 559 |--------|------|----------| 560 | **v3.0.0** | Rebrand & Foundation | Новое имя, config schema v2, AudioBackend ABC, migration tool | 561 | **v3.1.x** | Local Audio | LocalSink (PA/PW), ALSA direct, USB auto-discover, VirtualSink | 562 | **v3.2.x** | Network Backends | SnapcastClient, VBAN, LE Audio tracker, Auracast placeholder | 563 | **v3.3.x** | Scale & Federation | Multi-bridge mDNS, sync telemetry, cross-bridge groups, auto-tune | 564 | **v3.4.x** | HA + AI | HACS component, presence zones, auto-calibration, LLM intent, adaptive quality | 565 | **v3.5.x** | Platform | HACS mature, Plugin SDK, OpenHome OHRenderer, ESPHome component | 566 567 --- 568 569 ## Hardware Targets по фазам 570 571 | Фаза | Основное железо | Вторичное | 572 |------|----------------|-----------| 573 | v3.0 | x86 LXC (Proxmox), HAOS VM | — | 574 | v3.1 | x86 LXC с USB DAC / HDMI, RPi 4 | RPi Zero 2W | 575 | v3.2 | RPi Zero 2W (дешёвый network endpoint) | x86, ESP32 | 576 | v3.3 | Любое (federation агностична к железу) | — | 577 | v3.4 | HAOS VM + RPi с INMP441 mic (калибровка) | ESP32 с INMP441 | 578 | v3.5 | Все платформы | Linn/Naim hardware (OHRenderer) | 579 580 --- 581 582 ## Риски 583 584 | Риск | Вероятность | Митигация | 585 |------|-------------|-----------| 586 | LE Audio BlueZ Python bindings незрелые | Высокая | Guard за `experimental: true`, ETA 2027 | 587 | Sendspin protocol breaking change в v6 | Средняя | Versioned IPC contracts из текущего roadmap | 588 | Subprocess model не масштабируется | Средняя | Lazy spawn + idle timeout (Phase 2) | 589 | OpenHome нишевая аудитория | Высокая | После HACS mature, не вместо | 590 | ESPHome community не примет компонент | Средняя | Начать с RFC в ESPHome discussions | 591 592 --- 593 594 ## Принципы разработки 595 596 - **Backend-first:** новый тип устройства = новый backend, не изменение core 597 - **Incremental migration:** v2.x config мигрирует автоматически, не ломается 598 - **Subprocess-agnostic:** subprocess не знает какой backend его запустил 599 - **HA-native:** bridge — HA addon first, всё остальное поверх 600 - **No MA hardware coupling:** интеграция через `hass_players`, не upstream MA provider 601 - **Observable:** каждый backend публикует structured telemetry 602 - **Reuse over rewrite:** `pulsectl-asyncio`, `python-snapcast`, `async-upnp-client` уже доступны