Files
petr.polezhaev da01c607a6 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
2026-09-29 12:20:37 +03:00

235 lines
8.1 KiB
Python

"""Облачный provisioning FGLair/Ayla: e-mail/пароль/регион → параметры устройства.
Облако используется только при настройке (lanip_key статичен, PROTOCOL §7).
Логика — перенос tools/fglair-discover / legacy discovery на aiohttp.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
import aiohttp
_LOGGER = logging.getLogger(__name__)
USER_AGENT = "pyfglair/1.0 (fglair-core)"
REGIONS = {
"eu": {
"user_server": "user-field-eu.aylanetworks.com",
"device_server": "ads-eu.aylanetworks.com",
"app_id": "FGLair-eu-id",
"app_secret": "FGLair-eu-gpFbVBRoiJ8E3QWJ-QRULLL3j3U",
},
"us": {
"user_server": "user-field.aylanetworks.com",
"device_server": "ads-field.aylanetworks.com",
"app_id": "CJIOSP-id",
"app_secret": "CJIOSP-Vb8MQL_lFiYQ7DKjN0eCFXznKZE",
},
"cn": {
"user_server": "user-field.ayla.com.cn",
"device_server": "ads-field.ayla.com.cn",
"app_id": "FGLairField-cn-id",
"app_secret": "FGLairField-cn-zezg7Y60YpAvy3HPwxvWLnd4Oh4",
},
}
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)
class Device:
name: str
model: str
dsn: str
lan_ip: str
lanip_key: str
lanip_key_id: int
region: str
mac: str = ""
@property
def temp_type(self) -> str:
return "C" if self.region == "eu" else "F"
def as_config(self) -> dict:
return {
"name": self.name,
"app": f"fglair-{self.region}",
"model": self.model,
"dsn": self.dsn,
"temp_type": self.temp_type,
"mac_address": self.mac,
"ip_address": self.lan_ip,
"lanip_key": self.lanip_key,
"lanip_key_id": self.lanip_key_id,
}
def esphome_secrets(devices: list[Device]) -> str:
lines = []
for dev in devices:
slug = "".join(c for c in dev.name.lower() if c.isalnum()) or "ac"
lines.append(f"# {dev.name} ({dev.model})")
lines.append(f'{slug}_dsn: "{dev.dsn}"')
lines.append(f'{slug}_lanip_key: "{dev.lanip_key}"')
lines.append(f"{slug}_lanip_key_id: {dev.lanip_key_id}")
lines.append("")
return "\n".join(lines)
async def _request_json(session, method, url, *, token=None, payload=None,
insecure=False):
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token:
headers["Authorization"] = f"auth_token {token}"
try:
async with session.request(
method,
url,
json=payload,
headers=headers,
ssl=False if insecure else None,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
try:
data = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
data = None
return resp.status, data
except aiohttp.ClientError as err:
raise ProvisionError(f"Сеть недоступна ({url}): {err}", "network") from err
except TimeoutError as err:
raise ProvisionError(f"Таймаут запроса ({url})", "network") from err
async def _sign_in(session, email, password, region, base_url, insecure):
payload = {
"user": {
"email": email,
"password": password,
"application": {
"app_id": region["app_id"],
"app_secret": region["app_secret"],
},
}
}
url = base_url or f"https://{region['user_server']}"
status, data = await _request_json(
session, "POST", f"{url}/users/sign_in.json",
payload=payload, insecure=insecure,
)
if status != 200 or not isinstance(data, dict):
detail = data.get("error") if isinstance(data, dict) else data
raise ProvisionError(f"Ошибка входа ({status}): {detail}", "auth")
token = data.get("access_token")
if not token:
raise ProvisionError("Вход выполнен без access_token", "auth")
return token
async def _get_devices(session, token, region, base_url, insecure):
url = base_url or f"https://{region['device_server']}"
status, data = await _request_json(
session, "GET", f"{url}/apiv1/devices.json",
token=token, insecure=insecure,
)
if status != 200 or not isinstance(data, list):
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("Облако вернуло неожиданный формат устройства", "format")
devices.append(dev)
return devices
async def _get_lanip(session, token, region, base_url, insecure, dsn):
url = base_url or f"https://{region['device_server']}"
status, data = await _request_json(
session, "GET", f"{url}/apiv1/dsns/{dsn}/lan.json",
token=token, insecure=insecure,
)
if status != 200 or not isinstance(data, dict):
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}",
"format",
)
return lanip or {}
async def discover(
email: str,
password: str,
region: str = "eu",
*,
base_url: Optional[str] = None,
session: Optional[aiohttp.ClientSession] = None,
insecure: bool = False,
) -> list[Device]:
"""Список устройств аккаунта с lanip_key (для config flow / CLI)."""
if region not in REGIONS:
raise ProvisionError(f"Неизвестный регион: {region}", "format")
region_cfg = REGIONS[region]
base = base_url.rstrip("/") if base_url else None
own_session = session is None
if own_session:
session = aiohttp.ClientSession()
try:
token = await _sign_in(
session, email, password, region_cfg, base, insecure
)
raw_devices = await _get_devices(
session, token, region_cfg, base, insecure
)
if not raw_devices:
raise ProvisionError("У аккаунта нет устройств", "no_devices")
out: list[Device] = []
for dev in raw_devices:
dsn = dev["dsn"]
lanip = await _get_lanip(
session, token, region_cfg, base, insecure, dsn
)
key = lanip.get("lanip_key")
key_id = lanip.get("lanip_key_id")
if not key or key_id is None:
_LOGGER.warning(
"Устройство %s: lan.json без ключа — пропуск", dsn
)
continue
out.append(
Device(
name=dev.get("product_name") or dsn,
model=dev.get("oem_model", ""),
dsn=dsn,
lan_ip=dev.get("lan_ip", ""),
lanip_key=key,
lanip_key_id=int(key_id),
region=region,
mac=(dev.get("mac") or "").replace(":", "").lower(),
)
)
if not out:
raise ProvisionError("Не найдено устройств с ключом LAN", "no_devices")
return out
finally:
if own_session:
await session.close()