Files
fgl-aircon/custom_components/fglair/fglair_client.py
T
petr.polezhaev 29f62b639c ha(H2): ниты повторного ревью — pump без параллельных читателей, pending-рефкаунт, точная ошибка свойств
- session.py: новый лог-памп не поднимается, пока предыдущий не завершился
  (stopping/is_alive), параллельное чтение кольца исключено
- __init__: счётчик pending закрывает окно гонки setup/unload последней
  записи; warning при не-словарном fglair: в YAML
- trial: properties_missing — отдельная ошибка формы/abort (en/ru/strings)
- ConfigEntryNotReady: «порт 0» больше не показывается
- тесты: контраст одинакового явного порта, неблокирующий unload (тикер loop)
- план H5: публикация pyfglair именно на PyPI (manylinux x86_64/aarch64)
2026-09-29 12:29:35 +03:00

198 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Фоновый клиент 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()