diff --git a/README.md b/README.md index dabccea..3d602f1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop src/aircon/ property templates A/B/F, conversions, API implementation pyfglair/ Python bindings (cffi) for Home Assistant (docs/PLAN_HOME_ASSISTANT.md) components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md) -custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md) +custom_components/ Home Assistant custom component (docs/PLAN_HOME_ASSISTANT.md) tests/ ayla (protocol), aircon (conversions), pyfglair, tools examples/cli/ fglctl — sample CLI tools/ fglair-discover, protocol probes @@ -93,6 +93,14 @@ python -m pyfglair monitor --config config_home.json --duration 60 Тесты (без железа: mock-модуль + мок-облако): `scripts/py-ci.sh --setup && scripts/py-ci.sh` (или `pytest tests/pyfglair`). +## Home Assistant + +Компонент `custom_components/fglair/` (HACS custom repository) — climate, +switch/select/sensor/binary_sensor, config flow с превью шаблона, repair +для несовпадения ключа и диагностика. Инструкция: +[`custom_components/fglair/README.md`](custom_components/fglair/README.md); +приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`. + ## Getting the key (`lanip_key`) The key is static (baked into the AC module; only served by the Ayla cloud): diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md new file mode 100644 index 0000000..5796e05 --- /dev/null +++ b/custom_components/fglair/README.md @@ -0,0 +1,134 @@ +# FGLair — интеграция Home Assistant + +Локальное управление кондиционерами Fujitsu General (FGLair / Ayla LAN) +без облака в рантайме. Вся протокольная логика — в C++-ядре +[`fgl-aircon`](../../README.md) через python-пакет `pyfglair` (cffi). + +> Скриншоты `screenshots/step-N.png` — заглушки-плейсхолдеры: владелец +> заменит их реальными снимками, описания «что должно быть видно» даны +> рядом с каждым шагом. + +## Требования + +* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64). +* Пакет `pyfglair` (манифест ставит его автоматически из PyPI). + До публикации на PyPI установите wheel вручную в python-окружение HA: + `pip install pyfglair-*.whl` (см. `docs/RELEASE_HA.md`). +* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**: + телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. + +## Установка через HACS + +1. HACS → ⋮ → **Custom repositories** → URL репозитория, категория + **Integration** → **Add**. + + ![шаг 1](screenshots/step-1.png) + + *Должно быть видно: диалог добавления custom repository с заполненным URL + и выбранной категорией Integration.* + +2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant. + + ![шаг 2](screenshots/step-2.png) + + *Должно быть видно: страница загрузки интеграции с кнопкой Download и + версией.* + +## Добавление устройства + +3. Settings → Devices & Services → **Add Integration** → «FGLair». + + ![шаг 3](screenshots/step-3.png) + + *Должно быть видно: диалог поиска интеграции с введённым «FGLair» и + выделенным результатом.* + +4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако + используется один раз — получить статический LAN-ключ модуля. + + ![шаг 4](screenshots/step-4.png) + + *Должно быть видно: форма с заполненными регионом EU и e-mail, пароль + скрыт.* + +5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP). + + ![шаг 5](screenshots/step-5.png) + + *Должно быть видно: список с одним устройством (имя, модель, host).* + +6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных + значений (режимы, диапазон температур, пример конверсии) и, при + необходимости, ручные коэффициенты/диапазон уставки. Сверьте с + приложением FGLair и подтвердите. + + ![шаг 6](screenshots/step-6.png) + + *Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг, + текущая температура, capabilities, кнопки Submit.* + +7. **Готово**: карточка устройства с сущностями climate/switch/select/ + sensor/binary_sensor. + + ![шаг 7](screenshots/step-7.png) + + *Должно быть видно: страница устройства с созданными сущностями.* + +## Где взять ключ для ESPHome + +Download diagnostics на странице устройства показывает `dsn`, +`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для +`secrets.yaml` ESPHome) получите явным действием: + +* **Настроить заново** (Reconfigure) на карточке интеграции — вход в + облако FGLair покажет/сохранит актуальный ключ; либо +* `python -m pyfglair discover --region eu --email you@example.com + --format esphome-secrets` на хосте HA. + + ![шаг 8](screenshots/step-8.png) + + *Должно быть видно: страница Diagnostics с полями dsn/lanip_key_id/host + и маской lanip_key.* + +Альтернатива без облака — ручной ввод параметров (host, dsn, lanip_key, +lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl`. + +## Сущности + +| Платформа | Что создаётся | +|---|---| +| climate | режим (off/cool/dry/fan/heat/auto), скорость, swing (верт./гориз.), preset ECO/BOOST, уставка, текущая температура | +| switch | economy, powerful, coil dry, min heat, outdoor low noise, human det auto save, Wi-Fi LED, indoor fan control (по шаблону и capabilities) | +| select | положение вертикальной/горизонтальной заслонки (0…N−1) | +| sensor | температура в помещении, код ошибки, диагностическое состояние связи | +| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) | + +Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`; +режимы, которыми прибор не управляет, не создаются. + +## Диагностика и известные ситуации + +* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome). + Освободите один слот и перезапустите интеграцию. +* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится + Repair; нажмите **Настроить заново** и повторите вход в облако (или + вставьте новый `config_*.json`). +* **unavailable** — модуль недоступен (сеть/питание) или нет слотов; + диагностический сенсор «Состояние связи» показывает детали + (idle/registering/online/recovering/offline/key_error). +* Записи не эхо-подтверждаются модулем: состояние обновляется + оптимистично и подтверждается push-обновлением. + +## Приёмка на живом стенде + +`tests/acceptance/test_esphome_ha.py` (stdlib) прогоняет матрицу изменений +через REST API HA по long-lived token, с возвратом к исходному состоянию: + +```sh +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 +``` diff --git a/custom_components/fglair/screenshots/step-1.png b/custom_components/fglair/screenshots/step-1.png new file mode 100644 index 0000000..122df2b Binary files /dev/null and b/custom_components/fglair/screenshots/step-1.png differ diff --git a/custom_components/fglair/screenshots/step-2.png b/custom_components/fglair/screenshots/step-2.png new file mode 100644 index 0000000..4c6d9f9 Binary files /dev/null and b/custom_components/fglair/screenshots/step-2.png differ diff --git a/custom_components/fglair/screenshots/step-3.png b/custom_components/fglair/screenshots/step-3.png new file mode 100644 index 0000000..b496618 Binary files /dev/null and b/custom_components/fglair/screenshots/step-3.png differ diff --git a/custom_components/fglair/screenshots/step-4.png b/custom_components/fglair/screenshots/step-4.png new file mode 100644 index 0000000..4f341bf Binary files /dev/null and b/custom_components/fglair/screenshots/step-4.png differ diff --git a/custom_components/fglair/screenshots/step-5.png b/custom_components/fglair/screenshots/step-5.png new file mode 100644 index 0000000..f9b8882 Binary files /dev/null and b/custom_components/fglair/screenshots/step-5.png differ diff --git a/custom_components/fglair/screenshots/step-6.png b/custom_components/fglair/screenshots/step-6.png new file mode 100644 index 0000000..c5b98af Binary files /dev/null and b/custom_components/fglair/screenshots/step-6.png differ diff --git a/custom_components/fglair/screenshots/step-7.png b/custom_components/fglair/screenshots/step-7.png new file mode 100644 index 0000000..32f5f04 Binary files /dev/null and b/custom_components/fglair/screenshots/step-7.png differ diff --git a/custom_components/fglair/screenshots/step-8.png b/custom_components/fglair/screenshots/step-8.png new file mode 100644 index 0000000..09f5705 Binary files /dev/null and b/custom_components/fglair/screenshots/step-8.png differ diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 384d58f..a757852 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -163,4 +163,4 @@ Security → Long-lived access tokens) и REST API: вызов сервисов | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H4 ✅ | repair, диагностика (ключ для ESPHome), translations | -| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда | +| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` | diff --git a/docs/RELEASE_HA.md b/docs/RELEASE_HA.md new file mode 100644 index 0000000..da65e5a --- /dev/null +++ b/docs/RELEASE_HA.md @@ -0,0 +1,55 @@ +# Чек-лист релиза HA-интеграции (H5) + +## 1. Публикация pyfglair + +Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA +резолвит требования только через PyPI. + +```sh +# на каждой целевой архитектуре (linux x86_64/aarch64): +.venv/bin/python -m build --wheel # py3-none-linux_.whl с .so и mbedtls +.venv/bin/python -m build --sdist # sdist с C++-исходниками (MANIFEST.in) +twine upload dist/* +``` + +Проверки перед публикацией: + +* `scripts/py-ci.sh` — 28 pyfglair + 47 components + 6 acceptance self-test; +* `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11; +* `readelf -d pyfglair/libfgl-aircon.so*` — нет внешней `libmbedcrypto` + (mbedtls встроен); +* `auditwheel show dist/pyfglair-*.whl` — платформа совместима с manylinux + (зависимости: libstdc++/libc); +* установка wheel в чистый venv вне репозитория: `import pyfglair.session`. + +При невозможности публикации wheel для какой-то архитектуры в манифесте +можно указать прямую ссылку на wheel, но это лишает HACS-пользователей +других архитектур; предпочтителен PyPI. + +## 2. HACS + +* Репозиторий содержит `custom_components/fglair/` и `hacs.json` (name). +* Заполнить `codeowners` в `custom_components/fglair/manifest.json` + (GitHub-хендлы владельцев; сейчас пустой список — допустимо для custom + repository, но не для выкладки в HACS по умолчанию). +* Проверить `documentation`/`issue_tracker` в манифесте (актуальные URL). +* Заменить заглушки `custom_components/fglair/screenshots/step-N.png` + реальными скриншотами (описания «что должно быть видно» — в README). +* Добавить brand-ассеты (`custom_components/fglair/brand/icon.png`, + `logo.png`) при выкладке в HACS. +* Создать git-тег (semver) и указать его в HACS-релизе. + +## 3. Приёмка на живом стенде + +* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений + (HA; при наличии ESPHome-компонента — обе стороны), с возвратом. +* `--long --hours 24` — суточный прогон с CSV-отчётом. +* Проверить сценарии: 503 (два слота заняты), key_error и Repair, + offline/восстановление, несколько устройств (разные порты прослушивания), + reconfigure без потери конверсий. + +## 4. Ограничения текущего стенда + +* Реального железа/облака в CI нет: протокол проверен mock-модулем + (`tests/ayla/mock_ac.py`), облако — мок-сервером. +* Суточный soak и работа с реальными re-key — только на приборе. diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8aa4a88 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "FGLair", + "render_readme": true +} diff --git a/scripts/py-ci.sh b/scripts/py-ci.sh index e064c5d..f2a3210 100644 --- a/scripts/py-ci.sh +++ b/scripts/py-ci.sh @@ -27,7 +27,7 @@ if [ ! -x "$VENV/bin/python" ]; then exit 1 fi -"$VENV/bin/python" -m pytest tests/pyfglair -q +"$VENV/bin/python" -m pytest tests/pyfglair tests/acceptance -q if [ -x "$HA_VENV/bin/python" ]; then "$HA_VENV/bin/python" -m pytest tests/components -q diff --git a/tests/acceptance/test_acceptance.py b/tests/acceptance/test_acceptance.py new file mode 100644 index 0000000..44ed403 --- /dev/null +++ b/tests/acceptance/test_acceptance.py @@ -0,0 +1,169 @@ +"""Self-test приёмочного скрипта на моке HA REST API (без железа).""" +from __future__ import annotations + +import json +import pathlib +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from test_esphome_ha import ( # noqa: E402 + AcceptanceError, + HaRest, + build_steps, + main, +) + + +def free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class MockHa: + def __init__(self) -> None: + self.lock = threading.Lock() + self.fail_next = False + self.state = { + "entity_id": "climate.test", + "state": "heat", + "attributes": { + "hvac_modes": ["off", "cool", "heat"], + "fan_modes": ["low", "high"], + "swing_modes": ["off", "on"], + "temperature": 22.0, + "min_temp": 16.0, + "max_temp": 30.0, + "fan_mode": "low", + "swing_mode": "off", + }, + } + + def handler(self): + mock = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _json(self, status: int, payload) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + self.wfile.write(body) + + def do_GET(self): + if self.path == "/api/states/climate.test": + with mock.lock: + self._json(200, json.loads(json.dumps(mock.state))) + else: + self._json(404, {"message": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + data = json.loads(self.rfile.read(length) or b"{}") + with mock.lock: + if mock.fail_next: + mock.fail_next = False + self._json(500, {"message": "boom"}) + return + service = self.path.rsplit("/", 1)[-1] + attrs = mock.state["attributes"] + if service == "set_hvac_mode": + mock.state["state"] = data["hvac_mode"] + elif service == "set_fan_mode": + attrs["fan_mode"] = data["fan_mode"] + elif service == "set_swing_mode": + attrs["swing_mode"] = data["swing_mode"] + elif service == "set_temperature": + attrs["temperature"] = data["temperature"] + self._json(200, []) + + return Handler + + +@pytest.fixture +def mock_ha(): + mock = MockHa() + server = ThreadingHTTPServer(("127.0.0.1", 0), mock.handler()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = server.server_address[1] + yield mock, f"http://127.0.0.1:{port}" + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_build_steps_supported_only(): + state = { + "state": "heat", + "attributes": { + "hvac_modes": ["off"], + "fan_modes": [], + "temperature": 20.0, + "min_temp": 16.0, + "max_temp": 30.0, + }, + } + steps = build_steps(state) + assert [step.name for step in steps] == ["temperature"] + + +def test_quick_success(mock_ha, capsys): + _, url = mock_ha + assert main(["quick", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 0 + out = capsys.readouterr().out + assert "restore: expected=heat observed=heat" in out + + +def test_quick_burst_success(mock_ha): + _, url = mock_ha + assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 0 + + +def test_quick_service_error(mock_ha, capsys): + mock, url = mock_ha + mock.fail_next = True + assert main(["quick", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 2 + assert "HTTP 500" in capsys.readouterr().err + + +def test_long_report(mock_ha, tmp_path, capsys): + _, url = mock_ha + report = tmp_path / "acceptance.csv" + rc = main([ + "long", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test", + "--hours", "0.000003", "--interval", "0.01", + "--report", str(report), + ]) + assert rc == 0 + text = report.read_text() + assert "timestamp,step,expected,observed,result" in text + assert "ok" in text + assert "long: ok=1 fail=0" in capsys.readouterr().out + + +def test_wait_state_timeout(mock_ha): + _, url = mock_ha + ha = HaRest(url, "t", timeout=1) + with pytest.raises(AcceptanceError): + ha.wait_state("climate.test", lambda s: False, timeout=0.2) diff --git a/tests/acceptance/test_esphome_ha.py b/tests/acceptance/test_esphome_ha.py new file mode 100644 index 0000000..6607c46 --- /dev/null +++ b/tests/acceptance/test_esphome_ha.py @@ -0,0 +1,366 @@ +#!/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())