work: Отладил переподключение к Tion. Раньше при разрыве соединения в библиотеке btle соединение зависало. Сейчас при сбросе устройства явно вызывается disconnect библиотеки, чтобы сессия Bluetooth освобождалась.

This commit is contained in:
Fedorov Dmitriy
2026-09-13 21:35:37 +03:00
parent 5cc125db3f
commit fdae9dec72
7 changed files with 557 additions and 18 deletions
+3
View File
@@ -1,7 +1,10 @@
from .controller import TionController from .controller import TionController
from .models import TionState from .models import TionState
from .service import TionService
__all__ = [ __all__ = [
"TionController", "TionController",
"TionService",
"TionState", "TionState",
] ]
+74 -18
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
from typing import Any from typing import Any
from bleak import BleakScanner
from tion_btle import TionS4 from tion_btle import TionS4
from .models import TionState from .models import TionState
@@ -18,7 +18,7 @@ class TionController:
def __init__(self, mac: str): def __init__(self, mac: str):
self._mac = mac self._mac = mac
self._device = TionS4(mac) self._device: TionS4 | None = None
# Не позволяем двум частям программы одновременно работать с BLE. # Не позволяем двум частям программы одновременно работать с BLE.
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@@ -35,27 +35,88 @@ class TionController:
@property @property
def connected(self) -> bool: def connected(self) -> bool:
return self._device.connection_status == "connected" return (
self._device is not None
and self._device.connection_status == "connected"
)
@property async def _reset_device(self) -> None:
def state(self) -> TionState | 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: async def connect(self) -> None:
"""
Открыть постоянное BLE-соединение.
"""
async with self._lock: async with self._lock:
if self._started: if self.connected:
self._started = True
return 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 self._started = True
async def disconnect(self) -> None: async def disconnect(self) -> None:
@@ -63,13 +124,8 @@ class TionController:
Закрыть BLE-соединение. Закрыть BLE-соединение.
""" """
async with self._lock: async with self._lock:
if not self._started: await self._reset_device()
return
try:
await self._device.disconnect()
finally:
self._started = False
""" """
Состояние Состояние
+307
View File
@@ -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()
+37
View File
@@ -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())
+30
View File
@@ -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())
+22
View File
@@ -55,6 +55,28 @@ async def main():
state = await tion.set_speed(3) state = await tion.set_speed(3)
print_state("После SPEED 3", state) 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()
print(f"Connected after exit: {tion.connected}") print(f"Connected after exit: {tion.connected}")
+84
View File
@@ -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())