diff --git a/README.md b/README.md index 3d602f1..646f0fa 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ python -m pyfglair monitor --config config_home.json --duration 60 Компонент `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); +для несовпадения ключа и диагностика. Установка: HACS (нужно публичное +GitHub-зеркало) или ручное копирование `custom_components/fglair/`. +Инструкция: [`custom_components/fglair/README.md`](custom_components/fglair/README.md); приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`. ## Getting the key (`lanip_key`) diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md index 5796e05..ceda294 100644 --- a/custom_components/fglair/README.md +++ b/custom_components/fglair/README.md @@ -17,9 +17,15 @@ * Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**: телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. -## Установка через HACS +## Установка -1. HACS → ⋮ → **Custom repositories** → URL репозитория, категория +### Вариант A: HACS (нужно GitHub-зеркало) + +HACS работает только с публичными репозиториями на **GitHub**; GitLab/Gitea +(включая текущий хостинг проекта) напрямую не поддерживаются. Зеркалируйте +репозиторий на GitHub и добавьте зеркало в HACS: + +1. HACS → ⋮ → **Custom repositories** → URL GitHub-зеркала, категория **Integration** → **Add**. ![шаг 1](screenshots/step-1.png) @@ -34,6 +40,13 @@ *Должно быть видно: страница загрузки интеграции с кнопкой Download и версией.* +### Вариант B: вручную (любой хостинг) + +Скопируйте каталог `custom_components/fglair/` в +`/custom_components/fglair/` вашего HA и перезапустите Home +Assistant. Убедитесь, что `pyfglair` установлен в python-окружение HA +(см. «Требования»). + ## Добавление устройства 3. Settings → Devices & Services → **Add Integration** → «FGLair». @@ -43,7 +56,7 @@ *Должно быть видно: диалог поиска интеграции с введённым «FGLair» и выделенным результатом.* -4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако +4. **Вход в облако**: e-mail/пароль FGLair и регион. Облако используется один раз — получить статический LAN-ключ модуля. ![шаг 4](screenshots/step-4.png) @@ -57,7 +70,7 @@ *Должно быть видно: список с одним устройством (имя, модель, host).* -6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных +6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных значений (режимы, диапазон температур, пример конверсии) и, при необходимости, ручные коэффициенты/диапазон уставки. Сверьте с приложением FGLair и подтвердите. @@ -80,8 +93,11 @@ Download diagnostics на странице устройства показыва `lanip_key_id`, `host` и **маску** ключа. Полный ключ (для `secrets.yaml` ESPHome) получите явным действием: -* **Настроить заново** (Reconfigure) на карточке интеграции — вход в - облако FGLair покажет/сохранит актуальный ключ; либо +* **Настройка** (шестерёнка → Configure) на карточке интеграции: поле + «LAN IP key» содержит полный ключ — скопируйте его (там же можно + заменить ключ без облака); либо +* **Настроить заново** (Reconfigure) — повторный вход в облако FGLair + обновит ключ; либо * `python -m pyfglair discover --region eu --email you@example.com --format esphome-secrets` на хосте HA. diff --git a/custom_components/fglair/brand/icon.png b/custom_components/fglair/brand/icon.png new file mode 100644 index 0000000..8c4598e Binary files /dev/null and b/custom_components/fglair/brand/icon.png differ diff --git a/custom_components/fglair/brand/logo.png b/custom_components/fglair/brand/logo.png new file mode 100644 index 0000000..541cd30 Binary files /dev/null and b/custom_components/fglair/brand/logo.png differ diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index fdf9d20..23544a7 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -8,7 +8,12 @@ from functools import partial from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.helpers import selector import pyfglair @@ -547,3 +552,40 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): self._reconfigure_entry, title=title, data=data ) return self.async_create_entry(title=title, data=data) + + @staticmethod + def async_get_options_flow(config_entry: ConfigEntry) -> "FglairOptionsFlow": + return FglairOptionsFlow() + + +class FglairOptionsFlow(OptionsFlow): + """Показывает полный LAN-ключ (для secrets.yaml ESPHome) и позволяет + обновить его без облака.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + entry = self.config_entry + if user_input is not None: + key = str(user_input[CONF_LANIP_KEY]).strip() + if key and key != entry.data.get(CONF_LANIP_KEY): + self.hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_LANIP_KEY: key} + ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_create_entry(title="", data={}) + schema = vol.Schema( + {vol.Required(CONF_LANIP_KEY): selector.TextSelector()} + ) + schema = self.add_suggested_values_to_schema( + schema, {CONF_LANIP_KEY: entry.data.get(CONF_LANIP_KEY, "")} + ) + return self.async_show_form( + step_id="init", + data_schema=schema, + description_placeholders={ + "dsn": str(entry.data.get(CONF_DSN, "")), + "lanip_key_id": str(entry.data.get(CONF_LANIP_KEY_ID, "")), + "host": str(entry.data.get(CONF_HOST, "")), + }, + ) diff --git a/custom_components/fglair/diagnostics.py b/custom_components/fglair/diagnostics.py index 3701f7d..60a8fd8 100644 --- a/custom_components/fglair/diagnostics.py +++ b/custom_components/fglair/diagnostics.py @@ -2,7 +2,8 @@ Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в issues). Полный ключ для secrets.yaml ESPHome берётся явным действием: -«Настроить заново» (reconfigure) на карточке интеграции или CLI +«Настройка» (шестерёнка, options flow) на карточке интеграции — +одноимённое поле содержит полный ключ; либо CLI ``python -m pyfglair discover`` на хосте HA. """ from __future__ import annotations diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 1a70d40..ba3c5f3 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -189,5 +189,16 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "FGLair LAN key", + "description": "Full LAN key (for ESPHome secrets.yaml). Copy it, or paste a new one to replace the stored key. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Saving a changed key reloads the device.", + "data": { + "lanip_key": "LAN IP key" + } + } + } } } diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index 1a70d40..ba3c5f3 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -189,5 +189,16 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "FGLair LAN key", + "description": "Full LAN key (for ESPHome secrets.yaml). Copy it, or paste a new one to replace the stored key. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Saving a changed key reloads the device.", + "data": { + "lanip_key": "LAN IP key" + } + } + } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index 93f305a..6e2dd04 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -189,5 +189,16 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "LAN-ключ FGLair", + "description": "Полный LAN-ключ (для secrets.yaml ESPHome). Скопируйте его или вставьте новый, чтобы заменить сохранённый. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Сохранение изменённого ключа перезагружает устройство.", + "data": { + "lanip_key": "LAN IP key" + } + } + } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index a757852..8ab0fc0 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -118,8 +118,9 @@ asyncio → push-coordinator. Состояния: `online` → available; Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит реальными скриншотами; рядом с каждой — описание что должно быть видно): -1. **Установка через HACS**: - * HACS → ⋮ → Custom repositories → URL репозитория, категория +1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не + поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`): + * HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория Integration → Add. Скриншот: диалог добавления custom repository с заполненным URL и выбранной категорией Integration. * FGLair → Download → перезапуск HA. Скриншот: страница загрузки @@ -152,8 +153,11 @@ Security → Long-lived access tokens) и REST API: вызов сервисов (OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт скрипту (`--ha-url`, `--ha-token`). Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long` -(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ -открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP). +(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт дописывается +по ходу). Скрипт НЕ открывает собственную сессию к кондиционеру (оба слота +заняты HA+ESP). ESPHome-канал (опционально, `--esphome-host`): через native +API отправляются шаги «режим» и «уставка», проверка — по состоянию в HA +(сквозная цепочка ESPHome → модуль → HA); fan/swing — только со стороны HA. ## 8. Этапы diff --git a/docs/RELEASE_HA.md b/docs/RELEASE_HA.md index da65e5a..78032bb 100644 --- a/docs/RELEASE_HA.md +++ b/docs/RELEASE_HA.md @@ -1,55 +1,79 @@ # Чек-лист релиза HA-интеграции (H5) -## 1. Публикация pyfglair +## 1. Публикация pyfglair (PyPI) Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA -резолвит требования только через PyPI. +резолвит требования только через PyPI. PyPI (warehouse) принимает только +`manylinux*`/`musllinux*` платформенные теги — локальный +`py3-none-linux_x86_64` будет отклонён (`HTTP 400 unsupported platform +tag`), поэтому wheel нужно «починить» auditwheel'ом в контейнере со старой +glibc (manylinux): ```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) +pip install build auditwheel twine + +# для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере +# (например, quay.io/pypa/manylinux2014_): +python -m build --wheel # .so без внешнего mbedcrypto +auditwheel repair dist/pyfglair-*.whl \ + --plat manylinux2014_x86_64 -w dist/ # или manylinux_2_28 и т.п. +python -m build --sdist # sdist с C++-исходниками (MANIFEST.in) twine upload dist/* ``` Проверки перед публикацией: -* `scripts/py-ci.sh` — 28 pyfglair + 47 components + 6 acceptance self-test; +* `scripts/py-ci.sh` — pyfglair + acceptance self-test + components; * `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`. +* `readelf -d build-pyfglair/libfgl-aircon.so*` (или распакованный wheel: + `pyfglair/libfgl-aircon.so*`) — нет внешней `libmbedcrypto` (встроен); +* `auditwheel show` для repaired-wheel — тег manylinux и только + базовые системные библиотеки; +* установка wheel в чистый venv вне репозитория: + `python -c "import pyfglair.session"`; +* aarch64: сборка в соответствующем manylinux-контейнере (QEMU или + нативная), иначе пользователи ARM не смогут поставить `pyfglair`. -При невозможности публикации wheel для какой-то архитектуры в манифесте -можно указать прямую ссылку на wheel, но это лишает HACS-пользователей -других архитектур; предпочтителен PyPI. +Альтернатива — cibuildwheel (`pip install cibuildwheel`) с `auditwheel +repair` внутри, но текущий `bdist_wheel`-хук отдаёт тег +`py3-none-`, поэтому проверяйте итоговые теги вручную. -## 2. HACS +## 2. GitHub-зеркало и 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-релизе. +HACS устанавливает интеграции только с публичных **GitHub**-репозиториев +(GitLab/Gitea не поддерживаются). Шаги: + +* создать GitHub-зеркало (push mirror) и убедиться, что оно содержит + `custom_components/fglair/`, `hacs.json`, тег релиза; +* заполнить `codeowners` в `custom_components/fglair/manifest.json` + (GitHub-хендлы; сейчас пустой список); +* проверить `documentation`/`issue_tracker` в манифесте; +* обновить `version` манифеста под номер тега (сейчас `0.1.0`, pyfglair — + `1.0.0`; синхронизируйте по вкусу — компонент и пакет версионируются + отдельно); +* заменить заглушки `custom_components/fglair/screenshots/step-N.png` + реальными скриншотами (описания «что должно быть видно» — в README); +* заменить заглушки `custom_components/fglair/brand/{icon,logo}.png` + фирменными ассетами; +* создать git-тег (semver) в GitHub-зеркале — HACS покажет версию. ## 3. Приёмка на живом стенде -* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений - (HA; при наличии ESPHome-компонента — обе стороны), с возвратом. -* `--long --hours 24` — суточный прогон с CSV-отчётом. -* Проверить сценарии: 503 (два слота заняты), key_error и Repair, - offline/восстановление, несколько устройств (разные порты прослушивания), - reconfigure без потери конверсий. +* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений (HA; + с `--esphome-host` шаги «режим/уставка» идут через ESPHome, проверка — по + состоянию в HA), возврат к исходному. +* `long --hours 24 --interval 3600 --report acceptance.csv` — суточный + прогон, отчёт дописывается по ходу. +* Сценарии: 503 (два слота заняты), key_error + Repair, offline и + восстановление, два устройства (разные порты прослушивания), options flow + (копирование/смена ключа), reconfigure без потери конверсий. ## 4. Ограничения текущего стенда * Реального железа/облака в CI нет: протокол проверен mock-модулем - (`tests/ayla/mock_ac.py`), облако — мок-сервером. -* Суточный soak и работа с реальными re-key — только на приборе. + (`tests/ayla/mock_ac.py`), облако — мок-сервером; приёмочный скрипт имеет + self-test на моке HA REST. +* Суточный soak, re-key и облачный вход — только на приборе/аккаунте. +* ESPHome-канал приёмки требует установленного `aioesphomeapi` и + работающего ESPHome-компонента (PLAN_ESPHOME); без них скрипт работает + только со стороны HA. diff --git a/hacs.json b/hacs.json index 8aa4a88..8f6864b 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,5 @@ { "name": "FGLair", + "homeassistant": "2025.1.0", "render_readme": true } diff --git a/tests/acceptance/test_acceptance.py b/tests/acceptance/test_acceptance.py index 44ed403..9fffb06 100644 --- a/tests/acceptance/test_acceptance.py +++ b/tests/acceptance/test_acceptance.py @@ -3,10 +3,11 @@ from __future__ import annotations import json import pathlib -import socket import sys import threading +import types from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace import pytest @@ -14,24 +15,21 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from test_esphome_ha import ( # noqa: E402 AcceptanceError, + EsphomeChannel, HaRest, + Step, 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.fail_at = 0 + self.post_count = 0 + self.ignore_services = False + self.non_json = False self.state = { "entity_id": "climate.test", "state": "heat", @@ -47,6 +45,17 @@ class MockHa: }, } + def apply(self, service: str, data: dict) -> None: + attrs = self.state["attributes"] + if service == "set_hvac_mode": + self.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"] + def handler(self): mock = self @@ -66,31 +75,35 @@ class MockHa: self.close_connection = True self.wfile.write(body) + def _raw(self, status: int, body: bytes) -> None: + self.send_response(status) + self.send_header("Content-Type", "text/plain") + 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: + if self.path != "/api/states/climate.test": self._json(404, {"message": "not found"}) + return + with mock.lock: + if mock.non_json: + self._raw(200, b"captive portal") + return + self._json(200, json.loads(json.dumps(mock.state))) 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 + mock.post_count += 1 + if mock.fail_at and mock.post_count == mock.fail_at: 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"] + if not mock.ignore_services: + mock.apply(self.path.rsplit("/", 1)[-1], data) self._json(200, []) return Handler @@ -109,6 +122,9 @@ def mock_ha(): thread.join(timeout=5) +QUICK_ARGS = ["quick", "--ha-token", "t", "--entity", "climate.test"] + + def test_build_steps_supported_only(): state = { "state": "heat", @@ -120,33 +136,59 @@ def test_build_steps_supported_only(): "max_temp": 30.0, }, } - steps = build_steps(state) - assert [step.name for step in steps] == ["temperature"] + assert [step.name for step in build_steps(state)] == ["temperature"] -def test_quick_success(mock_ha, capsys): +def test_quick_success(mock_ha, capsys, tmp_path): _, url = mock_ha - assert main(["quick", "--ha-url", url, "--ha-token", "t", - "--entity", "climate.test"]) == 0 + report = tmp_path / "quick.csv" + assert main( + [*QUICK_ARGS, "--ha-url", url, "--report", str(report)] + ) == 0 out = capsys.readouterr().out assert "restore: expected=heat observed=heat" in out + assert "ok" in report.read_text() def test_quick_burst_success(mock_ha): _, url = mock_ha - assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t", - "--entity", "climate.test"]) == 0 + assert main([*QUICK_ARGS, "--burst", "--ha-url", url]) == 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 + mock.fail_at = 1 + assert main([*QUICK_ARGS, "--ha-url", url]) == 2 assert "HTTP 500" in capsys.readouterr().err -def test_long_report(mock_ha, tmp_path, capsys): +def test_quick_restores_after_partial_failure(mock_ha): + """Сбой на втором шаге — исходное состояние возвращается (finally).""" + mock, url = mock_ha + mock.fail_at = 2 + assert main([*QUICK_ARGS, "--ha-url", url]) == 2 + assert mock.state["state"] == "heat" + assert mock.state["attributes"]["fan_mode"] == "low" + + +def test_quick_timeout_is_failure(mock_ha, capsys): + mock, url = mock_ha + mock.ignore_services = True + rc = main( + [*QUICK_ARGS, "--ha-url", url, "--settle-timeout", "0.3"] + ) + assert rc == 1 + assert "ПРОВАЛЕНО" in capsys.readouterr().err + + +def test_quick_non_json_answer(mock_ha, capsys): + mock, url = mock_ha + mock.non_json = True + assert main([*QUICK_ARGS, "--ha-url", url]) == 2 + assert "не JSON" in capsys.readouterr().err + + +def test_long_report_on_success(mock_ha, tmp_path, capsys): _, url = mock_ha report = tmp_path / "acceptance.csv" rc = main([ @@ -162,8 +204,74 @@ def test_long_report(mock_ha, tmp_path, capsys): assert "long: ok=1 fail=0" in capsys.readouterr().out +def test_long_failure_restores_and_reports(mock_ha, tmp_path): + mock, url = mock_ha + mock.ignore_services = True # шаг не достигается, восстановление — работает + report = tmp_path / "fail.csv" + rc = main([ + "long", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test", + "--hours", "0.000003", "--interval", "0.01", + "--settle-timeout", "0.3", + "--report", str(report), + ]) + assert rc == 1 + assert "fail" in report.read_text() + assert mock.state["state"] == "heat" + + 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) + + +def test_esphome_channel_sends_mode_and_temperature(monkeypatch): + instances: list = [] + + class ClimateInfo: + key = 7 + + class FakeClient: + def __init__(self, host, port, noise_psk=None): + self.calls: list = [] + instances.append(self) + + async def connect(self, login=False): + self.login = login + + async def list_entities_services(self): + return [ClimateInfo()], [] + + async def climate_command(self, key=None, mode=None, + target_temperature=None): + self.calls.append((key, mode, target_temperature)) + + async def disconnect(self): + self.disconnected = True + + fake = types.ModuleType("aioesphomeapi") + fake.APIClient = FakeClient + fake.ClimateMode = SimpleNamespace(COOL="COOL", HEAT="HEAT") + fake.ClimateInfo = ClimateInfo + monkeypatch.setitem(sys.modules, "aioesphomeapi", fake) + + channel = EsphomeChannel("esphome.local", "key") + try: + channel.connect() + mode_step = Step("hvac_mode", "climate", "set_hvac_mode", + {"hvac_mode": "cool"}, "state", "cool") + temp_step = Step("temperature", "climate", "set_temperature", + {"temperature": 23.5}, "temperature", 23.5) + fan_step = Step("fan_mode", "climate", "set_fan_mode", + {"fan_mode": "low"}, "fan_mode", "low") + assert channel.apply(mode_step) is True + assert channel.apply(temp_step) is True + assert channel.apply(fan_step) is False + finally: + channel.close() + + assert instances[0].calls == [(7, "COOL", None), (7, None, 23.5)] + assert instances[0].login is True + assert instances[0].disconnected is True diff --git a/tests/acceptance/test_esphome_ha.py b/tests/acceptance/test_esphome_ha.py index 6607c46..3e88945 100644 --- a/tests/acceptance/test_esphome_ha.py +++ b/tests/acceptance/test_esphome_ha.py @@ -10,23 +10,28 @@ HA + ESPHome); работает через REST API Home Assistant по long-liv 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 \ + 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-отчёт. + «burst» (без пауз), каждое действие проверяется по состоянию в + HA и завершается возвратом к исходному состоянию; + long — одно изменение в --interval секунд (по умолчанию 3600) на + протяжении --hours часов, проверка и возврат, CSV-отчёт + дописывается по ходу. ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity -и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome, -результат сверяется по состоянию в HA. +и установленный `aioesphomeapi` — шаги «режим» и «уставка» отправляются +через ESPHome native API, результат сверяется по состоянию в HA (то есть +проверяется сквозная цепочка ESPHome → модуль → HA). Fan/swing через +ESPHome в этой версии не отправляются. """ from __future__ import annotations import argparse +import asyncio import csv import datetime as dt import json @@ -64,7 +69,12 @@ class HaRest: try: with urllib.request.urlopen(request, timeout=self.timeout) as resp: body = resp.read().decode() or "null" - return resp.status, json.loads(body) + try: + return resp.status, json.loads(body) + except ValueError as err: + raise AcceptanceError( + f"{method} {path}: ответ не JSON: {body[:120]!r}" + ) from err except urllib.error.HTTPError as err: try: body = err.read().decode(errors="replace") @@ -104,6 +114,79 @@ class HaRest: ) +class EsphomeChannel: + """Канал ESPHome native API: режим и уставка (best-effort).""" + + def __init__(self, host: str, key: str, timeout: float = DEFAULT_TIMEOUT): + self.host = host + self.key = key + self.timeout = timeout + self._loop = asyncio.new_event_loop() + self._aio = None + self._client = None + self._entity_key: Optional[int] = None + + def connect(self) -> None: + self._loop.run_until_complete(self._connect()) + + async def _connect(self) -> None: + import aioesphomeapi # noqa: PLC0415 + + self._aio = aioesphomeapi + self._client = aioesphomeapi.APIClient( + self.host, 6053, noise_psk=self.key or None + ) + await asyncio.wait_for( + self._client.connect(login=True), timeout=self.timeout + ) + entities, _services = await self._client.list_entities_services() + climate_info = getattr(self._aio, "ClimateInfo", None) + for entity in entities: + if climate_info is not None and isinstance(entity, climate_info): + self._entity_key = entity.key + break + if entity.__class__.__name__ == "ClimateInfo": + self._entity_key = entity.key + break + if self._entity_key is None: + raise AcceptanceError("ESPHome: climate-сущность не найдена") + + def apply(self, step: "Step") -> bool: + """Отправляет шаг через ESPHome; False — шаг не поддержан каналом.""" + if step.name not in ("hvac_mode", "temperature"): + return False + self._loop.run_until_complete(self._apply(step)) + return True + + async def _apply(self, step: "Step") -> None: + mode = None + target = None + if step.name == "hvac_mode": + mode = getattr(self._aio.ClimateMode, str(step.expected).upper()) + else: + target = float(step.expected) + await asyncio.wait_for( + self._client.climate_command( + key=self._entity_key, mode=mode, target_temperature=target + ), + timeout=self.timeout, + ) + + def close(self) -> None: + try: + if self._client is not None: + try: + self._loop.run_until_complete( + asyncio.wait_for( + self._client.disconnect(), timeout=self.timeout + ) + ) + except Exception: # noqa: BLE001 - best-effort + pass + finally: + self._loop.close() + + @dataclass class Step: name: str @@ -117,7 +200,8 @@ class Step: def check(state: dict) -> bool: if self.attribute == "state": return state.get("state") == self.expected - return state.get("attributes", {}).get(self.attribute) == self.expected + value = state.get("attributes", {}).get(self.attribute) + return _matches(value, self.expected) return check @@ -130,12 +214,26 @@ def _pick(options: list, preferred: str) -> Optional[str]: return options[0] +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 _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 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"] + hvac_modes = [mode for mode in attrs.get("hvac_modes", []) if mode != "off"] mode = _pick(hvac_modes, "cool") if mode: steps.append( @@ -171,14 +269,33 @@ def build_steps(state: dict) -> list[Step]: return steps -def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None: +def _original_predicate(original: dict) -> Callable[[dict], bool]: + state = original.get("state") + attrs = original.get("attributes", {}) + + def check(current: dict) -> bool: + if current.get("state") != state: + return False + current_attrs = current.get("attributes", {}) + for key in ("fan_mode", "swing_mode", "temperature"): + if attrs.get(key) is not None and not _matches( + current_attrs.get(key), attrs.get(key) + ): + return False + return True + + return check + + +def _restore( + ha: HaRest, entity_id: str, original: dict, *, wait: bool = True +) -> 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": + elif 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: @@ -190,59 +307,90 @@ def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None: if attrs.get("swing_mode") is not None: ha.call("climate", "set_swing_mode", {"entity_id": entity_id, "swing_mode": attrs["swing_mode"]}) + if wait: + ha.wait_state(entity_id, _original_predicate(original)) -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 _apply_step( + ha: HaRest, esphome: Optional[EsphomeChannel], entity_id: str, step: Step +) -> None: + if esphome is not None and esphome.apply(step): + return + ha.call(step.domain, step.service, + {"entity_id": entity_id, **step.service_data}) -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 _write_report( + path: str, rows: list[list], write_header: bool = True +) -> None: + mode = "w" if write_header else "a" + with open(path, mode, newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + if write_header: + writer.writerow( + ["timestamp", "step", "expected", "observed", "result"] + ) + writer.writerows(rows) -def run_quick(ha: HaRest, entity_id: str, *, burst: bool = False) -> list[dict]: +def run_quick( + ha: HaRest, + entity_id: str, + *, + burst: bool = False, + esphome: Optional[EsphomeChannel] = None, + settle_timeout: float = SETTLE_TIMEOUT, +) -> 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"), - } - ) + results: list[dict] = [] + try: + if burst and esphome is None: + for step in steps: + _apply_step(ha, esphome, entity_id, step) + for step in steps: + try: + state = ha.wait_state( + entity_id, step.predicate(), settle_timeout + ) + observed = _observed(state, step) + except AcceptanceError as err: + observed = str(err) + results.append({ + "step": step.name, + "expected": step.expected, + "observed": observed, + }) + else: + for step in steps: + _apply_step(ha, esphome, entity_id, step) + try: + state = ha.wait_state( + entity_id, step.predicate(), settle_timeout + ) + observed = _observed(state, step) + except AcceptanceError as err: + observed = str(err) + results.append({ + "step": step.name, + "expected": step.expected, + "observed": observed, + }) + finally: + _restore(ha, entity_id, original, wait=False) + try: + restored = ha.wait_state( + entity_id, _original_predicate(original), settle_timeout + ) + except AcceptanceError: + restored = ha.state(entity_id) + results.append({ + "step": "restore", + "expected": original.get("state"), + "observed": restored.get("state"), + }) return results @@ -253,6 +401,8 @@ def run_long( hours: float, interval: float, report_path: Optional[str], + esphome: Optional[EsphomeChannel] = None, + settle_timeout: float = SETTLE_TIMEOUT, ) -> tuple[int, int]: original = ha.state(entity_id) steps = build_steps(original) @@ -260,53 +410,38 @@ def run_long( raise AcceptanceError("climate-сущность не поддерживает ни одного шага") iterations = max(1, int(hours * 3600 / interval)) ok = failed = 0 - rows = [] + rows: list[list] = [] 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) + _apply_step(ha, esphome, entity_id, step) + state = ha.wait_state( + entity_id, step.predicate(), settle_timeout ) - passed = observed == step.expected - _restore(ha, entity_id, original, entity_id) - time.sleep(1.0) + observed = _observed(state, step) + passed = _matches(observed, step.expected) except AcceptanceError as err: - printed = str(err) + observed = str(err) passed = False - observed = printed + finally: + try: + _restore(ha, entity_id, original) + except AcceptanceError as err: + observed = f"restore failed: {err}" + passed = False 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"]) + row = [timestamp, step.name, step.expected, observed, + "ok" if passed else "fail"] + rows.append(row) + if report_path: + _write_report(report_path, [row], write_header=index == 0) 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"], @@ -318,26 +453,39 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--entity", required=True, help="entity_id climate-сущности FGLair") parser.add_argument("--burst", action="store_true", - help="quick: без пауз между изменениями") + help="quick: без пауз между изменениями (без ESPHome)") 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("--report", help="CSV-отчёт (quick и long)") parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--settle-timeout", type=float, + default=SETTLE_TIMEOUT) parser.add_argument("--esphome-host") parser.add_argument("--esphome-key", default="") - parser.add_argument("--esphome-entity") return parser +def _make_esphome(args: argparse.Namespace) -> Optional[EsphomeChannel]: + if not args.esphome_host: + return None + channel = EsphomeChannel(args.esphome_host, args.esphome_key, args.timeout) + channel.connect() + return channel + + def main(argv: Optional[list[str]] = None) -> int: args = _build_parser().parse_args(argv) if args.long_mode: args.mode = "long" + esphome: Optional[EsphomeChannel] = None try: - _esphome_client(args) + esphome = _make_esphome(args) ha = HaRest(args.ha_url, args.ha_token, args.timeout) if args.mode == "quick": - results = run_quick(ha, args.entity, burst=args.burst) + results = run_quick( + ha, args.entity, burst=args.burst, esphome=esphome, + settle_timeout=args.settle_timeout, + ) failed = [ row for row in results if not _matches(row.get("observed"), row.get("expected")) @@ -345,6 +493,14 @@ def main(argv: Optional[list[str]] = None) -> int: for row in results: print(f"{row['step']}: expected={row['expected']} " f"observed={row.get('observed', '-')}") + if args.report: + _write_report(args.report, [ + [dt.datetime.now(dt.timezone.utc).isoformat(), + row["step"], row["expected"], row.get("observed"), + "ok" if _matches(row.get("observed"), + row.get("expected")) else "fail"] + for row in results + ]) if failed: print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr) return 1 @@ -352,7 +508,8 @@ def main(argv: Optional[list[str]] = None) -> int: ok, failed = run_long( ha, args.entity, hours=args.hours, interval=args.interval, - report_path=args.report, + report_path=args.report, esphome=esphome, + settle_timeout=args.settle_timeout, ) print(f"long: ok={ok} fail={failed}" + (f", отчёт: {args.report}" if args.report else "")) @@ -360,6 +517,9 @@ def main(argv: Optional[list[str]] = None) -> int: except AcceptanceError as err: print(f"приёмка: {err}", file=sys.stderr) return 2 + finally: + if esphome is not None: + esphome.close() if __name__ == "__main__": diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index ef93ddc..c213a8b 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -479,3 +479,44 @@ async def test_reconfigure_wrong_device(hass: HomeAssistant): ) assert result["type"] == "abort" assert result["reason"] == "unique_id_mismatch" + + +async def test_options_flow_shows_and_updates_key( + hass: HomeAssistant, mock_ac, socket_enabled +): + from test_entities import _setup_entry + + proc = mock_ac(["--set", "operation_mode=6"]) + entry = await _setup_entry(hass, proc.port) + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + assert _suggested(result["data_schema"], "lanip_key") == LANIP_KEY + assert result["description_placeholders"]["dsn"] == DSN + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"lanip_key": LANIP_KEY} + ) + assert result["type"] == "create_entry" + await hass.async_block_till_done() + assert entry.data["lanip_key"] == LANIP_KEY + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_options_flow_new_key_reloads( + hass: HomeAssistant, mock_ac, socket_enabled +): + from test_entities import _setup_entry + + proc = mock_ac(["--set", "operation_mode=6"]) + entry = await _setup_entry(hass, proc.port) + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"lanip_key": "bmV3LWtleQ=="} + ) + assert result["type"] == "create_entry" + await hass.async_block_till_done() + assert entry.data["lanip_key"] == "bmV3LWtleQ==" + assert entry.state.name in ("LOADED", "SETUP_RETRY") + if entry.state.name == "LOADED": + assert await hass.config_entries.async_unload(entry.entry_id)