- critical: listen_port по умолчанию 0 (эфемерный) — несколько ConfigEntry не конфликтуют; тест двух записей на дефолтном порту - manifest: добавлен обязательный ключ codeowners - unload: set_log_handler(None) вынесен в executor (не блокирует loop); рефкаунт лог-пампа без гонки (старт после успешного setup) - config flow: возврат введённого в форму при ошибке (add_suggested_values_to_schema; секреты исключены), YAML-import подключён через async_setup + SOURCE_IMPORT, ProvisionError.kind вместо разбора русского текста, валидация device_port/key_id из JSON - trial: требует получения всех базовых свойств шаблона (не пустой ok), безопасное создание Session - py-ci.sh: отсутствие .venv-ha — ошибка (PY_CI_SKIP_HA=1 для пропуска); pytest.ini testpaths
197 lines
6.3 KiB
Python
197 lines
6.3 KiB
Python
"""Фоновый клиент FGLair: сессия pyfglair + доставка колбэков в event loop HA."""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Callable, Mapping, Optional
|
||
|
||
from homeassistant.core import HomeAssistant, callback
|
||
from homeassistant.exceptions import ConfigEntryNotReady
|
||
|
||
from pyfglair import (
|
||
Config,
|
||
Error,
|
||
Prop,
|
||
PropertyEvent,
|
||
Session,
|
||
State,
|
||
Template,
|
||
Value,
|
||
)
|
||
from pyfglair.templates import detect
|
||
|
||
from .const import (
|
||
CONF_DEVICE_PORT,
|
||
CONF_DSN,
|
||
CONF_HOST,
|
||
CONF_KEEPALIVE_MS,
|
||
CONF_LANIP_KEY,
|
||
CONF_LANIP_KEY_ID,
|
||
CONF_LISTEN_PORT,
|
||
CONF_MODEL,
|
||
CONF_TEMPLATE,
|
||
DEFAULT_DEVICE_PORT,
|
||
DEFAULT_KEEPALIVE_MS,
|
||
DEFAULT_LISTEN_PORT,
|
||
)
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
StateListener = Callable[[State, Error], None]
|
||
PropertyListener = Callable[[PropertyEvent], None]
|
||
|
||
|
||
def resolve_template(data: Mapping[str, Any]) -> Template:
|
||
"""Шаблон из данных записи: явный или по oem_model, иначе A."""
|
||
name = data.get(CONF_TEMPLATE)
|
||
if isinstance(name, str) and name in Template.__members__:
|
||
return Template[name]
|
||
model = data.get(CONF_MODEL)
|
||
if model:
|
||
found = detect(model)
|
||
if found is not None:
|
||
return found
|
||
return Template.A
|
||
|
||
|
||
class FglairClient:
|
||
"""Жизненный цикл сессии для одной ConfigEntry.
|
||
|
||
Создание/старт выполняются в executor (вызовы C-ядра), колбэки приходят
|
||
в event loop (Session создаётся с ``loop=hass.loop``).
|
||
"""
|
||
|
||
def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None:
|
||
self.hass = hass
|
||
self._data = dict(data)
|
||
self._session: Optional[Session] = None
|
||
self._state = State.IDLE
|
||
self._last_error = Error.NONE
|
||
self._state_listeners: list[StateListener] = []
|
||
self._property_listeners: list[PropertyListener] = []
|
||
self._dropped_notified = 0
|
||
|
||
@property
|
||
def state(self) -> State:
|
||
return self._state
|
||
|
||
@property
|
||
def last_error(self) -> Error:
|
||
return self._last_error
|
||
|
||
@property
|
||
def template(self) -> Template:
|
||
return resolve_template(self._data)
|
||
|
||
@property
|
||
def data(self) -> Mapping[str, Any]:
|
||
return self._data
|
||
|
||
@callback
|
||
def add_state_listener(self, listener: StateListener) -> Callable[[], None]:
|
||
self._state_listeners.append(listener)
|
||
return lambda: self._state_listeners.remove(listener)
|
||
|
||
@callback
|
||
def add_property_listener(
|
||
self, listener: PropertyListener
|
||
) -> Callable[[], None]:
|
||
self._property_listeners.append(listener)
|
||
return lambda: self._property_listeners.remove(listener)
|
||
|
||
@callback
|
||
def _on_state(self, state: State, error: Error) -> None:
|
||
self._state = state
|
||
self._last_error = error
|
||
self._notify_state()
|
||
if self._session is not None:
|
||
dropped = self._session.events_dropped
|
||
if dropped > self._dropped_notified:
|
||
_LOGGER.warning(
|
||
"FGLair: потеряно событий устройства: %d",
|
||
dropped - self._dropped_notified,
|
||
)
|
||
self._dropped_notified = dropped
|
||
|
||
@callback
|
||
def _on_property(self, event: PropertyEvent) -> None:
|
||
for listener in list(self._property_listeners):
|
||
listener(event)
|
||
|
||
def _notify_state(self) -> None:
|
||
for listener in list(self._state_listeners):
|
||
listener(self._state, self._last_error)
|
||
|
||
def _build_session(self) -> Session:
|
||
return Session(
|
||
Config(
|
||
host=self._data[CONF_HOST],
|
||
device_port=int(
|
||
self._data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)
|
||
),
|
||
dsn=self._data[CONF_DSN],
|
||
lanip_key=self._data[CONF_LANIP_KEY],
|
||
lanip_key_id=int(self._data[CONF_LANIP_KEY_ID]),
|
||
template=self.template,
|
||
listen_port=int(
|
||
self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)
|
||
),
|
||
keepalive_ms=int(
|
||
self._data.get(CONF_KEEPALIVE_MS, DEFAULT_KEEPALIVE_MS)
|
||
),
|
||
),
|
||
loop=self.hass.loop,
|
||
on_state=self._on_state,
|
||
on_property=self._on_property,
|
||
)
|
||
|
||
async def async_start(self) -> None:
|
||
if self._session is not None:
|
||
return
|
||
session = await self.hass.async_add_executor_job(self._build_session)
|
||
try:
|
||
started = await self.hass.async_add_executor_job(session.start)
|
||
except Exception:
|
||
await session.async_stop()
|
||
raise
|
||
if not started:
|
||
await session.async_stop()
|
||
raise ConfigEntryNotReady(
|
||
"не удалось запустить локальный HTTP-сервер FGLair "
|
||
f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})"
|
||
)
|
||
self._session = session
|
||
self._state = session.state
|
||
|
||
async def async_stop(self) -> None:
|
||
session, self._session = self._session, None
|
||
if session is not None:
|
||
await session.async_stop()
|
||
|
||
def cached(self, prop: Prop) -> Optional[Value]:
|
||
if self._session is None:
|
||
return None
|
||
return self._session.cached(prop)
|
||
|
||
def set_int(self, prop: Prop, value: int) -> bool:
|
||
return self._session is not None and self._session.set_int(prop, value)
|
||
|
||
def set_bool(self, prop: Prop, value: bool) -> bool:
|
||
return self._session is not None and self._session.set_bool(prop, value)
|
||
|
||
def set_string(self, prop: Prop, value: str) -> bool:
|
||
return (
|
||
self._session is not None and self._session.set_string(prop, value)
|
||
)
|
||
|
||
def get_prop(self, prop: Prop) -> bool:
|
||
return self._session is not None and self._session.get_prop(prop)
|
||
|
||
def batch_begin(self) -> bool:
|
||
return self._session is not None and self._session.batch_begin()
|
||
|
||
def batch_commit(self) -> bool:
|
||
return self._session is not None and self._session.batch_commit()
|
||
|
||
def batch_abort(self) -> bool:
|
||
return self._session is not None and self._session.batch_abort()
|