ha(H1): pyfglair — cffi-ядро (очередь событий), wheel, CLI discover/monitor
- CMake: shared-таргет fgl-aircon-shared (FGL_BUILD_SHARED, PIC, SOVERSION) - C-API: fgl_prop_raw_range + кольцевые очереди событий/логов (fgl_session_poll/wait_events, fgl_log_poll/wait_events): прямые колбэки из потоков ядра со стеком 8 КБ небезопасны для интерпретатора Python - pyfglair: _corebuild (cmake->wheel), _cffi (ABI), Session (reader-поток -> loop.call_soon_threadsafe), templates (интроспекция C-таблиц), provision (aiohttp), CLI discover/monitor + esphome-secrets - setup.py/pyproject: платформенный wheel py3-none-linux_x86_64 с .so - tests/pyfglair: templates, provision (мок-облако), session + CLI против mock_ac.py; scripts/py-ci.sh; ci.sh запускает python-часть
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""Облачный 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()
|
||||
Reference in New Issue
Block a user