From 6f72ca78268f32f225b7987dc042f62e3f3c6d26 Mon Sep 17 00:00:00 2001 From: 0xcodepunk <1329474@gmail.com> Date: Sun, 5 Jul 2026 21:29:23 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=B1=D1=8B=D1=81=D1=82=D1=80=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=81=D1=82=D0=B0=D1=80=D1=82=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=20=E2=80=94=20=D0=B8=D0=B7=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлена настройка APP_RUARK_HOST: юникастовый M-SEARCH на известный IP вместо SSDP-скана всей сети (ssdp.py, ssdp_locate) - добавлен RuarkLocationStore: location-URL Ruark кешируется между запусками (cache/ruark_location.txt) - добавлен StationDeviceStore: параметры станции кешируются между запусками (cache/station_device.json), быстрая TCP-проверка адреса перед использованием - refresh_device и _ensure_device переведены на быстрый путь с фолбэком на полный поиск при недоступности или чужом устройстве - поиск Ruark и подключение к станции в start() выполняются параллельно, при ошибке WebSocket-клиент останавливается - фиксированная пауза sleep(1) в _prepare_devices заменена ожиданием первого сообщения станции - добавлены тесты быстрых путей, фолбэков и сторов; обновлён пример .env в README --- README.md | 3 + src/core/config/settings.py | 2 + .../main_stream_manager.py | 22 ++- src/ruark_audio_system/location_store.py | 35 ++++ src/ruark_audio_system/ruark_r5_controller.py | 73 ++++++- src/ruark_audio_system/ssdp.py | 44 +++++ src/yandex_station/station_store.py | 47 +++++ src/yandex_station/station_ws_control.py | 36 +++- tests/conftest.py | 7 +- tests/test_device_init.py | 186 +++++++++++++++++- tests/test_streaming_loop.py | 15 ++ tests/test_volume_memory.py | 4 +- 12 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 src/ruark_audio_system/location_store.py create mode 100644 src/ruark_audio_system/ssdp.py create mode 100644 src/yandex_station/station_store.py diff --git a/README.md b/README.md index 2ae28c9..54b1bd4 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ APP_YA_MUSIC_TOKEN=your_token_here # PIN-код Ruark (по умолчанию 1234) APP_RUARK_PIN=1234 +# Необязательно: известный IP Ruark — быстрый старт без SSDP-скана сети +# APP_RUARK_HOST=192.168.1.20 + # Адрес и порты сервисов (адрес — IP машины в локальной сети) APP_LOCAL_SERVER_HOST=192.168.1.10 APP_LOCAL_SERVER_PORT_DLNA=8080 diff --git a/src/core/config/settings.py b/src/core/config/settings.py index a4a2137..388bf18 100644 --- a/src/core/config/settings.py +++ b/src/core/config/settings.py @@ -33,6 +33,8 @@ class Settings(BaseSettings): # Ruark R5 settings ruark_pin: str + # Известный IP Ruark: быстрый старт без SSDP-скана всей сети + ruark_host: str | None = None # Mode settings debug: bool = False diff --git a/src/main_stream_service/main_stream_manager.py b/src/main_stream_service/main_stream_manager.py index 2c629ff..e6fa7be 100644 --- a/src/main_stream_service/main_stream_manager.py +++ b/src/main_stream_service/main_stream_manager.py @@ -93,18 +93,28 @@ async def start(self): return logger.info("🎵 Запуск стриминга") + # Поиск Ruark и подключение к станции идут параллельно — + # это заметно ускоряет старт стрима + results: tuple[Any, Any] = await asyncio.gather( + self._ruark_controls.connect(), + self._station_controls.start_ws_client(), + return_exceptions=True, + ) + ruark_connected, ws_result = results try: - # Поиск устройств выполняется здесь, а не при старте процесса - if not await self._ruark_controls.connect(): + if isinstance(ws_result, BaseException): + raise ws_result + if isinstance(ruark_connected, BaseException): + raise ruark_connected + if not ruark_connected: raise RuarkDeviceNotFoundError( f"Устройство " f"'{self._ruark_controls.device_name}' " f"не найдено в сети" ) - logger.info("🔄 Запуск WebSocket клиента") - await self._station_controls.start_ws_client() except Exception as e: logger.error(f"❌ Не удалось запустить стриминг: {e}") + await self._station_controls.stop_ws_client() return self._stream_state_running = True @@ -652,7 +662,9 @@ async def _remember_ruark_volume(self): async def _prepare_devices(self): logger.info("🔧 Подготовка устройств к стримингу...") - await asyncio.sleep(1) + # Ждём первое сообщение станции вместо фиксированной паузы: + # обычно оно приходит сразу после подключения WebSocket + await self._ws_client.wait_for_state_update(1.0) await self._station_controls.set_default_volume() await self._ruark_controls.get_session_id() if await self._ruark_controls.get_power_status() == "0": diff --git a/src/ruark_audio_system/location_store.py b/src/ruark_audio_system/location_store.py new file mode 100644 index 0000000..f46fa17 --- /dev/null +++ b/src/ruark_audio_system/location_store.py @@ -0,0 +1,35 @@ +from logging import getLogger +from pathlib import Path + +logger = getLogger(__name__) + +DEFAULT_STORE_PATH = Path("cache") / "ruark_location.txt" + + +class RuarkLocationStore: + """Хранит location-URL Ruark между запусками для быстрого старта.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path or DEFAULT_STORE_PATH + + def load(self) -> str | None: + """Возвращает сохранённый location-URL или None, если его нет.""" + try: + location = self._path.read_text().strip() + return location or None + except FileNotFoundError: + return None + except OSError as e: + logger.warning( + f"⚠️ Не удалось прочитать сохранённый адрес Ruark: {e}" + ) + return None + + def save(self, location: str) -> None: + """Сохраняет location-URL для следующего запуска.""" + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(location) + logger.debug(f"💾 Адрес Ruark сохранён: {location}") + except OSError as e: + logger.warning(f"⚠️ Не удалось сохранить адрес Ruark: {e}") diff --git a/src/ruark_audio_system/ruark_r5_controller.py b/src/ruark_audio_system/ruark_r5_controller.py index 50ee5ca..81a5ce2 100644 --- a/src/ruark_audio_system/ruark_r5_controller.py +++ b/src/ruark_audio_system/ruark_r5_controller.py @@ -1,4 +1,5 @@ import asyncio +import socket import urllib.parse from logging import getLogger from typing import Any, Dict, List, Literal, Optional @@ -11,6 +12,8 @@ from ruark_audio_system.constants import DEFAULT_STREAM_TITLE, META_INFO from ruark_audio_system.exceptions import RuarkDeviceNotFoundError from ruark_audio_system.fsapi_client import RuarkFsApiClient +from ruark_audio_system.location_store import RuarkLocationStore +from ruark_audio_system.ssdp import ssdp_locate logger = getLogger(__name__) @@ -40,6 +43,7 @@ def __init__(self, device_name: str = "Ruark R5") -> None: self._connection_manager: Any = None self._rendering_control: Any = None self._fsapi = RuarkFsApiClient(pin=settings.ruark_pin) + self._location_store = RuarkLocationStore() @property def av_transport(self) -> Any: @@ -102,9 +106,15 @@ async def connect(self, attempts: int = 3, delay: float = 2.0) -> bool: return False def refresh_device(self) -> None: - """Обновление устройства.""" + """Обновление устройства. + + Сначала быстрый путь по известному адресу (настройка или кеш + прошлого запуска), при неудаче — полный SSDP-скан сети. + """ logger.info("🔄 Обновление устройства") - self.device = self.find_device(device_name=self.device_name) + self.device = self._device_from_known_address() + if not self.device: + self.device = self.find_device(device_name=self.device_name) if not self.device: logger.warning( f"⚠ Устройство '{self.device_name}' не найдено в сети!" @@ -126,11 +136,70 @@ def refresh_device(self) -> None: self._rendering_control = self.services.get( "urn:schemas-upnp-org:service:RenderingControl:1" ) + self._location_store.save(self.device.location) logger.info( f"Устройство обновлено: {self.device.friendly_name} " f"({self.device.location})" ) + def _device_from_known_address(self) -> Optional[upnpclient.Device]: + """Подключение по известному адресу без сканирования сети. + + Приоритет: IP из настроек (APP_RUARK_HOST), затем location + прошлого успешного поиска. Любая неудача — фолбэк на скан. + """ + candidates: list[str] = [] + if settings.ruark_host: + location = ssdp_locate(settings.ruark_host) + if location: + candidates.append(location) + cached = self._location_store.load() + if cached and cached not in candidates: + candidates.append(cached) + + for location in candidates: + device = self._device_at_location(location) + if device: + return device + return None + + def _device_at_location( + self, location: str + ) -> Optional[upnpclient.Device]: + """Проверяет, что по location отвечает именно нужное устройство.""" + try: + if not self._is_location_reachable(location): + return None + device = upnpclient.Device(location) + if self.device_name in device.friendly_name: + logger.info( + f"⚡ Ruark найден по известному адресу: {location}" + ) + return device + logger.info( + f"ℹ️ По адресу {location} другое устройство: " + f"{device.friendly_name}" + ) + except Exception as e: + logger.info( + f"ℹ️ Быстрое подключение по {location} не удалось: {e}" + ) + return None + + @staticmethod + def _is_location_reachable(location: str, timeout: float = 2.0) -> bool: + """Быстрая TCP-проверка адреса перед HTTP-запросом описания.""" + parsed = urllib.parse.urlparse(location) + if not parsed.hostname or not parsed.port: + return False + try: + with socket.create_connection( + (parsed.hostname, parsed.port), timeout=timeout + ): + return True + except OSError: + return False + def find_device(self, device_name: str) -> Optional[upnpclient.Device]: """Находит устройство по имени.""" logger.info(f"Начинаем поиск устройства: {device_name}") diff --git a/src/ruark_audio_system/ssdp.py b/src/ruark_audio_system/ssdp.py new file mode 100644 index 0000000..df0386b --- /dev/null +++ b/src/ruark_audio_system/ssdp.py @@ -0,0 +1,44 @@ +import socket +from logging import getLogger + +logger = getLogger(__name__) + +SSDP_PORT = 1900 + +M_SEARCH_REQUEST = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: {host}:{port}\r\n" + 'MAN: "ssdp:discover"\r\n' + "MX: 1\r\n" + "ST: upnp:rootdevice\r\n" + "\r\n" +) + + +def ssdp_locate(host: str, timeout: float = 2.0) -> str | None: + """Запрашивает location описания UPnP-устройства напрямую у IP. + + Юникастовый M-SEARCH вместо сканирования всей сети: устройство + с известным адресом отвечает за десятки миллисекунд. + + Args: + host (str): IP-адрес устройства. + timeout (float): Ожидание ответа в секундах. + Returns: + str | None: URL описания устройства или None, если нет ответа. + """ + request = M_SEARCH_REQUEST.format(host=host, port=SSDP_PORT).encode() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + try: + sock.sendto(request, (host, SSDP_PORT)) + data, _ = sock.recvfrom(4096) + for line in data.decode(errors="ignore").splitlines(): + if line.lower().startswith("location:"): + return line.split(":", 1)[1].strip() + return None + except OSError as e: + logger.info(f"ℹ️ {host} не ответил на M-SEARCH: {e}") + return None + finally: + sock.close() diff --git a/src/yandex_station/station_store.py b/src/yandex_station/station_store.py new file mode 100644 index 0000000..cfbfdef --- /dev/null +++ b/src/yandex_station/station_store.py @@ -0,0 +1,47 @@ +import json +from logging import getLogger +from pathlib import Path + +from yandex_station.mdns_device_finder import StationDevice + +logger = getLogger(__name__) + +DEFAULT_STORE_PATH = Path("cache") / "station_device.json" + + +class StationDeviceStore: + """Хранит параметры найденной станции между запусками. + + device_id и platform у станции постоянные, host почти всегда + закреплён DHCP — кеш позволяет пропустить mDNS-поиск при старте. + """ + + def __init__(self, path: Path | None = None) -> None: + self._path = path or DEFAULT_STORE_PATH + + def load(self) -> StationDevice | None: + """Возвращает сохранённые параметры станции или None.""" + try: + data = json.loads(self._path.read_text()) + return StationDevice( + device_id=str(data["device_id"]), + platform=str(data["platform"]), + host=str(data["host"]), + port=int(data["port"]), + ) + except FileNotFoundError: + return None + except (OSError, ValueError, KeyError, TypeError) as e: + logger.warning( + f"⚠️ Не удалось прочитать сохранённые параметры станции: {e}" + ) + return None + + def save(self, device: StationDevice) -> None: + """Сохраняет параметры станции для следующего запуска.""" + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(dict(device))) + logger.debug(f"💾 Параметры станции сохранены: {device['host']}") + except OSError as e: + logger.warning(f"⚠️ Не удалось сохранить параметры станции: {e}") diff --git a/src/yandex_station/station_ws_control.py b/src/yandex_station/station_ws_control.py index b050736..b2285ca 100644 --- a/src/yandex_station/station_ws_control.py +++ b/src/yandex_station/station_ws_control.py @@ -17,7 +17,8 @@ ClientNotRunningError, StationNotFoundError, ) -from yandex_station.mdns_device_finder import DeviceFinder +from yandex_station.mdns_device_finder import DeviceFinder, StationDevice +from yandex_station.station_store import StationDeviceStore logger = logging.getLogger(__name__) @@ -52,6 +53,7 @@ def __init__( self.state_updated = asyncio.Event() self._connect_task: asyncio.Task[None] | None = None self._connected_at: float | None = None + self._device_store = StationDeviceStore() # Хранение фоновых задач self.tasks: list[asyncio.Task[None]] = [] @@ -75,12 +77,23 @@ def _require_station_params(self) -> tuple[str, str, str]: async def _ensure_device(self) -> None: """Находит станцию в сети, если она ещё не найдена. + Сначала проверяется кеш прошлого запуска (быстрая TCP-проверка + адреса), при неудаче — mDNS-поиск с сохранением результата. + Raises: StationNotFoundError: Если станция не найдена за отведённое время. """ if self.device_id: return + cached = self._device_store.load() + if cached and await self._is_station_reachable( + cached["host"], cached["port"] + ): + self._apply_device(cached) + logger.info(f"⚡ Станция взята из кеша: {self.uri}") + return + logger.info("🔍 Поиск Яндекс Станции в сети...") await asyncio.to_thread(self.device_finder.find_devices) device = self.device_finder.device @@ -90,10 +103,29 @@ async def _ensure_device(self) -> None: "(mDNS-сервис _yandexio._tcp.local.)" ) + self._device_store.save(device) + self._apply_device(device) + logger.info(f"✅ Станция найдена: {self.uri}") + + def _apply_device(self, device: StationDevice) -> None: + """Заполняет параметры подключения из найденной станции.""" self.device_id = device["device_id"] self.platform = device["platform"] self.uri = f"wss://{device['host']}:{device['port']}" - logger.info(f"✅ Станция найдена: {self.uri}") + + @staticmethod + async def _is_station_reachable( + host: str, port: int, timeout: float = 1.5 + ) -> bool: + """Быстрая TCP-проверка, что станция отвечает по адресу из кеша.""" + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout + ) + writer.close() + return True + except (OSError, asyncio.TimeoutError): + return False async def run_once(self): """Гарантированный однократный запуск WebSocket.""" diff --git a/tests/conftest.py b/tests/conftest.py index c81f6b7..0703f40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,5 +21,8 @@ def mock_finder(): @pytest.fixture def mock_station_client(mock_finder): - """Клиент станции с замоканным DeviceFinder.""" - return YandexStationClient(device_finder=mock_finder) + """Клиент станции с замоканным DeviceFinder и пустым кешем.""" + client = YandexStationClient(device_finder=mock_finder) + client._device_store = MagicMock() + client._device_store.load.return_value = None + return client diff --git a/tests/test_device_init.py b/tests/test_device_init.py index b22a20b..8d329cc 100644 --- a/tests/test_device_init.py +++ b/tests/test_device_init.py @@ -1,10 +1,13 @@ -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest +from core.config.settings import settings from ruark_audio_system.exceptions import RuarkDeviceNotFoundError +from ruark_audio_system.location_store import RuarkLocationStore from ruark_audio_system.ruark_r5_controller import RuarkR5Controller from yandex_station.exceptions import StationNotFoundError +from yandex_station.station_store import StationDeviceStore from yandex_station.station_ws_control import YandexStationClient @@ -37,11 +40,51 @@ async def test_ensure_device_raises_when_station_not_found(): finder.device = {} finder.find_devices.return_value = False client = YandexStationClient(device_finder=finder) + client._device_store = MagicMock() + client._device_store.load.return_value = None with pytest.raises(StationNotFoundError): await client._ensure_device() +async def test_ensure_device_uses_reachable_cached_station( + mock_station_client, +): + """Кеш с доступной станцией избавляет от mDNS-поиска.""" + mock_station_client._device_store.load.return_value = { + "device_id": "cached_id", + "platform": "cached_platform", + "host": "10.0.0.3", + "port": 1961, + } + mock_station_client._is_station_reachable = AsyncMock(return_value=True) + + await mock_station_client._ensure_device() + + assert mock_station_client.device_id == "cached_id" + assert mock_station_client.uri == "wss://10.0.0.3:1961" + mock_station_client.device_finder.find_devices.assert_not_called() + + +async def test_ensure_device_ignores_unreachable_cache(mock_station_client): + """Протухший кеш станции — фолбэк на mDNS с сохранением результата.""" + mock_station_client._device_store.load.return_value = { + "device_id": "cached_id", + "platform": "cached_platform", + "host": "10.0.0.3", + "port": 1961, + } + mock_station_client._is_station_reachable = AsyncMock(return_value=False) + + await mock_station_client._ensure_device() + + assert mock_station_client.device_id == "dummy_id" + mock_station_client.device_finder.find_devices.assert_called_once() + mock_station_client._device_store.save.assert_called_once_with( + mock_station_client.device_finder.device + ) + + def test_ruark_init_does_not_search_network(): controller = RuarkR5Controller() @@ -88,3 +131,144 @@ async def test_ruark_connect_skips_search_when_already_connected(): assert await controller.connect() is True controller.refresh_device.assert_not_called() + + +def make_fake_device(name="Ruark R5", location="http://10.0.0.5:8080/dd.xml"): + """Фейковое UPnP-устройство для быстрого пути подключения.""" + device = MagicMock() + device.friendly_name = name + device.location = location + device.services = [] + return device + + +def make_fast_path_controller(monkeypatch, cached_location=None): + """Контроллер с изолированным кешем и без APP_RUARK_HOST.""" + monkeypatch.setattr(settings, "ruark_host", None) + controller = RuarkR5Controller() + controller._location_store = MagicMock() + controller._location_store.load.return_value = cached_location + return controller + + +def test_refresh_uses_cached_location_without_scan(monkeypatch): + """Кешированный адрес избавляет от SSDP-скана всей сети.""" + fake_device = make_fake_device() + controller = make_fast_path_controller( + monkeypatch, cached_location=fake_device.location + ) + monkeypatch.setattr( + controller, "_is_location_reachable", lambda location: True + ) + monkeypatch.setattr( + "ruark_audio_system.ruark_r5_controller.upnpclient.Device", + MagicMock(return_value=fake_device), + ) + controller.find_device = MagicMock() + + controller.refresh_device() + + assert controller.device is fake_device + controller.find_device.assert_not_called() + controller._location_store.save.assert_called_once_with( + fake_device.location + ) + + +def test_refresh_falls_back_to_scan_when_cache_stale(monkeypatch): + """Недоступный кешированный адрес — фолбэк на полный поиск.""" + fake_device = make_fake_device(location="http://10.0.0.7:8080/dd.xml") + controller = make_fast_path_controller( + monkeypatch, cached_location="http://10.0.0.5:8080/dd.xml" + ) + monkeypatch.setattr( + controller, "_is_location_reachable", lambda location: False + ) + controller.find_device = MagicMock(return_value=fake_device) + + controller.refresh_device() + + assert controller.device is fake_device + controller.find_device.assert_called_once() + controller._location_store.save.assert_called_once_with( + fake_device.location + ) + + +def test_refresh_rejects_wrong_device_at_cached_location(monkeypatch): + """Чужое устройство по кешированному адресу не принимается.""" + stranger = make_fake_device(name="Serviio") + ruark = make_fake_device(location="http://10.0.0.7:8080/dd.xml") + controller = make_fast_path_controller( + monkeypatch, cached_location=stranger.location + ) + monkeypatch.setattr( + controller, "_is_location_reachable", lambda location: True + ) + monkeypatch.setattr( + "ruark_audio_system.ruark_r5_controller.upnpclient.Device", + MagicMock(return_value=stranger), + ) + controller.find_device = MagicMock(return_value=ruark) + + controller.refresh_device() + + assert controller.device is ruark + controller.find_device.assert_called_once() + + +def test_env_host_connects_via_unicast_msearch(monkeypatch): + """IP из настроек резолвится юникастовым M-SEARCH без скана.""" + fake_device = make_fake_device(location="http://10.0.0.9:8080/dd.xml") + monkeypatch.setattr(settings, "ruark_host", "10.0.0.9") + controller = RuarkR5Controller() + controller._location_store = MagicMock() + controller._location_store.load.return_value = None + locate_mock = MagicMock(return_value=fake_device.location) + monkeypatch.setattr( + "ruark_audio_system.ruark_r5_controller.ssdp_locate", locate_mock + ) + monkeypatch.setattr( + controller, "_is_location_reachable", lambda location: True + ) + monkeypatch.setattr( + "ruark_audio_system.ruark_r5_controller.upnpclient.Device", + MagicMock(return_value=fake_device), + ) + controller.find_device = MagicMock() + + controller.refresh_device() + + assert controller.device is fake_device + locate_mock.assert_called_once_with("10.0.0.9") + controller.find_device.assert_not_called() + + +def test_location_store_roundtrip(tmp_path): + store = RuarkLocationStore(path=tmp_path / "location.txt") + + assert store.load() is None + store.save("http://10.0.0.5:8080/dd.xml") + assert store.load() == "http://10.0.0.5:8080/dd.xml" + + +def test_station_store_roundtrip(tmp_path): + store = StationDeviceStore(path=tmp_path / "station.json") + device = { + "device_id": "id1", + "platform": "platform1", + "host": "10.0.0.3", + "port": 1961, + } + + assert store.load() is None + store.save(device) + assert store.load() == device + + +def test_station_store_ignores_corrupted_file(tmp_path): + path = tmp_path / "station.json" + path.write_text("не json") + store = StationDeviceStore(path=path) + + assert store.load() is None diff --git a/tests/test_streaming_loop.py b/tests/test_streaming_loop.py index 500e856..09f09f3 100644 --- a/tests/test_streaming_loop.py +++ b/tests/test_streaming_loop.py @@ -523,6 +523,21 @@ async def test_cached_source_expires_after_ttl(): assert "42" not in manager._source_cache +async def test_start_stops_ws_client_when_ruark_not_found(): + """Ruark не найден при параллельном старте — WebSocket гасится.""" + station = make_station_controls(make_track()) + ruark = make_ruark() + ruark.connect.return_value = False + ruark.device_name = "Ruark R5" + manager = make_manager(station, ruark) + + await manager.start() + + assert manager._stream_state_running is False + station.start_ws_client.assert_called_once() + station.stop_ws_client.assert_called_once() + + async def test_stop_survives_unreachable_ruark(): """Недоступный Ruark не прерывает остановку стриминга.""" station = make_station_controls(make_track()) diff --git a/tests/test_volume_memory.py b/tests/test_volume_memory.py index 45852ae..f95d82b 100644 --- a/tests/test_volume_memory.py +++ b/tests/test_volume_memory.py @@ -30,8 +30,10 @@ def make_manager(ruark_volume=20): ruark.get_power_status.return_value = "1" ruark.get_session_id.return_value = "sid" station = AsyncMock(spec=YandexStationControls) + ws_client = MagicMock() + ws_client.wait_for_state_update = AsyncMock(return_value=True) manager = MainStreamManager( - station_ws_client=MagicMock(), + station_ws_client=ws_client, station_controls=station, ruark_controls=ruark, yandex_music_api=AsyncMock(spec=YandexMusicAPI),