ha(H2): исправления по ревью — несколько устройств, suggested values, YAML-import, типы ошибок

- 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
This commit is contained in:
2026-09-29 12:20:37 +03:00
parent 26d1d6d64f
commit da01c607a6
16 changed files with 275 additions and 100 deletions
+13 -9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant 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: 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 return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
clients: dict[str, FglairClient] = hass.data.setdefault(DATA_CLIENTS, {}) 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) client = FglairClient(hass, entry.data)
try:
await client.async_start() await client.async_start()
except Exception:
if not clients:
pyfglair.set_log_handler(None)
raise
clients[entry.entry_id] = client clients[entry.entry_id] = client
entry.runtime_data = client entry.runtime_data = client
if len(clients) == 1:
pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1)
if PLATFORMS: if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, 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: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
clients.pop(entry.entry_id, None) clients.pop(entry.entry_id, None)
if not clients: if not clients:
pyfglair.set_log_handler(None) await hass.async_add_executor_job(pyfglair.set_log_handler, None)
return True return True
+44 -19
View File
@@ -127,14 +127,23 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
lanip_key_id = obj.get("lanip_key_id") lanip_key_id = obj.get("lanip_key_id")
if not host or not dsn or not lanip_key or lanip_key_id is None: if not host or not dsn or not lanip_key or lanip_key_id is None:
return None return None
def to_int(value: Any) -> int | None:
try: try:
key_id = int(lanip_key_id) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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() model = str(obj.get("model") or "").strip()
return { return {
CONF_HOST: str(host).strip(), 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_DSN: str(dsn).strip(),
CONF_LANIP_KEY: str(lanip_key).strip(), CONF_LANIP_KEY: str(lanip_key).strip(),
CONF_LANIP_KEY_ID: key_id, CONF_LANIP_KEY_ID: key_id,
@@ -165,6 +174,27 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
self._devices: list[Device] = [] 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@@ -185,13 +215,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
) )
except pyfglair.ProvisionError as err: except pyfglair.ProvisionError as err:
_LOGGER.debug("discover не удался: %s", err) _LOGGER.debug("discover не удался: %s", err)
text = str(err) errors["base"] = {
if "вход" in text: "auth": "invalid_auth",
errors["base"] = "invalid_auth" "no_devices": "no_devices",
elif "нет устройств" in text or "Не найдено" in text: }.get(err.kind, "cannot_connect")
errors["base"] = "no_devices"
else:
errors["base"] = "cannot_connect"
except Exception: # pragma: no cover - защитный путь except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("discover: неожиданная ошибка") _LOGGER.exception("discover: неожиданная ошибка")
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
@@ -200,8 +227,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_devices" errors["base"] = "no_devices"
else: else:
return await self.async_step_cloud_device() return await self.async_step_cloud_device()
return self.async_show_form( return self._show_form(
step_id="cloud", data_schema=_cloud_schema(), errors=errors "cloud", _cloud_schema(), errors, user_input,
exclude=(CONF_PASSWORD,),
) )
async def async_step_cloud_device( async def async_step_cloud_device(
@@ -240,9 +268,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
) )
} }
) )
return self.async_show_form( return self._show_form("cloud_device", schema, errors, user_input)
step_id="cloud_device", data_schema=schema, errors=errors
)
async def async_step_manual( async def async_step_manual(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -254,8 +280,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
if error is None: if error is None:
return self._create_entry(data) return self._create_entry(data)
errors["base"] = error errors["base"] = error
return self.async_show_form( return self._show_form(
step_id="manual", data_schema=_manual_schema(), errors=errors "manual", _manual_schema(), errors, user_input,
exclude=(CONF_LANIP_KEY,),
) )
async def async_step_import_json( async def async_step_import_json(
@@ -271,9 +298,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
if error is None: if error is None:
return self._create_entry(data) return self._create_entry(data)
errors["base"] = error errors["base"] = error
return self.async_show_form( return self._show_form("import_json", _import_schema(), errors, user_input)
step_id="import_json", data_schema=_import_schema(), errors=errors
)
async def async_step_import( async def async_step_import(
self, import_data: dict[str, Any] self, import_data: dict[str, Any]
+5 -4
View File
@@ -1,7 +1,6 @@
"""Константы интеграции fglair.""" """Константы интеграции fglair."""
from __future__ import annotations from __future__ import annotations
from pyfglair import State
from pyfglair.provision import REGIONS as _PROVISION_REGIONS from pyfglair.provision import REGIONS as _PROVISION_REGIONS
DOMAIN = "fglair" DOMAIN = "fglair"
@@ -22,7 +21,10 @@ CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json" CONF_CONFIG_JSON = "config_json"
DEFAULT_DEVICE_PORT = 80 DEFAULT_DEVICE_PORT = 80
DEFAULT_LISTEN_PORT = 10275 # 0 => свободный эфемерный порт: несколько ConfigEntry (несколько
# кондиционеров) не конфликтуют; модулю фактический порт сообщается в
# local_reg (PROTOCOL §1).
DEFAULT_LISTEN_PORT = 0
DEFAULT_KEEPALIVE_MS = 15000 DEFAULT_KEEPALIVE_MS = 15000
TRIAL_TIMEOUT = 10.0 TRIAL_TIMEOUT = 10.0
@@ -34,6 +36,5 @@ TRIAL_ERRORS = {
"unreachable": "cannot_connect", "unreachable": "cannot_connect",
"timeout": "timeout", "timeout": "timeout",
"start_failed": "cannot_connect", "start_failed": "cannot_connect",
"properties_missing": "cannot_connect",
} }
STATE_FAILED = {State.OFFLINE, State.KEY_ERROR}
+9 -7
View File
@@ -147,18 +147,20 @@ class FglairClient:
async def async_start(self) -> None: async def async_start(self) -> None:
if self._session is not None: if self._session is not None:
return return
self._session = await self.hass.async_add_executor_job( session = await self.hass.async_add_executor_job(self._build_session)
self._build_session try:
) started = await self.hass.async_add_executor_job(session.start)
started = await self.hass.async_add_executor_job(self._session.start) except Exception:
await session.async_stop()
raise
if not started: if not started:
await self._session.async_stop() await session.async_stop()
self._session = None
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"не удалось запустить локальный HTTP-сервер FGLair " "не удалось запустить локальный HTTP-сервер FGLair "
f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})" 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: async def async_stop(self) -> None:
session, self._session = self._session, None session, self._session = self._session, None
+1
View File
@@ -1,6 +1,7 @@
{ {
"domain": "fglair", "domain": "fglair",
"name": "FGLair (Fujitsu General)", "name": "FGLair (Fujitsu General)",
"codeowners": [],
"config_flow": true, "config_flow": true,
"documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon", "documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon",
"integration_type": "device", "integration_type": "device",
+1 -3
View File
@@ -69,9 +69,7 @@
"cannot_connect": "Could not connect to the device", "cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy", "no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device", "key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device", "timeout": "Timed out waiting for the device"
"start_failed": "Could not start the local FGLair server",
"unknown": "Unexpected error"
} }
} }
} }
@@ -69,9 +69,7 @@
"cannot_connect": "Could not connect to the device", "cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy", "no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device", "key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device", "timeout": "Timed out waiting for the device"
"start_failed": "Could not start the local FGLair server",
"unknown": "Unexpected error"
} }
} }
} }
@@ -69,9 +69,7 @@
"cannot_connect": "Не удалось подключиться к устройству", "cannot_connect": "Не удалось подключиться к устройству",
"no_slots": "Оба LAN-слота заняты", "no_slots": "Оба LAN-слота заняты",
"key_mismatch": "LAN-ключ не совпадает с устройством", "key_mismatch": "LAN-ключ не совпадает с устройством",
"timeout": "Устройство не вышло на связь за отведённое время", "timeout": "Устройство не вышло на связь за отведённое время"
"start_failed": "Не удалось запустить локальный сервер FGLair",
"unknown": "Неожиданная ошибка"
} }
} }
} }
+15 -2
View File
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from typing import Iterable from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value from pyfglair import Config, Error, Prop, Session, State, Template, Value
from pyfglair.templates import template_info
BASE_PROPS: tuple[Prop, ...] = ( BASE_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE, Prop.OPERATION_MODE,
@@ -56,7 +57,8 @@ def trial_connect(
lock = threading.Lock() lock = threading.Lock()
box = {"state": State.IDLE, "error": Error.NONE} box = {"state": State.IDLE, "error": Error.NONE}
values: dict[Prop, Value] = {} 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: def on_state(state: State, error: Error) -> None:
with lock: with lock:
@@ -67,6 +69,7 @@ def trial_connect(
with lock: with lock:
values[event.prop] = event.value values[event.prop] = event.value
try:
session = Session( session = Session(
Config( Config(
host=host, host=host,
@@ -82,6 +85,8 @@ def trial_connect(
on_state=on_state, on_state=on_state,
on_property=on_property, on_property=on_property,
) )
except Exception: # pragma: no cover - защитный путь
return TrialResult(ok=False, reason="start_failed")
try: try:
try: try:
started = session.start() started = session.start()
@@ -110,11 +115,11 @@ def trial_connect(
state=state, error=error, state=state, error=error,
) )
if wanted:
session.batch_begin() session.batch_begin()
for prop in wanted: for prop in wanted:
session.get_prop(prop) session.get_prop(prop)
session.batch_commit() session.batch_commit()
prop_deadline = min(deadline, time.monotonic() + 2.0) prop_deadline = min(deadline, time.monotonic() + 2.0)
while time.monotonic() < prop_deadline: while time.monotonic() < prop_deadline:
with lock: with lock:
@@ -124,6 +129,14 @@ def trial_connect(
with lock: with lock:
snapshot = dict(values) 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) return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
finally: finally:
session.close() session.close()
+1 -1
View File
@@ -158,4 +158,4 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
| H4 | repair, диагностика (ключ для ESPHome), translations | | H4 | repair, диагностика (ключ для ESPHome), translations |
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` (PyPI или иной канал) — без неё `requirements` манифеста не разрешается в чистой HA |
+21 -12
View File
@@ -38,7 +38,15 @@ REGIONS = {
class ProvisionError(Exception): 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) @dataclass(frozen=True)
@@ -102,9 +110,9 @@ async def _request_json(session, method, url, *, token=None, payload=None,
data = None data = None
return resp.status, data return resp.status, data
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
raise ProvisionError(f"Сеть недоступна ({url}): {err}") from err raise ProvisionError(f"Сеть недоступна ({url}): {err}", "network") from err
except TimeoutError as 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): 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): if status != 200 or not isinstance(data, dict):
detail = data.get("error") if isinstance(data, dict) else data 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") token = data.get("access_token")
if not token: if not token:
raise ProvisionError("Вход выполнен без access_token") raise ProvisionError("Вход выполнен без access_token", "auth")
return token return token
@@ -139,12 +147,12 @@ async def _get_devices(session, token, region, base_url, insecure):
token=token, insecure=insecure, token=token, insecure=insecure,
) )
if status != 200 or not isinstance(data, list): if status != 200 or not isinstance(data, list):
raise ProvisionError(f"Ошибка списка устройств ({status}): {data}") raise ProvisionError(f"Ошибка списка устройств ({status}): {data}", "network")
devices = [] devices = []
for item in data: for item in data:
dev = item.get("device") if isinstance(item, dict) else None dev = item.get("device") if isinstance(item, dict) else None
if not isinstance(dev, dict) or not isinstance(dev.get("dsn"), str): if not isinstance(dev, dict) or not isinstance(dev.get("dsn"), str):
raise ProvisionError("Облако вернуло неожиданный формат устройства") raise ProvisionError("Облако вернуло неожиданный формат устройства", "format")
devices.append(dev) devices.append(dev)
return devices return devices
@@ -156,11 +164,12 @@ async def _get_lanip(session, token, region, base_url, insecure, dsn):
token=token, insecure=insecure, token=token, insecure=insecure,
) )
if status != 200 or not isinstance(data, dict): 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") lanip = data.get("lanip")
if lanip is not None and not isinstance(lanip, dict): if lanip is not None and not isinstance(lanip, dict):
raise ProvisionError( raise ProvisionError(
f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}" f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}",
"format",
) )
return lanip or {} return lanip or {}
@@ -176,7 +185,7 @@ async def discover(
) -> list[Device]: ) -> list[Device]:
"""Список устройств аккаунта с lanip_key (для config flow / CLI).""" """Список устройств аккаунта с lanip_key (для config flow / CLI)."""
if region not in REGIONS: if region not in REGIONS:
raise ProvisionError(f"Неизвестный регион: {region}") raise ProvisionError(f"Неизвестный регион: {region}", "format")
region_cfg = REGIONS[region] region_cfg = REGIONS[region]
base = base_url.rstrip("/") if base_url else None base = base_url.rstrip("/") if base_url else None
@@ -191,7 +200,7 @@ async def discover(
session, token, region_cfg, base, insecure session, token, region_cfg, base, insecure
) )
if not raw_devices: if not raw_devices:
raise ProvisionError("У аккаунта нет устройств") raise ProvisionError("У аккаунта нет устройств", "no_devices")
out: list[Device] = [] out: list[Device] = []
for dev in raw_devices: for dev in raw_devices:
dsn = dev["dsn"] dsn = dev["dsn"]
@@ -218,7 +227,7 @@ async def discover(
) )
) )
if not out: if not out:
raise ProvisionError("Не найдено устройств с ключом LAN") raise ProvisionError("Не найдено устройств с ключом LAN", "no_devices")
return out return out
finally: finally:
if own_session: if own_session:
+1
View File
@@ -1,4 +1,5 @@
[pytest] [pytest]
asyncio_mode = auto asyncio_mode = auto
testpaths = tests/pyfglair
filterwarnings = filterwarnings =
error::ResourceWarning error::ResourceWarning
+4 -1
View File
@@ -31,6 +31,9 @@ fi
if [ -x "$HA_VENV/bin/python" ]; then if [ -x "$HA_VENV/bin/python" ]; then
"$HA_VENV/bin/python" -m pytest tests/components -q "$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 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 fi
-4
View File
@@ -1,8 +1,6 @@
"""Фикстуры тестов HA-компонента fglair.""" """Фикстуры тестов HA-компонента fglair."""
from __future__ import annotations from __future__ import annotations
import os
import pytest import pytest
from helpers import DSN, KEY_ID, LANIP_KEY from helpers import DSN, KEY_ID, LANIP_KEY
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -13,7 +11,6 @@ from custom_components.fglair.const import (
CONF_HOST, CONF_HOST,
CONF_LANIP_KEY, CONF_LANIP_KEY,
CONF_LANIP_KEY_ID, CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_TEMPLATE, CONF_TEMPLATE,
DOMAIN, DOMAIN,
) )
@@ -33,7 +30,6 @@ def entry_data() -> dict:
CONF_LANIP_KEY: LANIP_KEY, CONF_LANIP_KEY: LANIP_KEY,
CONF_LANIP_KEY_ID: KEY_ID, CONF_LANIP_KEY_ID: KEY_ID,
CONF_TEMPLATE: "A", CONF_TEMPLATE: "A",
CONF_LISTEN_PORT: 20000 + (os.getpid() % 20000),
} }
+99 -1
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from helpers import DSN, KEY_ID, LANIP_KEY from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair 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): async def test_user_menu(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
assert result["type"] == "menu" assert result["type"] == "menu"
@@ -187,7 +197,9 @@ async def test_cloud_flow_success(hass: HomeAssistant):
async def test_cloud_auth_error(hass: HomeAssistant): async def test_cloud_auth_error(hass: HomeAssistant):
with patch( with patch(
"pyfglair.discover", "pyfglair.discover",
AsyncMock(side_effect=pyfglair.ProvisionError("Ошибка входа (401)")), AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud") 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["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"} 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
+31 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import socket import socket
from helpers import free_port
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry 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 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): async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled):
port = free_port()
sock = socket.socket() sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) sock.bind(("127.0.0.1", port))
sock.bind(("127.0.0.1", entry_data[CONF_LISTEN_PORT]))
sock.listen(1) sock.listen(1)
try: try:
entry = MockConfigEntry( 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) entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id) assert not await hass.config_entries.async_setup(entry.entry_id)