Files
fgl-aircon/tests/acceptance/test_esphome_ha.py
T
petr.polezhaev 8013781e09 ha(H5): README+HACS, скрипт приёмки, релизный чек-лист
- custom_components/fglair/README.md: HACS-установка, cloud/ручной/импорт,
  превью шаблона, сущности, ключ для ESPHome (reconfigure/CLI), диагностика
  и troubleshoot; заглушки screenshots/step-1..8.png (сгенерированы)
- tests/acceptance/test_esphome_ha.py: quick/burst/long через REST API HA
  (long-lived token), адаптивная матрица по capabilities, возврат исходного
  состояния, CSV-отчёт long, опциональный ESPHome-канал (aioesphomeapi)
- self-test приёмки на моке HA REST (6 тестов, без железа)
- hacs.json; README: раздел Home Assistant; docs/RELEASE_HA.md: публикация
  pyfglair на PyPI (wheel/sdist, auditwheel), HACS-релиз, приёмка
- scripts/py-ci.sh: tests/acceptance; план H5 отмечен
2026-09-29 14:42:41 +03:00

367 lines
13 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""Приёмка HA (и опционально ESPHome) на живом кондиционере.
Полуавтоматический скрипт: третью LAN-сессию НЕ открывает (оба слота заняты
HA + ESPHome); работает через REST API Home Assistant по long-lived token.
Профиль → Security → Long-lived access tokens → создать токен.
Запуск:
python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac
python tests/acceptance/test_esphome_ha.py --long \
--ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv
Режимы:
quick — матрица изменений (hvac/fan/уставка/swing), последовательно и
«burst» (без пауз), каждое с возвратом к исходному состоянию;
--long — одно изменение в --interval секунд (по умолчанию 3600) на
протяжении --hours часов, проверка и возврат, CSV-отчёт.
ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity
и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome,
результат сверяется по состоянию в HA.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import json
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Callable, Optional
DEFAULT_TIMEOUT = 15.0
SETTLE_TIMEOUT = 30.0
class AcceptanceError(RuntimeError):
pass
class HaRest:
"""Минимальный клиент REST API Home Assistant (stdlib)."""
def __init__(self, base_url: str, token: str, timeout: float = DEFAULT_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, payload: Any = None) -> Any:
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
self.base_url + path, data=data, headers=self.headers, method=method
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
body = resp.read().decode() or "null"
return resp.status, json.loads(body)
except urllib.error.HTTPError as err:
try:
body = err.read().decode(errors="replace")
finally:
err.close()
raise AcceptanceError(
f"{method} {path}: HTTP {err.code}: {body[:200]}"
) from err
except OSError as err:
raise AcceptanceError(f"{method} {path}: {err}") from err
def state(self, entity_id: str) -> dict:
_, data = self._request("GET", f"/api/states/{entity_id}")
if not isinstance(data, dict):
raise AcceptanceError(f"{entity_id}: неожиданный ответ /api/states")
return data
def call(self, domain: str, service: str, data: dict) -> None:
self._request("POST", f"/api/services/{domain}/{service}", data)
def wait_state(
self,
entity_id: str,
predicate: Callable[[dict], bool],
timeout: float = SETTLE_TIMEOUT,
) -> dict:
deadline = time.monotonic() + timeout
last: Optional[dict] = None
while time.monotonic() < deadline:
last = self.state(entity_id)
if predicate(last):
return last
time.sleep(0.5)
raise AcceptanceError(
f"{entity_id}: состояние не достигнуто за {timeout} с: "
f"{last and last.get('state')}"
)
@dataclass
class Step:
name: str
domain: str
service: str
service_data: dict
attribute: str
expected: Any
def predicate(self) -> Callable[[dict], bool]:
def check(state: dict) -> bool:
if self.attribute == "state":
return state.get("state") == self.expected
return state.get("attributes", {}).get(self.attribute) == self.expected
return check
def _pick(options: list, preferred: str) -> Optional[str]:
if not options:
return None
if preferred in options:
return preferred
return options[0]
def build_steps(state: dict) -> list[Step]:
"""Адаптивная матрица: только поддерживаемые режимы/диапазоны."""
attrs = state.get("attributes", {})
steps: list[Step] = []
hvac_modes = [m for m in attrs.get("hvac_modes", []) if m != "off"]
mode = _pick(hvac_modes, "cool")
if mode:
steps.append(
Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": mode}, "state", mode)
)
fan_mode = _pick(attrs.get("fan_modes", []), "low")
if fan_mode:
steps.append(
Step("fan_mode", "climate", "set_fan_mode",
{"fan_mode": fan_mode}, "fan_mode", fan_mode)
)
swing = _pick(attrs.get("swing_modes", []), "on")
if swing:
steps.append(
Step("swing_mode", "climate", "set_swing_mode",
{"swing_mode": swing}, "swing_mode", swing)
)
temperature = attrs.get("temperature")
low = attrs.get("min_temp", 16.0)
high = attrs.get("max_temp", 30.0)
if temperature is not None and low is not None and high is not None:
target = temperature + 1.0
if target > high:
target = max(low, high - 1.0)
steps.append(
Step("temperature", "climate", "set_temperature",
{"temperature": target}, "temperature", target)
)
return steps
def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None:
attrs = original.get("attributes", {})
state = original.get("state")
if state == "off" and "off" in attrs.get("hvac_modes", []):
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": "off"})
return
if state and state != "off":
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": state})
if attrs.get("fan_mode") is not None:
ha.call("climate", "set_fan_mode",
{"entity_id": entity_id, "fan_mode": attrs["fan_mode"]})
if attrs.get("temperature") is not None:
ha.call("climate", "set_temperature",
{"entity_id": entity_id, "temperature": attrs["temperature"]})
if attrs.get("swing_mode") is not None:
ha.call("climate", "set_swing_mode",
{"entity_id": entity_id, "swing_mode": attrs["swing_mode"]})
def _observed(state: Optional[dict], step: Step) -> Any:
if state is None:
return None
if step.attribute == "state":
return state.get("state")
return state.get("attributes", {}).get(step.attribute)
def _matches(observed: Any, expected: Any) -> bool:
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)):
return abs(float(observed) - float(expected)) < 0.11
return observed == expected
def run_quick(ha: HaRest, entity_id: str, *, burst: bool = False) -> list[dict]:
original = ha.state(entity_id)
steps = build_steps(original)
if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
results = []
if burst:
for step in steps:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
for step in steps:
state = ha.wait_state(entity_id, step.predicate())
results.append({
"step": step.name,
"expected": step.expected,
"observed": _observed(state, step),
})
else:
for step in steps:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
state = ha.wait_state(entity_id, step.predicate())
results.append({
"step": step.name,
"expected": step.expected,
"observed": _observed(state, step),
})
_restore(ha, entity_id, original, entity_id)
time.sleep(1.0)
restored = ha.state(entity_id)
results.append(
{
"step": "restore",
"expected": original.get("state"),
"observed": restored.get("state"),
}
)
return results
def run_long(
ha: HaRest,
entity_id: str,
*,
hours: float,
interval: float,
report_path: Optional[str],
) -> tuple[int, int]:
original = ha.state(entity_id)
steps = build_steps(original)
if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
iterations = max(1, int(hours * 3600 / interval))
ok = failed = 0
rows = []
for index in range(iterations):
step = steps[index % len(steps)]
timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
try:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
state = ha.wait_state(entity_id, step.predicate())
observed = (
state.get("state")
if step.attribute == "state"
else state.get("attributes", {}).get(step.attribute)
)
passed = observed == step.expected
_restore(ha, entity_id, original, entity_id)
time.sleep(1.0)
except AcceptanceError as err:
printed = str(err)
passed = False
observed = printed
ok += 1 if passed else 0
failed += 0 if passed else 1
rows.append([timestamp, step.name, step.expected, observed,
"ok" if passed else "fail"])
if index + 1 < iterations:
time.sleep(interval)
if report_path:
with open(report_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["timestamp", "step", "expected", "observed",
"result"])
writer.writerows(rows)
return ok, failed
def _esphome_client(args: argparse.Namespace):
if not args.esphome_host:
return None
try:
import aioesphomeapi # noqa: PLC0415
except ImportError as err:
raise AcceptanceError(
"--esphome-host задан, но aioesphomeapi не установлен"
) from err
return aioesphomeapi, args
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("mode", nargs="?", choices=["quick", "long"],
default="quick")
parser.add_argument("--long", dest="long_mode", action="store_true",
help="алиас режима long")
parser.add_argument("--ha-url", required=True)
parser.add_argument("--ha-token", required=True)
parser.add_argument("--entity", required=True,
help="entity_id climate-сущности FGLair")
parser.add_argument("--burst", action="store_true",
help="quick: без пауз между изменениями")
parser.add_argument("--hours", type=float, default=24.0)
parser.add_argument("--interval", type=float, default=3600.0)
parser.add_argument("--report", help="CSV-отчёт режима long")
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--esphome-host")
parser.add_argument("--esphome-key", default="")
parser.add_argument("--esphome-entity")
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
if args.long_mode:
args.mode = "long"
try:
_esphome_client(args)
ha = HaRest(args.ha_url, args.ha_token, args.timeout)
if args.mode == "quick":
results = run_quick(ha, args.entity, burst=args.burst)
failed = [
row for row in results
if not _matches(row.get("observed"), row.get("expected"))
]
for row in results:
print(f"{row['step']}: expected={row['expected']} "
f"observed={row.get('observed', '-')}")
if failed:
print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr)
return 1
return 0
ok, failed = run_long(
ha, args.entity,
hours=args.hours, interval=args.interval,
report_path=args.report,
)
print(f"long: ok={ok} fail={failed}"
+ (f", отчёт: {args.report}" if args.report else ""))
return 1 if failed else 0
except AcceptanceError as err:
print(f"приёмка: {err}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())