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:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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": "Неожиданная ошибка"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,5 @@
|
|||||||
[pytest]
|
[pytest]
|
||||||
asyncio_mode = auto
|
asyncio_mode = auto
|
||||||
|
testpaths = tests/pyfglair
|
||||||
filterwarnings =
|
filterwarnings =
|
||||||
error::ResourceWarning
|
error::ResourceWarning
|
||||||
|
|||||||
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user