"""Облачный 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): """Ошибка входа/списка устройств/ключа (текст — для показа пользователю).""" @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}") from err except TimeoutError as err: raise ProvisionError(f"Таймаут запроса ({url})") 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}") token = data.get("access_token") if not token: raise ProvisionError("Вход выполнен без access_token") 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}") devices = [ item.get("device", {}) for item in data if isinstance(item, dict) ] if any(not dev.get("dsn") for dev in devices): raise ProvisionError("Облако вернуло устройство без dsn") 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}") return data.get("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}") 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("У аккаунта нет устройств") 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") return out finally: if own_session: await session.close()