work: Отладил переподключение к Tion. Раньше при разрыве соединения в библиотеке btle соединение зависало. Сейчас при сбросе устройства явно вызывается disconnect библиотеки, чтобы сессия Bluetooth освобождалась.
This commit is contained in:
@@ -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
@@ -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
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Состояние
|
Состояние
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user