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
+21 -12
View File
@@ -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: