ha(H2): компонент fglair — manifest, config flow (облако/ручной/импорт), пробная сессия

- custom_components/fglair: manifest (config_flow, local_push, requirements),
  const, trial (start -> ONLINE <=10 c -> GET базовых свойств), FglairClient
  (сессия pyfglair в executor, колбэки в loop, --runtime_data),
  __init__ (setup/unload, рефкаунт глобального лог-пампа)
- config flow: меню cloud/manual/import_json + import-source; discover через
  pyfglair.provision; пробное подключение с диагностикой (cannot_connect,
  no_slots, key_mismatch, timeout, invalid_auth, invalid_import); unique_id=dsn
- strings.json + translations/{en,ru}.json
- тесты HA (pytest-homeassistant-custom-component): меню/шаги/ошибки/импорт/
  облако (discover замокан), setup/unload, занятый порт; trial против
  mock_ac.py (online/key_mismatch/unreachable/no_slots)
- pytest.ini (общий), tests/conftest перенесён на уровень tests/,
  scripts/py-ci.sh: --setup-ha и прогон tests/components
This commit is contained in:
2026-09-29 12:03:44 +03:00
parent 397be6236d
commit 26d1d6d64f
21 changed files with 1400 additions and 20 deletions
+167
View File
@@ -0,0 +1,167 @@
"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль."""
from __future__ import annotations
import os
import pathlib
import socket
import subprocess
import sys
import threading
import time
import pytest
from aiohttp import web
ROOT = pathlib.Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if "FGL_AIRCON_LIB" not in os.environ:
from pyfglair._corebuild import build_core
os.environ["FGL_AIRCON_LIB"] = str(build_core())
MOCK_AC = ROOT / "tests" / "ayla" / "mock_ac.py"
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
KEY_ID = 64201
DSN = "AC000W00MOCK0001"
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
class Proc:
"""Subprocess с построчным чтением stdout и ожиданием префиксов."""
def __init__(self, argv):
self.lines: list[str] = []
self.lock = threading.Lock()
self.event = threading.Event()
self.proc = subprocess.Popen(
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self) -> None:
assert self.proc.stdout is not None
for line in self.proc.stdout:
with self.lock:
self.lines.append(line.rstrip("\n"))
self.event.set()
def all_lines(self) -> list[str]:
with self.lock:
return list(self.lines)
def text(self) -> str:
return "\n".join(self.all_lines())
def wait_line(self, needle: str, timeout: float) -> str | None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self.lock:
for line in self.lines:
if needle in line:
return line
self.event.wait(0.1)
return None
def stop(self) -> None:
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
if self.proc.stdout is not None:
self.proc.stdout.close()
@pytest.fixture(scope="session")
def ac_env() -> dict:
return {"dsn": DSN, "key": LANIP_KEY, "key_id": KEY_ID}
@pytest.fixture
async def cloud():
"""Мок облака Ayla: sign_in / devices / lan.json."""
state = {
"url": "",
"fail_login": False,
"no_key": False,
"bad_devices": False,
"bad_lanip": False,
}
async def sign_in(request):
if state["fail_login"]:
return web.json_response({"error": "invalid"}, status=401)
return web.json_response({"access_token": "tok-123"})
async def devices(request):
assert request.headers["Authorization"] == "auth_token tok-123"
if state["bad_devices"]:
return web.json_response([{"device": "not-an-object"}])
return web.json_response([
{
"device": {
"dsn": DSN,
"product_name": "Bedroom",
"oem_model": "AP-WC1E",
"lan_ip": "192.168.1.50",
"mac": "AA:BB:CC:DD:EE:FF",
}
}
])
async def lan(request):
if state["no_key"]:
return web.json_response({"lanip": {}})
if state["bad_lanip"]:
return web.json_response({"lanip": ["unexpected"]})
return web.json_response({
"lanip": {"lanip_key": "TW9ja0tleQ==", "lanip_key_id": KEY_ID}
})
app = web.Application()
app.router.add_post("/users/sign_in.json", sign_in)
app.router.add_get("/apiv1/devices.json", devices)
app.router.add_get("/apiv1/dsns/{dsn}/lan.json", lan)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
state["url"] = f"http://127.0.0.1:{port}"
yield state
await runner.cleanup()
@pytest.fixture
def mock_ac():
procs: list[Proc] = []
def start(extra: list[str] | None = None) -> Proc:
port = free_port()
proc = Proc([
sys.executable, str(MOCK_AC),
"--port", str(port),
"--lanip-key", LANIP_KEY,
"--key-id", str(KEY_ID),
*(extra or []),
])
procs.append(proc)
if proc.wait_line("READY", 15) is None:
proc.stop()
raise RuntimeError("mock_ac не стартовал: " + proc.text())
proc.port = port # type: ignore[attr-defined]
return proc
yield start
for proc in procs:
proc.stop()