#!/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())