core(M4): fglctl, fglair-discover, README библиотеки; M5 — вне скоупа core-релиза

- README.md (корень): сборка POSIX/ESP-IDF-компонентом, тесты, получение
  ключа (fglair-discover), примеры fglctl; planned-компоненты помечены.
- examples/cli/fglctl.cpp: monitor/get/set/status на публичном API
  (batch GET шаблона, конверсии, wait_online, логи в stderr, exit-коды;
  сборка с -Wall -Wextra -Werror).
- tools/fglair-discover: облачный provisioning (eu/us/cn, endpoints и
  app_id/secret из PROTOCOL §7), --format json|esphome-secrets, --out 0600,
  обработка URLError/KeyError; stdlib-only.
- tests/tools/test_fglair_discover.py: мок-облако (http.server), 4 теста,
  ctest tools_fglair_discover.
- docs/reports/M4_SOAK.md: 60-мин soak на AP-WC1E — 0 рассинхронов,
  33/33 свойства (вкл. строковые), 273 опроса модуля, pushes_bad=0;
  24-ч прогон — приёмочный шаг перед релизом.
- План: M4 ✅; M5 (FglHub/mDNS) — ВНЕ СКОУПА core-релиза.
- Гигиена: config_kata.json (корень+docs/legacy) в .gitignore; MQTT-креды
  из docs/legacy/run.sh заменены плейсхолдером (история чистится отдельно).
- Фиксы ревью: mock арм fail-pushes после активации (флейк san-сборки);
  fglctl: валидация strtoll (abc/99 — отказ, exit!=0), чтение config с
  предупреждением об обрезке, Config{} вместо memset; discover: 0600.
