diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py new file mode 100644 index 0000000..e9fc81a --- /dev/null +++ b/custom_components/fglair/__init__.py @@ -0,0 +1,65 @@ +"""FGLair (Fujitsu General) — локальное управление через LAN-протокол Ayla.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +import pyfglair + +from .const import DOMAIN +from .fglair_client import FglairClient + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = [] + +_LOG_LEVELS = { + 0: logging.DEBUG, + 1: logging.INFO, + 2: logging.WARNING, + 3: logging.ERROR, +} + +DATA_CLIENTS = f"{DOMAIN}_clients" + + +def _log_handler(level: int, message: str) -> None: + _LOGGER.log(_LOG_LEVELS.get(level, logging.DEBUG), "pyfglair: %s", message) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + clients: dict[str, FglairClient] = hass.data.setdefault(DATA_CLIENTS, {}) + if not clients: + pyfglair.set_log_handler(_log_handler) + pyfglair.set_log_level(1) + + client = FglairClient(hass, entry.data) + try: + await client.async_start() + except Exception: + if not clients: + pyfglair.set_log_handler(None) + raise + clients[entry.entry_id] = client + entry.runtime_data = client + + if PLATFORMS: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + client: FglairClient = entry.runtime_data + await client.async_stop() + + clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) + clients.pop(entry.entry_id, None) + if not clients: + pyfglair.set_log_handler(None) + return True diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py new file mode 100644 index 0000000..4e707d0 --- /dev/null +++ b/custom_components/fglair/config_flow.py @@ -0,0 +1,307 @@ +"""Config flow fglair: облако / ручной ввод / импорт config_*.json + пробная +сессия (start → ONLINE ≤ 10 c → базовые свойства).""" +from __future__ import annotations + +import json +import logging +from functools import partial +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers import selector + +import pyfglair +from pyfglair import Template +from pyfglair.provision import Device +from pyfglair.templates import detect + +from .const import ( + CONF_CONFIG_JSON, + CONF_DEVICE_PORT, + CONF_DSN, + CONF_EMAIL, + CONF_HOST, + CONF_LANIP_KEY, + CONF_LANIP_KEY_ID, + CONF_LISTEN_PORT, + CONF_MODEL, + CONF_NAME, + CONF_PASSWORD, + CONF_REGION, + CONF_TEMPLATE, + DEFAULT_DEVICE_PORT, + DEFAULT_LISTEN_PORT, + DOMAIN, + REGIONS, + TRIAL_ERRORS, + TRIAL_TIMEOUT, +) +from .trial import TrialResult, trial_connect + +_LOGGER = logging.getLogger(__name__) + + +def _password_selector() -> selector.TextSelector: + return selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ) + + +def _cloud_schema() -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): _password_selector(), + vol.Required(CONF_REGION, default="eu"): vol.In(REGIONS), + } + ) + + +def _manual_schema() -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional( + CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT + ): vol.All(int, vol.Range(min=1, max=65535)), + vol.Required(CONF_DSN): str, + vol.Required(CONF_LANIP_KEY): _password_selector(), + vol.Required(CONF_LANIP_KEY_ID): vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_MODEL): str, + } + ) + + +def _import_schema() -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_CONFIG_JSON): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT, multiline=True + ) + ) + } + ) + + +def _template_name(model: str | None) -> str: + if model: + found = detect(model) + if found is not None: + return found.name + return Template.A.name + + +def _manual_data(user_input: dict[str, Any]) -> dict[str, Any]: + model = str(user_input.get(CONF_MODEL) or "").strip() + return { + CONF_HOST: str(user_input[CONF_HOST]).strip(), + CONF_DEVICE_PORT: int( + user_input.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT) + ), + CONF_DSN: str(user_input[CONF_DSN]).strip(), + CONF_LANIP_KEY: str(user_input[CONF_LANIP_KEY]).strip(), + CONF_LANIP_KEY_ID: int(user_input[CONF_LANIP_KEY_ID]), + CONF_NAME: str(user_input.get(CONF_NAME) or "").strip(), + CONF_MODEL: model, + CONF_TEMPLATE: _template_name(model), + } + + +def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None: + """config_*.json (fglair-discover/fglctl) → данные записи.""" + if isinstance(source, str): + try: + obj = json.loads(source) + except ValueError: + return None + else: + obj = source + if not isinstance(obj, dict): + return None + host = obj.get("ip_address") or obj.get("host") + dsn = obj.get("dsn") + lanip_key = obj.get("lanip_key") + lanip_key_id = obj.get("lanip_key_id") + if not host or not dsn or not lanip_key or lanip_key_id is None: + return None + try: + key_id = int(lanip_key_id) + except (TypeError, ValueError): + return None + model = str(obj.get("model") or "").strip() + return { + CONF_HOST: str(host).strip(), + CONF_DEVICE_PORT: int(obj.get("device_port", DEFAULT_DEVICE_PORT)), + CONF_DSN: str(dsn).strip(), + CONF_LANIP_KEY: str(lanip_key).strip(), + CONF_LANIP_KEY_ID: key_id, + CONF_NAME: str(obj.get("name") or "").strip(), + CONF_MODEL: model, + CONF_TEMPLATE: _template_name(model), + } + + +def _run_trial(data: dict[str, Any]) -> TrialResult: + return trial_connect( + host=data[CONF_HOST], + dsn=data[CONF_DSN], + lanip_key=data[CONF_LANIP_KEY], + lanip_key_id=int(data[CONF_LANIP_KEY_ID]), + device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)), + template=Template[str(data.get(CONF_TEMPLATE, "A"))], + listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)), + timeout=TRIAL_TIMEOUT, + ) + + +class FglairConfigFlow(ConfigFlow, domain=DOMAIN): + """Настройка интеграции: облако, ручной ввод или импорт.""" + + VERSION = 1 + + def __init__(self) -> None: + self._devices: list[Device] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + return self.async_show_menu( + step_id="user", menu_options=["cloud", "manual", "import_json"] + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + try: + self._devices = await pyfglair.discover( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + user_input[CONF_REGION], + ) + except pyfglair.ProvisionError as err: + _LOGGER.debug("discover не удался: %s", err) + text = str(err) + if "вход" in text: + errors["base"] = "invalid_auth" + elif "нет устройств" in text or "Не найдено" in text: + errors["base"] = "no_devices" + else: + errors["base"] = "cannot_connect" + except Exception: # pragma: no cover - защитный путь + _LOGGER.exception("discover: неожиданная ошибка") + errors["base"] = "cannot_connect" + else: + if not self._devices: + errors["base"] = "no_devices" + else: + return await self.async_step_cloud_device() + return self.async_show_form( + step_id="cloud", data_schema=_cloud_schema(), errors=errors + ) + + async def async_step_cloud_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + device = next( + (d for d in self._devices if d.dsn == user_input[CONF_DSN]), None + ) + if device is None: + errors["base"] = "unknown" + else: + data = { + CONF_HOST: device.lan_ip or device.dsn, + CONF_DEVICE_PORT: DEFAULT_DEVICE_PORT, + CONF_DSN: device.dsn, + CONF_LANIP_KEY: device.lanip_key, + CONF_LANIP_KEY_ID: device.lanip_key_id, + CONF_NAME: device.name, + CONF_MODEL: device.model, + CONF_TEMPLATE: _template_name(device.model), + CONF_REGION: device.region, + } + error = await self._async_trial(data) + if error is None: + return self._create_entry(data) + errors["base"] = error + schema = vol.Schema( + { + vol.Required(CONF_DSN): vol.In( + { + d.dsn: f"{d.name} ({d.model or '?'}, {d.lan_ip or '?'})" + for d in self._devices + } + ) + } + ) + return self.async_show_form( + step_id="cloud_device", data_schema=schema, errors=errors + ) + + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + data = _manual_data(user_input) + error = await self._async_trial(data) + if error is None: + return self._create_entry(data) + errors["base"] = error + return self.async_show_form( + step_id="manual", data_schema=_manual_schema(), errors=errors + ) + + async def async_step_import_json( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + data = _parse_import(user_input[CONF_CONFIG_JSON]) + if data is None: + errors["base"] = "invalid_import" + else: + error = await self._async_trial(data) + if error is None: + return self._create_entry(data) + errors["base"] = error + return self.async_show_form( + step_id="import_json", data_schema=_import_schema(), errors=errors + ) + + async def async_step_import( + self, import_data: dict[str, Any] + ) -> ConfigFlowResult: + """Импорт из configuration.yaml / discovery-конфига.""" + data = _parse_import(import_data) if isinstance(import_data, dict) else None + if data is None: + return self.async_abort(reason="invalid_import") + error = await self._async_trial(data) + if error is not None: + return self.async_abort(reason=error) + return self._create_entry(data) + + async def _async_trial(self, data: dict[str, Any]) -> str | None: + """None — сессия поднялась; иначе ключ ошибки формы.""" + await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) + self._abort_if_unique_id_configured() + result: TrialResult = await self.hass.async_add_executor_job( + partial(_run_trial, data) + ) + if result.ok: + return None + _LOGGER.debug( + "пробное подключение не удалось: reason=%s state=%s error=%s", + result.reason, result.state, result.error, + ) + return TRIAL_ERRORS.get(result.reason, "cannot_connect") + + def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + title = data.get(CONF_NAME) or data[CONF_DSN] + return self.async_create_entry(title=title, data=data) diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py new file mode 100644 index 0000000..29ba04b --- /dev/null +++ b/custom_components/fglair/const.py @@ -0,0 +1,39 @@ +"""Константы интеграции fglair.""" +from __future__ import annotations + +from pyfglair import State +from pyfglair.provision import REGIONS as _PROVISION_REGIONS + +DOMAIN = "fglair" + +CONF_HOST = "host" +CONF_DEVICE_PORT = "device_port" +CONF_DSN = "dsn" +CONF_LANIP_KEY = "lanip_key" +CONF_LANIP_KEY_ID = "lanip_key_id" +CONF_MODEL = "model" +CONF_TEMPLATE = "template" +CONF_NAME = "name" +CONF_LISTEN_PORT = "listen_port" +CONF_KEEPALIVE_MS = "keepalive_ms" +CONF_REGION = "region" +CONF_EMAIL = "email" +CONF_PASSWORD = "password" +CONF_CONFIG_JSON = "config_json" + +DEFAULT_DEVICE_PORT = 80 +DEFAULT_LISTEN_PORT = 10275 +DEFAULT_KEEPALIVE_MS = 15000 +TRIAL_TIMEOUT = 10.0 + +REGIONS = sorted(_PROVISION_REGIONS) + +TRIAL_ERRORS = { + "no_slots": "no_slots", + "key_mismatch": "key_mismatch", + "unreachable": "cannot_connect", + "timeout": "timeout", + "start_failed": "cannot_connect", +} + +STATE_FAILED = {State.OFFLINE, State.KEY_ERROR} diff --git a/custom_components/fglair/fglair_client.py b/custom_components/fglair/fglair_client.py new file mode 100644 index 0000000..9668801 --- /dev/null +++ b/custom_components/fglair/fglair_client.py @@ -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() diff --git a/custom_components/fglair/manifest.json b/custom_components/fglair/manifest.json new file mode 100644 index 0000000..f2de7f6 --- /dev/null +++ b/custom_components/fglair/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "fglair", + "name": "FGLair (Fujitsu General)", + "config_flow": true, + "documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon", + "integration_type": "device", + "iot_class": "local_push", + "issue_tracker": "https://git.ratigorsk-12.ru/esphome/fgl-aircon/-/issues", + "loggers": ["pyfglair"], + "requirements": ["pyfglair>=1.0.0"], + "version": "0.1.0" +} diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json new file mode 100644 index 0000000..c7d4d90 --- /dev/null +++ b/custom_components/fglair/strings.json @@ -0,0 +1,77 @@ +{ + "config": { + "step": { + "user": { + "title": "FGLair", + "menu_options": { + "cloud": "Cloud sign-in (FGLair account)", + "manual": "Manual setup", + "import_json": "Import config_*.json" + } + }, + "cloud": { + "title": "FGLair cloud sign-in", + "description": "Used once for provisioning: fetches the static LAN key of the device.", + "data": { + "email": "Email", + "password": "Password", + "region": "Region" + }, + "data_description": { + "region": "FGLair/Ayla account region" + } + }, + "cloud_device": { + "title": "Select air conditioner", + "data": { + "dsn": "Device" + } + }, + "manual": { + "title": "Device parameters", + "data": { + "host": "Host (IP or DNS name)", + "device_port": "Device port", + "dsn": "DSN", + "lanip_key": "LAN IP key", + "lanip_key_id": "LAN IP key ID", + "name": "Name (optional)", + "model": "Model / oem_model (optional)" + }, + "data_description": { + "device_port": "Default 80; change only for port-forwarding setups", + "lanip_key": "From cloud discovery or ESPHome diagnostics" + } + }, + "import_json": { + "title": "Import config_*.json", + "data": { + "config_json": "config JSON" + }, + "data_description": { + "config_json": "Paste a JSON object produced by fglair-discover or fglctl" + } + } + }, + "error": { + "cannot_connect": "Could not connect to the device", + "no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry", + "key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config", + "timeout": "Timed out waiting for the device to come online", + "invalid_auth": "Invalid email or password", + "invalid_import": "Invalid config JSON", + "no_devices": "No devices found in the account", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This device is already configured", + "invalid_import": "Invalid config JSON", + "cannot_connect": "Could not connect to the device", + "no_slots": "Both LAN slots are busy", + "key_mismatch": "LAN key does not match the device", + "timeout": "Timed out waiting for the device", + "start_failed": "Could not start the local FGLair server", + "unknown": "Unexpected error" + } + } +} diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json new file mode 100644 index 0000000..c7d4d90 --- /dev/null +++ b/custom_components/fglair/translations/en.json @@ -0,0 +1,77 @@ +{ + "config": { + "step": { + "user": { + "title": "FGLair", + "menu_options": { + "cloud": "Cloud sign-in (FGLair account)", + "manual": "Manual setup", + "import_json": "Import config_*.json" + } + }, + "cloud": { + "title": "FGLair cloud sign-in", + "description": "Used once for provisioning: fetches the static LAN key of the device.", + "data": { + "email": "Email", + "password": "Password", + "region": "Region" + }, + "data_description": { + "region": "FGLair/Ayla account region" + } + }, + "cloud_device": { + "title": "Select air conditioner", + "data": { + "dsn": "Device" + } + }, + "manual": { + "title": "Device parameters", + "data": { + "host": "Host (IP or DNS name)", + "device_port": "Device port", + "dsn": "DSN", + "lanip_key": "LAN IP key", + "lanip_key_id": "LAN IP key ID", + "name": "Name (optional)", + "model": "Model / oem_model (optional)" + }, + "data_description": { + "device_port": "Default 80; change only for port-forwarding setups", + "lanip_key": "From cloud discovery or ESPHome diagnostics" + } + }, + "import_json": { + "title": "Import config_*.json", + "data": { + "config_json": "config JSON" + }, + "data_description": { + "config_json": "Paste a JSON object produced by fglair-discover or fglctl" + } + } + }, + "error": { + "cannot_connect": "Could not connect to the device", + "no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry", + "key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config", + "timeout": "Timed out waiting for the device to come online", + "invalid_auth": "Invalid email or password", + "invalid_import": "Invalid config JSON", + "no_devices": "No devices found in the account", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This device is already configured", + "invalid_import": "Invalid config JSON", + "cannot_connect": "Could not connect to the device", + "no_slots": "Both LAN slots are busy", + "key_mismatch": "LAN key does not match the device", + "timeout": "Timed out waiting for the device", + "start_failed": "Could not start the local FGLair server", + "unknown": "Unexpected error" + } + } +} diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json new file mode 100644 index 0000000..c704197 --- /dev/null +++ b/custom_components/fglair/translations/ru.json @@ -0,0 +1,77 @@ +{ + "config": { + "step": { + "user": { + "title": "FGLair", + "menu_options": { + "cloud": "Вход в облако FGLair", + "manual": "Ввод параметров вручную", + "import_json": "Импорт config_*.json" + } + }, + "cloud": { + "title": "Вход в облако FGLair", + "description": "Нужен однократно для провижинига: получает статический LAN-ключ устройства.", + "data": { + "email": "E-mail", + "password": "Пароль", + "region": "Регион" + }, + "data_description": { + "region": "Регион аккаунта FGLair/Ayla" + } + }, + "cloud_device": { + "title": "Выбор кондиционера", + "data": { + "dsn": "Устройство" + } + }, + "manual": { + "title": "Параметры устройства", + "data": { + "host": "Хост (IP или DNS-имя)", + "device_port": "Порт модуля", + "dsn": "DSN", + "lanip_key": "LAN IP key", + "lanip_key_id": "LAN IP key ID", + "name": "Имя (необязательно)", + "model": "Модель / oem_model (необязательно)" + }, + "data_description": { + "device_port": "По умолчанию 80; меняйте только для проброса портов", + "lanip_key": "Из облачного discovery или диагностики ESPHome" + } + }, + "import_json": { + "title": "Импорт config_*.json", + "data": { + "config_json": "JSON конфигурации" + }, + "data_description": { + "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться к устройству", + "no_slots": "Оба LAN-слота заняты (приложение FGLair или ESPHome). Освободите один и повторите", + "key_mismatch": "LAN-ключ не совпадает с устройством; повторите discovery или импортируйте config заново", + "timeout": "Устройство не вышло на связь за отведённое время", + "invalid_auth": "Неверный e-mail или пароль", + "invalid_import": "Некорректный config JSON", + "no_devices": "В аккаунте не найдено устройств", + "unknown": "Неожиданная ошибка" + }, + "abort": { + "already_configured": "Устройство уже настроено", + "invalid_import": "Некорректный config JSON", + "cannot_connect": "Не удалось подключиться к устройству", + "no_slots": "Оба LAN-слота заняты", + "key_mismatch": "LAN-ключ не совпадает с устройством", + "timeout": "Устройство не вышло на связь за отведённое время", + "start_failed": "Не удалось запустить локальный сервер FGLair", + "unknown": "Неожиданная ошибка" + } + } +} diff --git a/custom_components/fglair/trial.py b/custom_components/fglair/trial.py new file mode 100644 index 0000000..75b1315 --- /dev/null +++ b/custom_components/fglair/trial.py @@ -0,0 +1,129 @@ +"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение +базовых свойств. Выполняется в executor (блокирующие вызовы ядра). +""" +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass, field +from typing import Iterable + +from pyfglair import Config, Error, Prop, Session, State, Template, Value + +BASE_PROPS: tuple[Prop, ...] = ( + Prop.OPERATION_MODE, + Prop.FAN_SPEED, + Prop.DISPLAY_TEMPERATURE, +) + + +@dataclass +class TrialResult: + ok: bool + reason: str = "" + state: State = State.IDLE + error: Error = Error.NONE + properties: dict[Prop, Value] = field(default_factory=dict) + + +def _failure_reason(state: State, error: Error) -> str: + if state == State.KEY_ERROR or error == Error.KEY_MISMATCH: + return "key_mismatch" + if error == Error.NO_SLOT: + return "no_slots" + if error in (Error.UNREACHABLE, Error.ACTIVATION_TIMEOUT): + return "unreachable" + return "timeout" + + +def trial_connect( + host: str, + dsn: str, + lanip_key: str, + lanip_key_id: int, + *, + device_port: int = 80, + template: Template = Template.A, + listen_port: int = 10275, + keepalive_ms: int = 15000, + timeout: float = 10.0, + props: Iterable[Prop] = BASE_PROPS, +) -> TrialResult: + """Поднимает сессию, ждёт ONLINE и читает базовые свойства. + + Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``. + """ + lock = threading.Lock() + box = {"state": State.IDLE, "error": Error.NONE} + values: dict[Prop, Value] = {} + wanted = tuple(props) + + def on_state(state: State, error: Error) -> None: + with lock: + box["state"] = state + box["error"] = error + + def on_property(event) -> None: + with lock: + values[event.prop] = event.value + + session = Session( + Config( + host=host, + device_port=device_port, + dsn=dsn, + lanip_key=lanip_key, + lanip_key_id=lanip_key_id, + template=template, + listen_port=listen_port, + keepalive_ms=keepalive_ms, + ), + loop=None, + on_state=on_state, + on_property=on_property, + ) + try: + try: + started = session.start() + except Exception: # pragma: no cover - защитный путь + return TrialResult(ok=False, reason="start_failed") + if not started: + return TrialResult(ok=False, reason="start_failed") + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with lock: + state, error = box["state"], box["error"] + if state == State.ONLINE: + break + if state == State.KEY_ERROR: + return TrialResult( + ok=False, reason=_failure_reason(state, error), + state=state, error=error, + ) + time.sleep(0.05) + else: + with lock: + state, error = box["state"], box["error"] + return TrialResult( + ok=False, reason=_failure_reason(state, error), + state=state, error=error, + ) + + session.batch_begin() + for prop in wanted: + session.get_prop(prop) + session.batch_commit() + + prop_deadline = min(deadline, time.monotonic() + 2.0) + while time.monotonic() < prop_deadline: + with lock: + if all(prop in values for prop in wanted): + break + time.sleep(0.05) + + with lock: + snapshot = dict(values) + return TrialResult(ok=True, state=State.ONLINE, properties=snapshot) + finally: + session.close() diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 5c4a8c7..e5f581c 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -155,7 +155,7 @@ Security → Long-lived access tokens) и REST API: вызов сервисов | # | Содержимое | |---|-----------| | H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | -| H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | +| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H4 | repair, диагностика (ключ для ESPHome), translations | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз | diff --git a/pyfglair/session.py b/pyfglair/session.py index 2abec03..d5ab0b3 100644 --- a/pyfglair/session.py +++ b/pyfglair/session.py @@ -65,12 +65,13 @@ class _LogPump(threading.Thread): def __init__(self) -> None: super().__init__(name="pyfglair-log", daemon=True) + self._stop = threading.Event() def run(self) -> None: out = ffi.new("fgl_log_event_t[]", 8) dropped = 0 warned_at = 0.0 - while True: + while not self._stop.is_set(): total = int(lib.fgl_log_events_dropped()) if total > dropped: if time.monotonic() - warned_at > 30.0: @@ -80,7 +81,7 @@ class _LogPump(threading.Thread): ) warned_at = time.monotonic() dropped = total - count = lib.fgl_log_wait_events(out, 8, 500) + count = lib.fgl_log_wait_events(out, 8, 250) handler = _GLOBAL_LOG if handler is None: continue @@ -93,13 +94,25 @@ class _LogPump(threading.Thread): except Exception: _LOGGER.exception("log handler failed") + def stop(self) -> None: + self._stop.set() + self.join(timeout=2.0) + def set_log_handler(handler: Optional[LogHandler]) -> None: - """Глобальный приёмник логов ядра (0=debug..3=error).""" + """Глобальный приёмник логов ядра (0=debug..3=error). + + ``None`` останавливает pump-поток (если он запущен). + """ global _GLOBAL_LOG, _LOG_PUMP with _LOG_PUMP_LOCK: _GLOBAL_LOG = handler - if handler is not None and _LOG_PUMP is None: + if handler is None: + pump, _LOG_PUMP = _LOG_PUMP, None + if pump is not None: + pump.stop() + return + if _LOG_PUMP is None: _LOG_PUMP = _LogPump() _LOG_PUMP.start() diff --git a/pyproject.toml b/pyproject.toml index 573b3f9..723722b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,8 +28,3 @@ zip-safe = false [tool.setuptools.package-data] pyfglair = ["py.typed"] - -[tool.pytest.ini_options] -testpaths = ["tests/pyfglair"] -asyncio_mode = "auto" -filterwarnings = ["error::ResourceWarning"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e32aadf --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +filterwarnings = + error::ResourceWarning diff --git a/scripts/py-ci.sh b/scripts/py-ci.sh index 16cc859..5d4c747 100644 --- a/scripts/py-ci.sh +++ b/scripts/py-ci.sh @@ -1,17 +1,26 @@ #!/bin/bash -# CI python-части репозитория (pyfglair; позже — тесты HA-компонента). -# scripts/py-ci.sh --setup # создать venv и поставить зависимости -# scripts/py-ci.sh # прогнать pytest (ядро соберётся в conftest) +# CI python-части репозитория: pyfglair + HA-компонент. +# scripts/py-ci.sh --setup # venv для pyfglair-тестов +# scripts/py-ci.sh --setup-ha # venv с homeassistant/pytest-плагином (тяжёлый) +# scripts/py-ci.sh # pytest: tests/pyfglair + tests/components (если есть venv) set -euo pipefail cd "$(dirname "$0")/.." VENV="${PY_VENV:-.venv}" +HA_VENV="${HA_VENV:-.venv-ha}" -if [ "${1:-}" = "--setup" ]; then - python3 -m venv "$VENV" - "$VENV/bin/pip" install -q --upgrade pip - "$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio -fi +case "${1:-}" in + --setup) + python3 -m venv "$VENV" + "$VENV/bin/pip" install -q --upgrade pip + "$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio + ;; + --setup-ha) + python3 -m venv "$HA_VENV" + "$HA_VENV/bin/pip" install -q --upgrade pip + "$HA_VENV/bin/pip" install -q -r tests/components/requirements.txt + ;; +esac if [ ! -x "$VENV/bin/python" ]; then echo "py-ci: нет $VENV (запустите: scripts/py-ci.sh --setup)" >&2 @@ -19,3 +28,9 @@ if [ ! -x "$VENV/bin/python" ]; then fi "$VENV/bin/python" -m pytest tests/pyfglair -q + +if [ -x "$HA_VENV/bin/python" ]; then + "$HA_VENV/bin/python" -m pytest tests/components -q +else + echo "py-ci: нет $HA_VENV — HA-тесты пропущены (scripts/py-ci.sh --setup-ha)" +fi diff --git a/tests/components/fglair/conftest.py b/tests/components/fglair/conftest.py new file mode 100644 index 0000000..bb3d243 --- /dev/null +++ b/tests/components/fglair/conftest.py @@ -0,0 +1,42 @@ +"""Фикстуры тестов HA-компонента fglair.""" +from __future__ import annotations + +import os + +import pytest +from helpers import DSN, KEY_ID, LANIP_KEY +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.fglair.const import ( + CONF_DEVICE_PORT, + CONF_DSN, + CONF_HOST, + CONF_LANIP_KEY, + CONF_LANIP_KEY_ID, + CONF_LISTEN_PORT, + CONF_TEMPLATE, + DOMAIN, +) + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield + + +@pytest.fixture +def entry_data() -> dict: + return { + CONF_HOST: "127.0.0.1", + CONF_DEVICE_PORT: 9, + CONF_DSN: DSN, + CONF_LANIP_KEY: LANIP_KEY, + CONF_LANIP_KEY_ID: KEY_ID, + CONF_TEMPLATE: "A", + CONF_LISTEN_PORT: 20000 + (os.getpid() % 20000), + } + + +@pytest.fixture +def config_entry(entry_data) -> MockConfigEntry: + return MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=DSN) diff --git a/tests/components/fglair/helpers.py b/tests/components/fglair/helpers.py new file mode 100644 index 0000000..20c2608 --- /dev/null +++ b/tests/components/fglair/helpers.py @@ -0,0 +1,16 @@ +"""Общие данные HA-тестов fglair (импортируется как top-level модуль).""" +from __future__ import annotations + +import socket + +DSN = "AC000W00MOCK0001" +LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw==" +KEY_ID = 64201 + + +def free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py new file mode 100644 index 0000000..7ca2aa8 --- /dev/null +++ b/tests/components/fglair/test_config_flow.py @@ -0,0 +1,199 @@ +"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан).""" +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +from helpers import DSN, KEY_ID, LANIP_KEY +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +import pyfglair +from custom_components.fglair.const import DOMAIN +from custom_components.fglair.trial import TrialResult +from pyfglair.provision import Device + +MANUAL_INPUT = { + "host": "192.168.1.50", + "device_port": 80, + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "name": "Bedroom", + "model": "AP-WC1E", +} + +TRIAL_OK = TrialResult(ok=True) +TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable") + + +async def _menu(hass: HomeAssistant): + return await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + +async def _select(hass: HomeAssistant, flow_id: str, option: str): + return await hass.config_entries.flow.async_configure( + flow_id, {"next_step_id": option} + ) + + +async def test_user_menu(hass: HomeAssistant): + result = await _menu(hass) + assert result["type"] == "menu" + assert set(result["menu_options"]) == {"cloud", "manual", "import_json"} + + +async def test_manual_success(hass: HomeAssistant): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ) as trial: + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "manual") + assert result["type"] == "form" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MANUAL_INPUT + ) + assert result["type"] == "create_entry" + assert result["title"] == "Bedroom" + assert result["result"].unique_id == DSN + data = result["data"] + assert data["lanip_key_id"] == KEY_ID + assert data["template"] == "A" + trial.assert_called_once() + assert trial.call_args.args[0]["host"] == "192.168.1.50" + + +async def test_manual_connect_error(hass: HomeAssistant): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_NETWORK_FAIL, + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "manual") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MANUAL_INPUT + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_manual_key_mismatch_error(hass: HomeAssistant): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TrialResult(ok=False, reason="key_mismatch"), + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "manual") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MANUAL_INPUT + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "key_mismatch"} + + +async def test_manual_already_configured(hass: HomeAssistant): + existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN) + existing.add_to_hass(hass) + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "manual") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MANUAL_INPUT + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_import_json_success(hass: HomeAssistant): + payload = json.dumps( + { + "ip_address": "192.168.1.60", + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "model": "AP-WB2E", + "name": "Kitchen", + } + ) + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "import_json") + assert result["type"] == "form" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"config_json": payload} + ) + assert result["type"] == "create_entry" + assert result["data"]["host"] == "192.168.1.60" + assert result["data"]["template"] == "B" + + +async def test_import_json_invalid(hass: HomeAssistant): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "import_json") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"config_json": "not json"} + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_import"} + + +async def test_cloud_flow_success(hass: HomeAssistant): + device = Device( + name="Bedroom", + model="AP-WC1E", + dsn=DSN, + lan_ip="192.168.1.50", + lanip_key=LANIP_KEY, + lanip_key_id=KEY_ID, + region="eu", + ) + with patch( + "pyfglair.discover", AsyncMock(return_value=[device]) + ), patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "cloud") + assert result["type"] == "form" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "user@example.com", "password": "secret", "region": "eu"}, + ) + assert result["type"] == "form" + assert result["step_id"] == "cloud_device" + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"dsn": DSN} + ) + assert result["type"] == "create_entry" + assert result["data"]["host"] == "192.168.1.50" + assert result["data"]["lanip_key_id"] == KEY_ID + + +async def test_cloud_auth_error(hass: HomeAssistant): + with patch( + "pyfglair.discover", + AsyncMock(side_effect=pyfglair.ProvisionError("Ошибка входа (401)")), + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "cloud") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"email": "user@example.com", "password": "bad", "region": "eu"}, + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/fglair/test_init.py b/tests/components/fglair/test_init.py new file mode 100644 index 0000000..0ac3123 --- /dev/null +++ b/tests/components/fglair/test_init.py @@ -0,0 +1,41 @@ +"""Setup/unload ConfigEntry: клиент стартует, останавливается, ошибки порта.""" +from __future__ import annotations + +import socket + +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.fglair.const import CONF_LISTEN_PORT, DOMAIN +from pyfglair import Prop, State + + +async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = config_entry.runtime_data + assert client is not None + assert isinstance(client.state, State) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert client.cached(Prop.OPERATION_MODE) is None + + +async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled): + sock = socket.socket() + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + sock.bind(("127.0.0.1", entry_data[CONF_LISTEN_PORT])) + sock.listen(1) + try: + entry = MockConfigEntry( + domain=DOMAIN, data=entry_data, unique_id=entry_data["dsn"] + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state.name == "SETUP_RETRY" + finally: + sock.close() diff --git a/tests/components/fglair/test_trial.py b/tests/components/fglair/test_trial.py new file mode 100644 index 0000000..e689b03 --- /dev/null +++ b/tests/components/fglair/test_trial.py @@ -0,0 +1,76 @@ +"""Пробное подключение (trial_connect) против mock_ac.py — без HA-рантайма.""" +from __future__ import annotations + +import asyncio + +from helpers import free_port + +from custom_components.fglair.trial import trial_connect +from pyfglair import Prop, State + + +async def test_trial_online(mock_ac, ac_env, socket_enabled): + proc = mock_ac() + result = await asyncio.to_thread( + trial_connect, + "127.0.0.1", + ac_env["dsn"], + ac_env["key"], + ac_env["key_id"], + device_port=proc.port, + listen_port=free_port(), + timeout=10.0, + ) + assert result.ok, result.reason + assert result.state == State.ONLINE + assert result.properties[Prop.OPERATION_MODE].int_value == 6 + assert result.properties[Prop.FAN_SPEED].int_value == 4 + + +async def test_trial_key_mismatch(mock_ac, ac_env, socket_enabled): + proc = mock_ac() + result = await asyncio.to_thread( + trial_connect, + "127.0.0.1", + ac_env["dsn"], + ac_env["key"], + ac_env["key_id"] + 1, + device_port=proc.port, + listen_port=free_port(), + timeout=10.0, + ) + assert not result.ok + assert result.reason == "key_mismatch" + + +async def test_trial_unreachable(ac_env, socket_enabled): + dead_port = free_port() + result = await asyncio.to_thread( + trial_connect, + "127.0.0.1", + ac_env["dsn"], + ac_env["key"], + ac_env["key_id"], + device_port=dead_port, + listen_port=free_port(), + timeout=1.0, + ) + assert not result.ok + assert result.reason in ("unreachable", "timeout") + assert result.state != State.ONLINE + + +async def test_trial_no_slots(mock_ac, ac_env, socket_enabled): + proc = mock_ac(["--503"]) + result = await asyncio.to_thread( + trial_connect, + "127.0.0.1", + ac_env["dsn"], + ac_env["key"], + ac_env["key_id"], + device_port=proc.port, + listen_port=free_port(), + timeout=3.0, + ) + assert not result.ok + assert result.reason == "no_slots" diff --git a/tests/components/requirements.txt b/tests/components/requirements.txt new file mode 100644 index 0000000..1a1ca78 --- /dev/null +++ b/tests/components/requirements.txt @@ -0,0 +1,2 @@ +pytest-homeassistant-custom-component==0.13.367 +aiohttp>=3.9 diff --git a/tests/pyfglair/conftest.py b/tests/conftest.py similarity index 96% rename from tests/pyfglair/conftest.py rename to tests/conftest.py index c3569e3..daf4054 100644 --- a/tests/pyfglair/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Общие фикстуры pyfglair-тестов: сборка ядра, mock-облако, mock-модуль.""" +"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль.""" from __future__ import annotations import os @@ -12,7 +12,7 @@ import time import pytest from aiohttp import web -ROOT = pathlib.Path(__file__).resolve().parents[2] +ROOT = pathlib.Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT))