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
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"""Фоновый клиент 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()
|
||||
Reference in New Issue
Block a user