CI: 11/11 ×3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
This commit is contained in:
2026-09-29 01:16:58 +03:00
parent fa1718f405
commit 31cd56f082
12 changed files with 780 additions and 4 deletions
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""fglair-discover — облачный provisioning устройств FGLair (Ayla).
Получает lanip_key/lanip_key_id и параметры устройств из облака Ayla
(docs/PROTOCOL.md §7) и печатает конфиг для fglair-core. Ключ статичен
(зашит в модуль), облако используется только здесь.
Использование:
fglair-discover --region eu --email user@example.com [--password ...]
[--device "Living Room"] [--format json|esphome-secrets] [--out FILE]
Пароль: --password или env FGLAIR_PASSWORD (иначе getpass).
--api-base URL — внутренний флаг для тестов (мок облака).
Форматы:
json — конфиг в формате config_*.json (совместим с legacy и
fglctl)
esphome-secrets — блок для secrets.yaml (dsn/lanip_key/lanip_key_id)
"""
import argparse
import getpass
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
# Секреты приложений FGLair (PROTOCOL.md §7, из APK FGLair 3.4.3).
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",
},
}
USER_AGENT = "fglair-discover/0.1 (fglair-core)"
def _http_json(method, url, payload=None, headers=None, insecure=False):
"""Запрос с JSON-ответом; возвращает (status, parsed)."""
data = json.dumps(payload).encode() if payload is not None else None
hdrs = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if data is not None:
hdrs["Content-Type"] = "application/json"
if headers:
hdrs.update(headers)
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
ctx = None
if insecure:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
return resp.status, json.loads(resp.read().decode() or "null")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode() or "null")
except Exception:
body = None
return e.code, body
except urllib.error.URLError as e:
sys.exit(f"Сеть недоступна ({url}): {e.reason}")
def sign_in(args, region, build):
payload = {
"user": {
"email": args.email,
"password": args.password,
"application": {
"app_id": region["app_id"],
"app_secret": region["app_secret"],
},
}
}
status, data = _http_json(
"POST", build(region["user_server"], "/users/sign_in.json"),
payload, insecure=args.insecure)
if status != 200 or not isinstance(data, dict):
detail = data.get("error") if isinstance(data, dict) else data
sys.exit(f"Ошибка входа ({status}): {detail}")
token = data.get("access_token")
if not token:
sys.exit(f"Вход без access_token (статус {status})")
return token
def get_devices(args, region, token, build):
status, data = _http_json(
"GET", build(region["device_server"], "/apiv1/devices.json"),
headers={"Authorization": f"auth_token {token}"},
insecure=args.insecure)
if status != 200 or not isinstance(data, list):
sys.exit(f"Ошибка списка устройств ({status}): {data}")
devices = [d.get("device", {}) for d in data if isinstance(d, dict)]
if not all(d.get("dsn") for d in devices):
sys.exit("Облако вернуло устройство без dsn — неожиданный формат")
return devices
def get_lanip(args, region, token, dsn, build):
status, data = _http_json(
"GET",
build(region["device_server"], f"/apiv1/dsns/{dsn}/lan.json"),
headers={"Authorization": f"auth_token {token}"},
insecure=args.insecure)
if status != 200 or not isinstance(data, dict):
sys.exit(f"Ошибка lan.json для {dsn} ({status}): {data}")
return data.get("lanip") or {}
def main():
ap = argparse.ArgumentParser(
description="Облачный discovery устройств FGLair (fglair-core)")
ap.add_argument("--region", choices=sorted(REGIONS), default="eu")
ap.add_argument("--email", default=os.environ.get("FGLAIR_EMAIL"))
ap.add_argument("--password", default=os.environ.get("FGLAIR_PASSWORD"))
ap.add_argument("--device", help="Фильтр по имени устройства (product_name)")
ap.add_argument("--format", choices=["json", "esphome-secrets"],
default="json")
ap.add_argument("--out", help="Записать в файл (иначе stdout)")
ap.add_argument("--insecure", action="store_true",
help="Не проверять TLS-сертификаты (самоподписанные прокси)")
ap.add_argument("--api-base",
help="ВНУТРЕННЕЕ: базовый URL мок-облака (тесты)")
args = ap.parse_args()
if not args.email:
sys.exit("Укажите --email или FGLAIR_EMAIL")
if not args.password:
args.password = getpass.getpass("Пароль FGLair: ")
region = dict(REGIONS[args.region])
if args.api_base: # тестовый мок: оба сервера на одном хосте, plain http
host = args.api_base.removeprefix("http://").removeprefix("https://")
region["user_server"] = host
region["device_server"] = host
scheme = "http"
else:
scheme = "https"
def build(server, path):
return f"{scheme}://{server}{path}"
token = sign_in(args, region, build)
devices = get_devices(args, region, token, build)
if not devices:
sys.exit("У аккаунта нет устройств")
outputs = []
for dev in devices:
if args.device and dev.get("product_name") != args.device:
continue
dsn = dev["dsn"]
lanip = get_lanip(args, region, token, dsn, build)
lanip_key = lanip.get("lanip_key")
lanip_key_id = lanip.get("lanip_key_id")
if not lanip_key or lanip_key_id is None:
print(f"ВНИМАНИЕ: {dev.get('product_name')}: lan.json без ключа, "
"пропуск", file=sys.stderr)
continue
outputs.append({
"name": dev.get("product_name", dsn),
"app": f"fglair-{args.region}",
"model": dev.get("oem_model", ""),
"dsn": dsn,
"temp_type": "C" if args.region == "eu" else "F",
"mac_address": (dev.get("mac") or "").replace(":", "").lower(),
"ip_address": dev.get("lan_ip", ""),
"lanip_key": lanip_key,
"lanip_key_id": lanip_key_id,
})
if not outputs:
sys.exit("Подходящих устройств не найдено")
if args.format == "esphome-secrets":
lines = []
for d in outputs:
slug = "".join(c for c in d["name"].lower() if c.isalnum()) or "ac"
lines.append(f"# {d['name']} ({d['model']})")
lines.append(f"{slug}_dsn: \"{d['dsn']}\"")
lines.append(f"{slug}_lanip_key: \"{d['lanip_key']}\"")
lines.append(f"{slug}_lanip_key_id: {d['lanip_key_id']}")
lines.append("")
text = "\n".join(lines)
else:
# По одной компактной строке на устройство (парсится построчно;
# каждая строка — самостоятельный конфиг config_*.json).
text = "\n".join(json.dumps(d, ensure_ascii=False)
for d in outputs) + "\n"
if args.out:
# Ключ устройства — секрет: файл только для владельца.
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(text)
print(f"Записано: {args.out} (права 0600)", file=sys.stderr)
else:
sys.stdout.write(text)
if __name__ == "__main__":
main()