From da01c607a6cb2e4e032d7e06114412e2d4c8e2d0 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 12:20:37 +0300 Subject: [PATCH] =?UTF-8?q?ha(H2):=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=E2=80=94=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2,=20suggested=20values,=20YAML-import,=20?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - critical: listen_port по умолчанию 0 (эфемерный) — несколько ConfigEntry не конфликтуют; тест двух записей на дефолтном порту - manifest: добавлен обязательный ключ codeowners - unload: set_log_handler(None) вынесен в executor (не блокирует loop); рефкаунт лог-пампа без гонки (старт после успешного setup) - config flow: возврат введённого в форму при ошибке (add_suggested_values_to_schema; секреты исключены), YAML-import подключён через async_setup + SOURCE_IMPORT, ProvisionError.kind вместо разбора русского текста, валидация device_port/key_id из JSON - trial: требует получения всех базовых свойств шаблона (не пустой ok), безопасное создание Session - py-ci.sh: отсутствие .venv-ha — ошибка (PY_CI_SKIP_HA=1 для пропуска); pytest.ini testpaths --- custom_components/fglair/__init__.py | 24 +++-- custom_components/fglair/config_flow.py | 67 ++++++++---- custom_components/fglair/const.py | 9 +- custom_components/fglair/fglair_client.py | 16 +-- custom_components/fglair/manifest.json | 1 + custom_components/fglair/strings.json | 4 +- custom_components/fglair/translations/en.json | 4 +- custom_components/fglair/translations/ru.json | 4 +- custom_components/fglair/trial.py | 67 +++++++----- docs/PLAN_HOME_ASSISTANT.md | 2 +- pyfglair/provision.py | 33 +++--- pytest.ini | 1 + scripts/py-ci.sh | 5 +- tests/components/fglair/conftest.py | 4 - tests/components/fglair/test_config_flow.py | 100 +++++++++++++++++- tests/components/fglair/test_init.py | 34 +++++- 16 files changed, 275 insertions(+), 100 deletions(-) diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index e9fc81a..b1e1435 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,24 +31,27 @@ def _log_handler(level: int, message: str) -> None: async def async_setup(hass: HomeAssistant, config: dict) -> bool: + if isinstance(config.get(DOMAIN), dict): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) 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 + await client.async_start() clients[entry.entry_id] = client entry.runtime_data = client + if len(clients) == 1: + pyfglair.set_log_handler(_log_handler) + pyfglair.set_log_level(1) if PLATFORMS: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -61,5 +65,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) clients.pop(entry.entry_id, None) if not clients: - pyfglair.set_log_handler(None) + await hass.async_add_executor_job(pyfglair.set_log_handler, None) return True diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index 4e707d0..7f5006d 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -127,14 +127,23 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None: 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): + + def to_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + key_id = to_int(lanip_key_id) + if key_id is None or key_id < 0: return None + port = to_int(obj.get("device_port", DEFAULT_DEVICE_PORT)) + if port is None or not 1 <= port <= 65535: + port = DEFAULT_DEVICE_PORT 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_DEVICE_PORT: port, CONF_DSN: str(dsn).strip(), CONF_LANIP_KEY: str(lanip_key).strip(), CONF_LANIP_KEY_ID: key_id, @@ -165,6 +174,27 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: self._devices: list[Device] = [] + def _show_form( + self, + step_id: str, + schema: vol.Schema, + errors: dict[str, str], + user_input: dict[str, Any] | None = None, + *, + exclude: tuple[str, ...] = (), + ) -> ConfigFlowResult: + """Показывает форму, возвращая введённые значения (кроме секретов).""" + if user_input: + suggested = { + key: value + for key, value in user_input.items() + if key not in exclude + } + schema = self.add_suggested_values_to_schema(schema, suggested) + return self.async_show_form( + step_id=step_id, data_schema=schema, errors=errors + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -185,13 +215,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): ) 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" + errors["base"] = { + "auth": "invalid_auth", + "no_devices": "no_devices", + }.get(err.kind, "cannot_connect") except Exception: # pragma: no cover - защитный путь _LOGGER.exception("discover: неожиданная ошибка") errors["base"] = "cannot_connect" @@ -200,8 +227,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): 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 + return self._show_form( + "cloud", _cloud_schema(), errors, user_input, + exclude=(CONF_PASSWORD,), ) async def async_step_cloud_device( @@ -240,9 +268,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): ) } ) - return self.async_show_form( - step_id="cloud_device", data_schema=schema, errors=errors - ) + return self._show_form("cloud_device", schema, errors, user_input) async def async_step_manual( self, user_input: dict[str, Any] | None = None @@ -254,8 +280,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): 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 + return self._show_form( + "manual", _manual_schema(), errors, user_input, + exclude=(CONF_LANIP_KEY,), ) async def async_step_import_json( @@ -271,9 +298,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): 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 - ) + return self._show_form("import_json", _import_schema(), errors, user_input) async def async_step_import( self, import_data: dict[str, Any] diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py index 29ba04b..8c5221c 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -1,7 +1,6 @@ """Константы интеграции fglair.""" from __future__ import annotations -from pyfglair import State from pyfglair.provision import REGIONS as _PROVISION_REGIONS DOMAIN = "fglair" @@ -22,7 +21,10 @@ CONF_PASSWORD = "password" CONF_CONFIG_JSON = "config_json" DEFAULT_DEVICE_PORT = 80 -DEFAULT_LISTEN_PORT = 10275 +# 0 => свободный эфемерный порт: несколько ConfigEntry (несколько +# кондиционеров) не конфликтуют; модулю фактический порт сообщается в +# local_reg (PROTOCOL §1). +DEFAULT_LISTEN_PORT = 0 DEFAULT_KEEPALIVE_MS = 15000 TRIAL_TIMEOUT = 10.0 @@ -34,6 +36,5 @@ TRIAL_ERRORS = { "unreachable": "cannot_connect", "timeout": "timeout", "start_failed": "cannot_connect", + "properties_missing": "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 index 9668801..dc3a80c 100644 --- a/custom_components/fglair/fglair_client.py +++ b/custom_components/fglair/fglair_client.py @@ -147,18 +147,20 @@ class FglairClient: 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) + 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 self._session.async_stop() - self._session = None + await session.async_stop() raise ConfigEntryNotReady( "не удалось запустить локальный HTTP-сервер FGLair " f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})" ) - self._state = self._session.state + self._session = session + self._state = session.state async def async_stop(self) -> None: session, self._session = self._session, None diff --git a/custom_components/fglair/manifest.json b/custom_components/fglair/manifest.json index f2de7f6..1557c39 100644 --- a/custom_components/fglair/manifest.json +++ b/custom_components/fglair/manifest.json @@ -1,6 +1,7 @@ { "domain": "fglair", "name": "FGLair (Fujitsu General)", + "codeowners": [], "config_flow": true, "documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon", "integration_type": "device", diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index c7d4d90..84ff0a4 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -69,9 +69,7 @@ "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" + "timeout": "Timed out waiting for the device" } } } diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index c7d4d90..84ff0a4 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -69,9 +69,7 @@ "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" + "timeout": "Timed out waiting for the device" } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index c704197..9cab6ad 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -69,9 +69,7 @@ "cannot_connect": "Не удалось подключиться к устройству", "no_slots": "Оба LAN-слота заняты", "key_mismatch": "LAN-ключ не совпадает с устройством", - "timeout": "Устройство не вышло на связь за отведённое время", - "start_failed": "Не удалось запустить локальный сервер FGLair", - "unknown": "Неожиданная ошибка" + "timeout": "Устройство не вышло на связь за отведённое время" } } } diff --git a/custom_components/fglair/trial.py b/custom_components/fglair/trial.py index 75b1315..89d7f3d 100644 --- a/custom_components/fglair/trial.py +++ b/custom_components/fglair/trial.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from typing import Iterable from pyfglair import Config, Error, Prop, Session, State, Template, Value +from pyfglair.templates import template_info BASE_PROPS: tuple[Prop, ...] = ( Prop.OPERATION_MODE, @@ -56,7 +57,8 @@ def trial_connect( lock = threading.Lock() box = {"state": State.IDLE, "error": Error.NONE} values: dict[Prop, Value] = {} - wanted = tuple(props) + valid = {info.prop for info in template_info(template)} + wanted = tuple(prop for prop in props if prop in valid) def on_state(state: State, error: Error) -> None: with lock: @@ -67,21 +69,24 @@ def trial_connect( 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: + 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, + ) + except Exception: # pragma: no cover - защитный путь + return TrialResult(ok=False, reason="start_failed") try: try: started = session.start() @@ -110,20 +115,28 @@ def trial_connect( 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) + if wanted: + 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) + missing = [prop for prop in wanted if prop not in snapshot] + if missing: + return TrialResult( + ok=False, + reason="properties_missing", + state=State.ONLINE, + properties=snapshot, + ) 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 e5f581c..06673b3 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -158,4 +158,4 @@ Security → Long-lived access tokens) и REST API: вызов сервисов | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H4 | repair, диагностика (ключ для ESPHome), translations | -| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз | +| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` (PyPI или иной канал) — без неё `requirements` манифеста не разрешается в чистой HA | diff --git a/pyfglair/provision.py b/pyfglair/provision.py index 713a754..ec0a810 100644 --- a/pyfglair/provision.py +++ b/pyfglair/provision.py @@ -38,7 +38,15 @@ REGIONS = { class ProvisionError(Exception): - """Ошибка входа/списка устройств/ключа (текст — для показа пользователю).""" + """Ошибка provisioning (текст — для показа пользователю). + + ``kind``: "auth" | "no_devices" | "network" | "format" — для маппинга в + ошибки config flow без разбора текста. + """ + + def __init__(self, message: str, kind: str = "unknown") -> None: + super().__init__(message) + self.kind = kind @dataclass(frozen=True) @@ -102,9 +110,9 @@ async def _request_json(session, method, url, *, token=None, payload=None, data = None return resp.status, data except aiohttp.ClientError as err: - raise ProvisionError(f"Сеть недоступна ({url}): {err}") from err + raise ProvisionError(f"Сеть недоступна ({url}): {err}", "network") from err except TimeoutError as err: - raise ProvisionError(f"Таймаут запроса ({url})") from err + raise ProvisionError(f"Таймаут запроса ({url})", "network") from err async def _sign_in(session, email, password, region, base_url, insecure): @@ -125,10 +133,10 @@ async def _sign_in(session, email, password, region, base_url, insecure): ) if status != 200 or not isinstance(data, dict): detail = data.get("error") if isinstance(data, dict) else data - raise ProvisionError(f"Ошибка входа ({status}): {detail}") + raise ProvisionError(f"Ошибка входа ({status}): {detail}", "auth") token = data.get("access_token") if not token: - raise ProvisionError("Вход выполнен без access_token") + raise ProvisionError("Вход выполнен без access_token", "auth") return token @@ -139,12 +147,12 @@ async def _get_devices(session, token, region, base_url, insecure): token=token, insecure=insecure, ) if status != 200 or not isinstance(data, list): - raise ProvisionError(f"Ошибка списка устройств ({status}): {data}") + raise ProvisionError(f"Ошибка списка устройств ({status}): {data}", "network") devices = [] for item in data: dev = item.get("device") if isinstance(item, dict) else None if not isinstance(dev, dict) or not isinstance(dev.get("dsn"), str): - raise ProvisionError("Облако вернуло неожиданный формат устройства") + raise ProvisionError("Облако вернуло неожиданный формат устройства", "format") devices.append(dev) return devices @@ -156,11 +164,12 @@ async def _get_lanip(session, token, region, base_url, insecure, dsn): token=token, insecure=insecure, ) if status != 200 or not isinstance(data, dict): - raise ProvisionError(f"Ошибка lan.json для {dsn} ({status}): {data}") + raise ProvisionError(f"Ошибка lan.json для {dsn} ({status}): {data}", "network") lanip = data.get("lanip") if lanip is not None and not isinstance(lanip, dict): raise ProvisionError( - f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}" + f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}", + "format", ) return lanip or {} @@ -176,7 +185,7 @@ async def discover( ) -> list[Device]: """Список устройств аккаунта с lanip_key (для config flow / CLI).""" if region not in REGIONS: - raise ProvisionError(f"Неизвестный регион: {region}") + raise ProvisionError(f"Неизвестный регион: {region}", "format") region_cfg = REGIONS[region] base = base_url.rstrip("/") if base_url else None @@ -191,7 +200,7 @@ async def discover( session, token, region_cfg, base, insecure ) if not raw_devices: - raise ProvisionError("У аккаунта нет устройств") + raise ProvisionError("У аккаунта нет устройств", "no_devices") out: list[Device] = [] for dev in raw_devices: dsn = dev["dsn"] @@ -218,7 +227,7 @@ async def discover( ) ) if not out: - raise ProvisionError("Не найдено устройств с ключом LAN") + raise ProvisionError("Не найдено устройств с ключом LAN", "no_devices") return out finally: if own_session: diff --git a/pytest.ini b/pytest.ini index e32aadf..bbaab21 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] asyncio_mode = auto +testpaths = tests/pyfglair filterwarnings = error::ResourceWarning diff --git a/scripts/py-ci.sh b/scripts/py-ci.sh index 5d4c747..e064c5d 100644 --- a/scripts/py-ci.sh +++ b/scripts/py-ci.sh @@ -31,6 +31,9 @@ fi if [ -x "$HA_VENV/bin/python" ]; then "$HA_VENV/bin/python" -m pytest tests/components -q +elif [ "${PY_CI_SKIP_HA:-0}" = "1" ]; then + echo "py-ci: HA-тесты пропущены (PY_CI_SKIP_HA=1)" else - echo "py-ci: нет $HA_VENV — HA-тесты пропущены (scripts/py-ci.sh --setup-ha)" + echo "py-ci: нет $HA_VENV — запустите scripts/py-ci.sh --setup-ha" >&2 + exit 1 fi diff --git a/tests/components/fglair/conftest.py b/tests/components/fglair/conftest.py index bb3d243..aeeb278 100644 --- a/tests/components/fglair/conftest.py +++ b/tests/components/fglair/conftest.py @@ -1,8 +1,6 @@ """Фикстуры тестов 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 @@ -13,7 +11,6 @@ from custom_components.fglair.const import ( CONF_HOST, CONF_LANIP_KEY, CONF_LANIP_KEY_ID, - CONF_LISTEN_PORT, CONF_TEMPLATE, DOMAIN, ) @@ -33,7 +30,6 @@ def entry_data() -> dict: CONF_LANIP_KEY: LANIP_KEY, CONF_LANIP_KEY_ID: KEY_ID, CONF_TEMPLATE: "A", - CONF_LISTEN_PORT: 20000 + (os.getpid() % 20000), } diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index 7ca2aa8..36b6eb5 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -7,6 +7,7 @@ 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 homeassistant.setup import async_setup_component from pytest_homeassistant_custom_component.common import MockConfigEntry import pyfglair @@ -40,6 +41,15 @@ async def _select(hass: HomeAssistant, flow_id: str, option: str): ) +def _suggested(schema, field: str): + for key in schema.schema: + if getattr(key, "schema", None) == field: + description = key.description + if isinstance(description, dict): + return description.get("suggested_value") + return None + + async def test_user_menu(hass: HomeAssistant): result = await _menu(hass) assert result["type"] == "menu" @@ -187,7 +197,9 @@ async def test_cloud_flow_success(hass: HomeAssistant): async def test_cloud_auth_error(hass: HomeAssistant): with patch( "pyfglair.discover", - AsyncMock(side_effect=pyfglair.ProvisionError("Ошибка входа (401)")), + AsyncMock( + side_effect=pyfglair.ProvisionError("401 unauthorized", "auth") + ), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") @@ -197,3 +209,89 @@ async def test_cloud_auth_error(hass: HomeAssistant): ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_auth"} + + +async def test_manual_error_keeps_input(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["errors"] == {"base": "cannot_connect"} + assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"] + assert _suggested(result["data_schema"], "dsn") == DSN + assert _suggested(result["data_schema"], "lanip_key") is None + + +async def test_cloud_no_devices(hass: HomeAssistant): + with patch( + "pyfglair.discover", + AsyncMock( + side_effect=pyfglair.ProvisionError( + "У аккаунта нет устройств", "no_devices" + ) + ), + ): + 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": "secret", "region": "eu"}, + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "no_devices"} + + +async def test_yaml_import(hass: HomeAssistant): + payload = { + "ip_address": "192.168.1.70", + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "model": "AP-WF1E", + } + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload}) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data["template"] == "F" + assert entries[0].data["host"] == "192.168.1.70" + + +async def test_yaml_import_invalid(hass: HomeAssistant): + assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"}) + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN) == [] + assert not hass.config_entries.flow.async_progress() + + +async def test_import_json_null_device_port(hass: HomeAssistant): + payload = json.dumps( + { + "ip_address": "192.168.1.71", + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": str(KEY_ID), + "device_port": None, + } + ) + 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"], "import_json") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"config_json": payload} + ) + assert result["type"] == "create_entry" + assert result["data"]["device_port"] == 80 + assert trial.call_args.args[0]["device_port"] == 80 diff --git a/tests/components/fglair/test_init.py b/tests/components/fglair/test_init.py index 0ac3123..2845995 100644 --- a/tests/components/fglair/test_init.py +++ b/tests/components/fglair/test_init.py @@ -3,6 +3,7 @@ from __future__ import annotations import socket +from helpers import free_port from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -24,14 +25,41 @@ async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled): assert client.cached(Prop.OPERATION_MODE) is None +async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled): + """Две записи без явного listen_port не конфликтуют (порт 0 → эфемерный).""" + first = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="DSN-1") + second = MockConfigEntry( + domain=DOMAIN, + data={**entry_data, "dsn": "DSN-2"}, + unique_id="DSN-2", + ) + first.add_to_hass(hass) + assert await hass.config_entries.async_setup(first.entry_id) + await hass.async_block_till_done() + + second.add_to_hass(hass) + assert await hass.config_entries.async_setup(second.entry_id) + await hass.async_block_till_done() + assert first.state.name == "LOADED" + assert second.state.name == "LOADED" + + first_client = first.runtime_data + assert await hass.config_entries.async_unload(first.entry_id) + assert first_client.cached(Prop.OPERATION_MODE) is None + assert second.state.name == "LOADED" + assert await hass.config_entries.async_unload(second.entry_id) + + async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled): + port = free_port() sock = socket.socket() - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) - sock.bind(("127.0.0.1", entry_data[CONF_LISTEN_PORT])) + sock.bind(("127.0.0.1", port)) sock.listen(1) try: entry = MockConfigEntry( - domain=DOMAIN, data=entry_data, unique_id=entry_data["dsn"] + domain=DOMAIN, + data={**entry_data, CONF_LISTEN_PORT: port}, + unique_id=entry_data["dsn"], ) entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(entry.entry_id)