diff --git a/app/tion/__init__.py b/app/tion/__init__.py index f2fb64f..df97824 100644 --- a/app/tion/__init__.py +++ b/app/tion/__init__.py @@ -1,7 +1,10 @@ from .controller import TionController from .models import TionState +from .service import TionService + __all__ = [ "TionController", + "TionService", "TionState", ] diff --git a/app/tion/controller.py b/app/tion/controller.py index b273ee9..b29f67b 100644 --- a/app/tion/controller.py +++ b/app/tion/controller.py @@ -1,6 +1,6 @@ import asyncio from typing import Any - +from bleak import BleakScanner from tion_btle import TionS4 from .models import TionState @@ -18,7 +18,7 @@ class TionController: def __init__(self, mac: str): self._mac = mac - self._device = TionS4(mac) + self._device: TionS4 | None = None # Не позволяем двум частям программы одновременно работать с BLE. self._lock = asyncio.Lock() @@ -35,27 +35,88 @@ class TionController: @property def connected(self) -> bool: - return self._device.connection_status == "connected" + return ( + self._device is not None + and self._device.connection_status == "connected" + ) - @property - def state(self) -> TionState | None: + async def _reset_device(self) -> None: """ - Последнее известное состояние без обращения к Bluetooth. + Полностью уничтожить текущий BLE transport. + + Важно: + tion-btle не вызывает BleakClient.disconnect(), + если WinRT уже считает устройство disconnected. + + Поэтому при полном reset принудительно закрываем + BleakClient напрямую. """ - return self._state + + old_device = self._device + + # Controller сразу больше не считает старый объект рабочим. + self._device = None + self._started = False + + if old_device is None: + return + + try: + # Нам нужен именно настоящий BleakClient.disconnect(). + # + # Не old_device.disconnect(), потому что tion-btle + # может пропустить физический cleanup при + # connection_status == "disc". + await old_device._btle.disconnect() + + except Exception: + # Старый transport всё равно больше использоваться + # не будет. + pass """ Соединение """ + async def connect(self) -> None: - """ - Открыть постоянное BLE-соединение. - """ async with self._lock: - if self._started: + if self.connected: + self._started = True return - await self._device.connect() + # Полностью закрываем всё, что осталось + # от предыдущего соединения. + await self._reset_device() + + # Получаем свежий BLEDevice. + ble_device = await BleakScanner.find_device_by_address( + self._mac, + timeout=5.0, + ) + + if ble_device is None: + raise ConnectionError( + f"Tion {self._mac} not found" + ) + + # Новый TionS4 = новый BleakClient. + device = TionS4(ble_device) + + try: + await device.connect() + + except Exception: + # ВАЖНО: + # освобождаем даже частично созданную + # WinRT/GATT-сессию. + try: + await device._btle.disconnect() + except Exception: + pass + + raise + + self._device = device self._started = True async def disconnect(self) -> None: @@ -63,13 +124,8 @@ class TionController: Закрыть BLE-соединение. """ async with self._lock: - if not self._started: - return + await self._reset_device() - try: - await self._device.disconnect() - finally: - self._started = False """ Состояние diff --git a/app/tion/service.py b/app/tion/service.py new file mode 100644 index 0000000..26b62f0 --- /dev/null +++ b/app/tion/service.py @@ -0,0 +1,307 @@ +import asyncio + +from collections.abc import Awaitable, Callable +from contextlib import suppress +from datetime import datetime, timezone + +from .controller import TionController +from .models import TionState + + +TionOperation = Callable[ + [TionController], + Awaitable[TionState] +] + + +class TionService: + """ + Долгоживущий сервис работы с Tion. + + Отвечает за: + - подключение; + - периодический опрос состояния; + - online/offline; + - last_seen; + - автоматическое переподключение; + - синхронизацию команд с polling. + """ + + def __init__( + self, + controller: TionController, + poll_interval: float = 5.0, + ): + if poll_interval <= 0: + raise ValueError("poll_interval must be greater than 0") + + self._controller = controller + self._poll_interval = poll_interval + + self._state: TionState | None = None + self._online = False + self._last_seen: datetime | None = None + self._last_error: str | None = None + + self._running = False + self._poll_task: asyncio.Task | None = None + + # Защищает последовательность: + # + # reconnect -> command -> update state + # + # от вмешательства polling или другой команды. + self._operation_lock = asyncio.Lock() + + # Защищает start / stop. + self._lifecycle_lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def state(self) -> TionState | None: + """ + Последнее успешно полученное состояние Tion. + + Bluetooth-запрос не выполняется. + """ + return self._state + + @property + def online(self) -> bool: + return self._online + + @property + def running(self) -> bool: + return self._running + + @property + def last_seen(self) -> datetime | None: + """ + Время последнего успешного обмена с Tion. + """ + return self._last_seen + + @property + def last_error(self) -> str | None: + """ + Последняя ошибка связи. + + После успешного обмена сбрасывается в None. + """ + return self._last_error + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """ + Запустить сервис. + + Первая попытка подключения и чтения состояния выполняется сразу. + После этого запускается фоновый polling. + """ + + async with self._lifecycle_lock: + if self._running: + return + + self._running = True + + # Сразу пытаемся получить состояние. + # Если Tion недоступен, сервис всё равно продолжит работу. + await self.refresh_state() + + self._poll_task = asyncio.create_task( + self._poll_loop(), + name="tion-poll", + ) + + async def stop(self) -> None: + """ + Остановить polling и корректно закрыть BLE-соединение. + """ + + async with self._lifecycle_lock: + if not self._running: + return + + self._running = False + + poll_task = self._poll_task + self._poll_task = None + + if poll_task is not None: + poll_task.cancel() + + with suppress(asyncio.CancelledError): + await poll_task + + async with self._operation_lock: + await self._safe_disconnect() + + self._online = False + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + async def refresh_state(self) -> TionState | None: + """ + Принудительно обновить состояние Tion. + + При ошибке: + - online становится False; + - last_error обновляется; + - старый state сохраняется; + - исключение наружу не выбрасывается. + + Возвращает None при ошибке. + """ + + async with self._operation_lock: + return await self._execute_locked( + lambda controller: controller.get_state(), + raise_on_error=False, + ) + + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + + async def execute( + self, + operation: TionOperation, + ) -> TionState: + """ + Выполнить любую команду TionController. + + Пример: + + await service.execute( + lambda tion: tion.set_speed(3) + ) + + После команды состояние Service автоматически обновляется. + """ + + async with self._operation_lock: + state = await self._execute_locked( + operation, + raise_on_error=True, + ) + + # Здесь None невозможен, потому что raise_on_error=True. + assert state is not None + + return state + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _execute_locked( + self, + operation: TionOperation, + *, + raise_on_error: bool, + ) -> TionState | None: + """ + Выполнение BLE-операции. + + Вызывается только при занятом _operation_lock. + """ + + try: + await self._ensure_connected() + + state = await operation(self._controller) + + except asyncio.CancelledError: + raise + + except Exception as exc: + self._mark_offline(exc) + + # После BLE-ошибки считаем соединение повреждённым. + # На следующей попытке будет создано новое. + await self._safe_disconnect() + + if raise_on_error: + raise + + return None + + self._mark_online(state) + + return state + + async def _ensure_connected(self) -> None: + """ + Убедиться, что имеется рабочее BLE-соединение. + + Если физического соединения нет, старое состояние подключения + сбрасывается и выполняется новое connect(). + """ + + if self._controller.connected: + return + + await self._safe_disconnect() + await self._controller.connect() + + async def _safe_disconnect(self) -> None: + """ + Закрыть соединение, не распространяя ошибку disconnect наружу. + """ + + with suppress(Exception): + await self._controller.disconnect() + + def _mark_online(self, state: TionState) -> None: + self._state = state + self._online = True + self._last_seen = datetime.now(timezone.utc) + self._last_error = None + + def _mark_offline(self, exc: Exception) -> None: + self._online = False + self._last_error = ( + f"{type(exc).__name__}: {exc}" + ) + + # ------------------------------------------------------------------ + # Background polling + # ------------------------------------------------------------------ + + async def _poll_loop(self) -> None: + """ + Фоновый цикл обновления состояния. + """ + + while self._running: + await asyncio.sleep(self._poll_interval) + + if not self._running: + break + + await self.refresh_state() + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + async def __aenter__(self) -> "TionService": + await self.start() + return self + + async def __aexit__( + self, + exc_type, + exc_value, + traceback, + ) -> None: + await self.stop() \ No newline at end of file diff --git a/tests/test-reconnect.py b/tests/test-reconnect.py new file mode 100644 index 0000000..83655ad --- /dev/null +++ b/tests/test-reconnect.py @@ -0,0 +1,37 @@ +import asyncio +import logging + + + +from app.my_dataclasses import TION_MAC +from app.tion import TionController, TionService + + +# Убираем служебное логирование библиотек + + +async def main(): + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + controller = TionController(TION_MAC) + + service = TionService( + controller, + poll_interval=3, + ) + + async with service: + for seconds in range(0, 91, 3): + print( + f"{seconds:02d}s | " + f"online={service.online} | " + f"last_seen={service.last_seen} | " + f"error={service.last_error}" + ) + + await asyncio.sleep(3) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test-scanning.py b/tests/test-scanning.py new file mode 100644 index 0000000..b3c12e7 --- /dev/null +++ b/tests/test-scanning.py @@ -0,0 +1,30 @@ +import asyncio +import logging + +from bleak import BleakScanner + +from app.my_dataclasses import TION_MAC + + +logging.disable(logging.CRITICAL) + + +async def main(): + print("Ищу Tion...") + + device = await BleakScanner.find_device_by_address( + TION_MAC, + timeout=10, + ) + + if device is None: + print("Tion НЕ найден сканером") + else: + print("Tion найден:") + print(f" name: {device.name}") + print(f" address: {device.address}") + print(f" details: {device.details}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/tests/test_tion_controller.py b/tests/test_tion_controller.py index 9cba0b3..63eaf95 100644 --- a/tests/test_tion_controller.py +++ b/tests/test_tion_controller.py @@ -55,6 +55,28 @@ async def main(): state = await tion.set_speed(3) print_state("После SPEED 3", state) + state = await tion.set_target_temperature(20) + print_state("TARGET TEMP 20°C", state) + + state = await tion.set_air_mode("recirculation") + print_state("RECIRCULATION", state) + await asyncio.sleep(3) + + state = await tion.set_air_mode("outside") + print_state("RECIRCULATION", state) + await asyncio.sleep(3) + + state = await tion.sound_off() + print_state("SOUND OFF", state) + + state = await tion.sound_on() + print_state("SOUND ON", state) + + state = await tion.light_off() + print_state("LIGHT OFF", state) + + state = await tion.light_on() + print_state("LIGHT ON", state) print() print(f"Connected after exit: {tion.connected}") diff --git a/tests/test_tion_service.py b/tests/test_tion_service.py new file mode 100644 index 0000000..46e538e --- /dev/null +++ b/tests/test_tion_service.py @@ -0,0 +1,84 @@ +import asyncio +import json +import logging +from app.tion import ( + TionController, + TionService, +) + +from app.my_dataclasses import * + + +def print_service(service: TionService) -> None: + print() + print("=" * 60) + + print(f"Running: {service.running}") + print(f"Online: {service.online}") + print(f"Last seen: {service.last_seen}") + print(f"Last error: {service.last_error}") + + if service.state is not None: + print() + print(json.dumps( + service.state.to_dict(), + indent=2, + ensure_ascii=False, + )) + + +async def main(): + + # Убираем лишнее логирование + logging.getLogger("asyncio").setLevel(logging.WARNING) + logging.getLogger("bleak").setLevel(logging.WARNING) + logging.getLogger("tion_btle").setLevel(logging.WARNING) + + controller = TionController(TION_MAC) + + service = TionService( + controller, + poll_interval=3, + ) + + async with service: + + print("=== После запуска ===") + print_service(service) + + print() + print("Ждём несколько циклов polling...") + + await asyncio.sleep(60) + + print_service(service) + + print() + print("=== Устанавливаем скорость 2 ===") + + state = await service.execute( + lambda tion: tion.set_speed(2) + ) + + print(json.dumps( + state.to_dict(), + indent=2, + ensure_ascii=False, + )) + + print() + print("=== Устанавливаем температуру 20°C ===") + + await service.execute( + lambda tion: tion.set_target_temperature(20) + ) + + print_service(service) + + print() + print("=== После stop ===") + print_service(service) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file