Files
fgl-aircon/custom_components/fglair/fglair_client.py
T
petr.polezhaev 26d1d6d64f ha(H2): компонент fglair — manifest, config flow (облако/ручной/импорт), пробная сессия
- custom_components/fglair: manifest (config_flow, local_push, requirements),
  const, trial (start -> ONLINE <=10 c -> GET базовых свойств), FglairClient
  (сессия pyfglair в executor, колбэки в loop, --runtime_data),
  __init__ (setup/unload, рефкаунт глобального лог-пампа)
- config flow: меню cloud/manual/import_json + import-source; discover через
  pyfglair.provision; пробное подключение с диагностикой (cannot_connect,
  no_slots, key_mismatch, timeout, invalid_auth, invalid_import); unique_id=dsn
- strings.json + translations/{en,ru}.json
- тесты HA (pytest-homeassistant-custom-component): меню/шаги/ошибки/импорт/
  облако (discover замокан), setup/unload, занятый порт; trial против
  mock_ac.py (online/key_mismatch/unreachable/no_slots)
- pytest.ini (общий), tests/conftest перенесён на уровень tests/,
  scripts/py-ci.sh: --setup-ha и прогон tests/components
2026-09-29 12:03:44 +03:00

195 lines
6.3 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
self._session = await self.hass.async_add_executor_job(
self._build_session
)
started = await self.hass.async_add_executor_job(self._session.start)
if not started:
await self._session.async_stop()
self._session = None
raise ConfigEntryNotReady(
"не удалось запустить локальный HTTP-сервер FGLair "
f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})"
)
self._state = self._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()