/ 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` уже доступны