doc: поправил зависимости и расписание
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from .controller import TionController
|
||||
from .models import TionState
|
||||
from .service import TionService
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TionController",
|
||||
"TionService",
|
||||
"TionState",
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from bleak import BleakScanner
|
||||
from tion_btle import TionS4
|
||||
|
||||
from .models import TionState
|
||||
|
||||
from app.my_dataclasses import *
|
||||
|
||||
|
||||
class TionController:
|
||||
"""
|
||||
Высокоуровневый контроллер Tion 4S.
|
||||
|
||||
Один экземпляр TionController должен владеть одним BLE-соединением
|
||||
с бризером на протяжении всей работы приложения.
|
||||
"""
|
||||
|
||||
def __init__(self, mac: str):
|
||||
self._mac = mac
|
||||
self._device: TionS4 | None = None
|
||||
|
||||
# Не позволяем двум частям программы одновременно работать с BLE.
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# Показывает, что мы удерживаем внешнее соединение tion-btle.
|
||||
self._started = False
|
||||
|
||||
# Последнее успешно прочитанное состояние.
|
||||
self._state: TionState | None = None
|
||||
|
||||
@property
|
||||
def mac(self) -> str:
|
||||
return self._mac
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return (
|
||||
self._device is not None
|
||||
and self._device.connection_status == "connected"
|
||||
)
|
||||
|
||||
async def _reset_device(self) -> None:
|
||||
"""
|
||||
Полностью уничтожить текущий BLE transport.
|
||||
|
||||
Важно:
|
||||
tion-btle не вызывает BleakClient.disconnect(),
|
||||
если WinRT уже считает устройство disconnected.
|
||||
|
||||
Поэтому при полном reset принудительно закрываем
|
||||
BleakClient напрямую.
|
||||
"""
|
||||
|
||||
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 with self._lock:
|
||||
if self.connected:
|
||||
self._started = True
|
||||
return
|
||||
|
||||
# Полностью закрываем всё, что осталось
|
||||
# от предыдущего соединения.
|
||||
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:
|
||||
"""
|
||||
Закрыть BLE-соединение.
|
||||
"""
|
||||
async with self._lock:
|
||||
await self._reset_device()
|
||||
|
||||
|
||||
"""
|
||||
Состояние
|
||||
"""
|
||||
|
||||
async def get_state(self) -> TionState:
|
||||
"""
|
||||
Получить реальное текущее состояние бризера.
|
||||
"""
|
||||
self._ensure_started()
|
||||
|
||||
async with self._lock:
|
||||
raw_state = await self._device.get()
|
||||
|
||||
state = TionState.from_raw(raw_state)
|
||||
self._state = state
|
||||
|
||||
return state
|
||||
|
||||
"""
|
||||
Питание
|
||||
"""
|
||||
|
||||
async def power_on(self) -> TionState:
|
||||
return await self._set({
|
||||
"state": "on"
|
||||
})
|
||||
|
||||
async def power_off(self) -> TionState:
|
||||
return await self._set({
|
||||
"state": "off"
|
||||
})
|
||||
|
||||
"""
|
||||
Скорость вентилятора
|
||||
"""
|
||||
|
||||
async def set_speed(self, speed: int) -> TionState:
|
||||
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
|
||||
raise ValueError("Tion fan speed must be between 1 and 6")
|
||||
|
||||
return await self._set({
|
||||
"fan_speed": speed
|
||||
})
|
||||
|
||||
"""
|
||||
Целевая температура
|
||||
"""
|
||||
|
||||
async def set_target_temperature(self, temperature: int) -> TionState:
|
||||
if not MIN_TARGET_TEMP <= temperature <= MAX_TARGET_TEMP:
|
||||
raise ValueError(
|
||||
f"Tion target temperature must be between "
|
||||
f"{MIN_TARGET_TEMP} and {MAX_TARGET_TEMP} °C"
|
||||
)
|
||||
|
||||
return await self._set({
|
||||
"heater_temp": temperature
|
||||
})
|
||||
|
||||
"""
|
||||
Нагрев
|
||||
"""
|
||||
|
||||
async def heater_on(self) -> TionState:
|
||||
return await self._set({
|
||||
"heater": "on"
|
||||
})
|
||||
|
||||
async def heater_off(self) -> TionState:
|
||||
return await self._set({
|
||||
"heater": "off"
|
||||
})
|
||||
|
||||
"""
|
||||
Забор воздуха
|
||||
"""
|
||||
|
||||
async def set_air_mode(self, mode: str) -> TionState:
|
||||
if mode not in SUPPORTED_AIR_MODES:
|
||||
raise ValueError(
|
||||
f"Unsupported Tion air mode: {mode}. "
|
||||
f"Available modes: {sorted(SUPPORTED_AIR_MODES)}"
|
||||
)
|
||||
|
||||
return await self._set({
|
||||
"mode": mode
|
||||
})
|
||||
|
||||
"""
|
||||
Звук
|
||||
"""
|
||||
async def sound_on(self) -> TionState:
|
||||
return await self._set({
|
||||
"sound": "on"
|
||||
})
|
||||
|
||||
async def sound_off(self) -> TionState:
|
||||
return await self._set({
|
||||
"sound": "off"
|
||||
})
|
||||
|
||||
"""
|
||||
Световая индикация
|
||||
"""
|
||||
|
||||
async def light_on(self) -> TionState:
|
||||
return await self._set({
|
||||
"light": "on"
|
||||
})
|
||||
|
||||
async def light_off(self) -> TionState:
|
||||
return await self._set({
|
||||
"light": "off"
|
||||
})
|
||||
|
||||
async def _set(self, settings: dict[str, Any]) -> TionState:
|
||||
"""
|
||||
Отправить настройки в Tion и затем прочитать
|
||||
фактическое состояние устройства.
|
||||
"""
|
||||
|
||||
self._ensure_started()
|
||||
|
||||
async with self._lock:
|
||||
await self._device.set(settings)
|
||||
|
||||
raw_state = await self._device.get()
|
||||
|
||||
state = TionState.from_raw(raw_state)
|
||||
self._state = state
|
||||
|
||||
return state
|
||||
|
||||
def _ensure_started(self) -> None:
|
||||
if not self._started:
|
||||
raise RuntimeError(
|
||||
"TionController is not connected. "
|
||||
"Call await controller.connect() first."
|
||||
)
|
||||
|
||||
async def __aenter__(self) -> "TionController":
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
||||
await self.disconnect()
|
||||
@@ -0,0 +1,56 @@
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _is_on(value: Any) -> bool:
|
||||
return value == "on"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TionState:
|
||||
power: bool
|
||||
heater: bool
|
||||
heating: bool
|
||||
sound: bool
|
||||
mode: str
|
||||
|
||||
out_temp: int
|
||||
in_temp: int
|
||||
target_temp: int
|
||||
|
||||
fan_speed: int
|
||||
filter_remain: float
|
||||
|
||||
device_time: str
|
||||
request_error_code: int
|
||||
model: str
|
||||
|
||||
light: bool | None = None
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: Mapping[str, Any]) -> "TionState":
|
||||
light_raw = data.get("light")
|
||||
|
||||
return cls(
|
||||
power=_is_on(data["state"]),
|
||||
heater=_is_on(data["heater"]),
|
||||
heating=_is_on(data["heating"]),
|
||||
sound=_is_on(data["sound"]),
|
||||
mode=str(data["mode"]),
|
||||
|
||||
out_temp=int(data["out_temp"]),
|
||||
in_temp=int(data["in_temp"]),
|
||||
target_temp=int(data["heater_temp"]),
|
||||
|
||||
fan_speed=int(data["fan_speed"]),
|
||||
filter_remain=float(data["filter_remain"]),
|
||||
|
||||
device_time=str(data["time"]),
|
||||
request_error_code=int(data["request_error_code"]),
|
||||
model=str(data["model"]),
|
||||
|
||||
light=None if light_raw is None else _is_on(light_raw),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user