Files
petr.polezhaev 57ee555704 ha(H4): repair key_mismatch, диагностика для ESPHome, reconfigure, переводы
- repairs: issue key_mismatch (ERROR, fixable) при состоянии key_error,
  ConfirmRepairFlow, снятие issue при online/unload/remove
- config flow: async_step_reconfigure (меню, prefill ручной формы,
  unique_id_mismatch, update_reload_and_abort) — путь «перепровижинировать»
- diagnostics: config/esp_home (dsn/lanip_key/key_id/host для secrets.yaml)
  + esp_home_masked и redacted entry_data, runtime-снимок значений
  (raw/display) из кэша ядра
- translations: issues.key_mismatch (title/description/fix_flow) en/ru,
  abort unique_id_mismatch/reconfigure_successful; тест согласованности
  strings↔en↔ru
- FglairClient.events_dropped; mock_ac: параметр key_id в фикстуре
- тесты: diagnostics, repairs issue+fix-flow, reconfigure (успех/чужое
  устройство), translations (45 components)
2026-09-29 14:06:39 +03:00

170 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Общие фикстуры тестов: сборка ядра, 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, key_id: int | 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 if key_id is None else 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()