"""Фоновый клиент 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() port = self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) raise ConfigEntryNotReady( "не удалось запустить локальный HTTP-сервер FGLair" + (f" (порт {port})" if port else "") ) 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()