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
+46 -21
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")
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]