ha(H5): исправления по ревью — GitHub/HACS, рабочий ESPHome-канал, restore, manylinux

- critical: README честно описывает HACS (только публичный GitHub-зеркало)
  + ручная установка копированием; RELEASE_HA — шаг зеркала и codeowners
- critical: ESPHome-канал приёмки реализован реально (aioesphomeapi:
  connect/list_entities/climate_command для режима и уставки; fan/swing —
  только HA); unit-тест на фейковом модуле
- major: quick/long восстанавливают исходное состояние в finally даже при
  сбое; quick ждёт восстановления; ошибки шага пишутся в результат (rc 1),
  а не фаталят (rc 2)
- major: RELEASE_HA — manylinux через auditwheel repair (PyPI отклоняет
  linux_x86_64), build/twine, aarch64, корректные проверки .so, тег/версия
- major: полный ключ для ESPHome теперь реально виден в options flow
  («Настройка» на карточке интеграции, поле LAN IP key, можно заменить) —
  README/diagnostics синхронизированы
- minor: non-JSON ответ → rc 2; CSV quick+long и инкрементальная запись;
  допуск _matches в long; --settle-timeout; hacs.json homeassistant=2025.1;
  brand/{icon,logo}.png заглушки; план §6/§7 уточнён
- тесты: acceptance self-test 11 (restore-after-failure, long fail, non-JSON,
  esphome channel), options flow (2), всего 28+11+49
This commit is contained in:
2026-09-29 14:54:50 +03:00
parent 8013781e09
commit a03458f9bc
15 changed files with 609 additions and 178 deletions
+3 -2
View File
@@ -97,8 +97,9 @@ python -m pyfglair monitor --config config_home.json --duration 60
Компонент `custom_components/fglair/` (HACS custom repository) — climate, Компонент `custom_components/fglair/` (HACS custom repository) — climate,
switch/select/sensor/binary_sensor, config flow с превью шаблона, repair switch/select/sensor/binary_sensor, config flow с превью шаблона, repair
для несовпадения ключа и диагностика. Инструкция: для несовпадения ключа и диагностика. Установка: HACS (нужно публичное
[`custom_components/fglair/README.md`](custom_components/fglair/README.md); GitHub-зеркало) или ручное копирование `custom_components/fglair/`.
Инструкция: [`custom_components/fglair/README.md`](custom_components/fglair/README.md);
приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`. приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`.
## Getting the key (`lanip_key`) ## Getting the key (`lanip_key`)
+22 -6
View File
@@ -17,9 +17,15 @@
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**: * Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. телефон с 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**. **Integration** → **Add**.
![шаг 1](screenshots/step-1.png) ![шаг 1](screenshots/step-1.png)
@@ -34,6 +40,13 @@
*Должно быть видно: страница загрузки интеграции с кнопкой Download и *Должно быть видно: страница загрузки интеграции с кнопкой Download и
версией.* версией.*
### Вариант B: вручную (любой хостинг)
Скопируйте каталог `custom_components/fglair/` в
`<config>/custom_components/fglair/` вашего HA и перезапустите Home
Assistant. Убедитесь, что `pyfglair` установлен в python-окружение HA
(см. «Требования»).
## Добавление устройства ## Добавление устройства
3. Settings → Devices & Services → **Add Integration** → «FGLair». 3. Settings → Devices & Services → **Add Integration** → «FGLair».
@@ -43,7 +56,7 @@
*Должно быть видно: диалог поиска интеграции с введённым «FGLair» и *Должно быть видно: диалог поиска интеграции с введённым «FGLair» и
выделенным результатом.* выделенным результатом.*
4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако 4. **Вход в облако**: e-mail/пароль FGLair и регион. Облако
используется один раз — получить статический LAN-ключ модуля. используется один раз — получить статический LAN-ключ модуля.
![шаг 4](screenshots/step-4.png) ![шаг 4](screenshots/step-4.png)
@@ -57,7 +70,7 @@
*Должно быть видно: список с одним устройством (имя, модель, host).* *Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных 6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных
значений (режимы, диапазон температур, пример конверсии) и, при значений (режимы, диапазон температур, пример конверсии) и, при
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
приложением FGLair и подтвердите. приложением FGLair и подтвердите.
@@ -80,8 +93,11 @@ Download diagnostics на странице устройства показыва
`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для `lanip_key_id`, `host` и **маску** ключа. Полный ключ (для
`secrets.yaml` ESPHome) получите явным действием: `secrets.yaml` ESPHome) получите явным действием:
* **Настроить заново** (Reconfigure) на карточке интеграции — вход в * **Настройка** (шестерёнка → Configure) на карточке интеграции: поле
облако FGLair покажет/сохранит актуальный ключ; либо «LAN IP key» содержит полный ключ — скопируйте его (там же можно
заменить ключ без облака); либо
* **Настроить заново** (Reconfigure) — повторный вход в облако FGLair
обновит ключ; либо
* `python -m pyfglair discover --region eu --email you@example.com * `python -m pyfglair discover --region eu --email you@example.com
--format esphome-secrets` на хосте HA. --format esphome-secrets` на хосте HA.
Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 B

+43 -1
View File
@@ -8,7 +8,12 @@ from functools import partial
from typing import Any from typing import Any
import voluptuous as vol 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 from homeassistant.helpers import selector
import pyfglair import pyfglair
@@ -547,3 +552,40 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._reconfigure_entry, title=title, data=data self._reconfigure_entry, title=title, data=data
) )
return self.async_create_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, "")),
},
)
+2 -1
View File
@@ -2,7 +2,8 @@
Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в
issues). Полный ключ для secrets.yaml ESPHome берётся явным действием: issues). Полный ключ для secrets.yaml ESPHome берётся явным действием:
«Настроить заново» (reconfigure) на карточке интеграции или CLI «Настройка» (шестерёнка, options flow) на карточке интеграции —
одноимённое поле содержит полный ключ; либо CLI
``python -m pyfglair discover`` на хосте HA. ``python -m pyfglair discover`` на хосте HA.
""" """
from __future__ import annotations from __future__ import annotations
+11
View File
@@ -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"
}
}
}
} }
} }
@@ -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"
}
}
}
} }
} }
@@ -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"
}
}
}
} }
} }
+8 -4
View File
@@ -118,8 +118,9 @@ asyncio → push-coordinator. Состояния: `online` → available;
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
реальными скриншотами; рядом с каждой — описание что должно быть видно): реальными скриншотами; рядом с каждой — описание что должно быть видно):
1. **Установка через HACS**: 1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не
* HACS → ⋮ → Custom repositories → URL репозитория, категория поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`):
* HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория
Integration → Add. Скриншот: диалог добавления custom repository с Integration → Add. Скриншот: диалог добавления custom repository с
заполненным URL и выбранной категорией Integration. заполненным URL и выбранной категорией Integration.
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки * FGLair → Download → перезапуск HA. Скриншот: страница загрузки
@@ -152,8 +153,11 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт (OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
скрипту (`--ha-url`, `--ha-token`). скрипту (`--ha-url`, `--ha-token`).
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long` Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ (24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт дописывается
открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP). по ходу). Скрипт НЕ открывает собственную сессию к кондиционеру (оба слота
заняты HA+ESP). ESPHome-канал (опционально, `--esphome-host`): через native
API отправляются шаги «режим» и «уставка», проверка — по состоянию в HA
(сквозная цепочка ESPHome → модуль → HA); fan/swing — только со стороны HA.
## 8. Этапы ## 8. Этапы
+57 -33
View File
@@ -1,55 +1,79 @@
# Чек-лист релиза HA-интеграции (H5) # Чек-лист релиза HA-интеграции (H5)
## 1. Публикация pyfglair ## 1. Публикация pyfglair (PyPI)
Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA Манифест компонента требует `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 ```sh
# на каждой целевой архитектуре (linux x86_64/aarch64): pip install build auditwheel twine
.venv/bin/python -m build --wheel # py3-none-linux_<arch>.whl с .so и mbedtls
.venv/bin/python -m build --sdist # sdist с C++-исходниками (MANIFEST.in) # для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере
# (например, quay.io/pypa/manylinux2014_<arch>):
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/* 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; * `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11;
* `readelf -d pyfglair/libfgl-aircon.so*` — нет внешней `libmbedcrypto` * `readelf -d build-pyfglair/libfgl-aircon.so*` (или распакованный wheel:
(mbedtls встроен); `pyfglair/libfgl-aircon.so*`) — нет внешней `libmbedcrypto` (встроен);
* `auditwheel show dist/pyfglair-*.whl` — платформа совместима с manylinux * `auditwheel show` для repaired-wheel — тег manylinux и только
(зависимости: libstdc++/libc); базовые системные библиотеки;
* установка wheel в чистый venv вне репозитория: `import pyfglair.session`. * установка wheel в чистый venv вне репозитория:
`python -c "import pyfglair.session"`;
* aarch64: сборка в соответствующем manylinux-контейнере (QEMU или
нативная), иначе пользователи ARM не смогут поставить `pyfglair`.
При невозможности публикации wheel для какой-то архитектуры в манифесте Альтернатива — cibuildwheel (`pip install cibuildwheel`) с `auditwheel
можно указать прямую ссылку на wheel, но это лишает HACS-пользователей repair` внутри, но текущий `bdist_wheel`-хук отдаёт тег
других архитектур; предпочтителен PyPI. `py3-none-<plat>`, поэтому проверяйте итоговые теги вручную.
## 2. HACS ## 2. GitHub-зеркало и HACS
* Репозиторий содержит `custom_components/fglair/` и `hacs.json` (name). HACS устанавливает интеграции только с публичных **GitHub**-репозиториев
* Заполнить `codeowners` в `custom_components/fglair/manifest.json` (GitLab/Gitea не поддерживаются). Шаги:
(GitHub-хендлы владельцев; сейчас пустой список — допустимо для custom
repository, но не для выкладки в HACS по умолчанию). * создать GitHub-зеркало (push mirror) и убедиться, что оно содержит
* Проверить `documentation`/`issue_tracker` в манифесте (актуальные URL). `custom_components/fglair/`, `hacs.json`, тег релиза;
* Заменить заглушки `custom_components/fglair/screenshots/step-N.png` * заполнить `codeowners` в `custom_components/fglair/manifest.json`
реальными скриншотами (описания «что должно быть видно» — в README). (GitHub-хендлы; сейчас пустой список);
* Добавить brand-ассеты (`custom_components/fglair/brand/icon.png`, * проверить `documentation`/`issue_tracker` в манифесте;
`logo.png`) при выкладке в HACS. * обновить `version` манифеста под номер тега (сейчас `0.1.0`, pyfglair —
* Создать git-тег (semver) и указать его в HACS-релизе. `1.0.0`; синхронизируйте по вкусу — компонент и пакет версионируются
отдельно);
* заменить заглушки `custom_components/fglair/screenshots/step-N.png`
реальными скриншотами (описания «что должно быть видно» — в README);
* заменить заглушки `custom_components/fglair/brand/{icon,logo}.png`
фирменными ассетами;
* создать git-тег (semver) в GitHub-зеркале — HACS покажет версию.
## 3. Приёмка на живом стенде ## 3. Приёмка на живом стенде
* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений * `tests/acceptance/test_esphome_ha.py quick` — матрица изменений (HA;
(HA; при наличии ESPHome-компонента — обе стороны), с возвратом. с `--esphome-host` шаги «режим/уставка» идут через ESPHome, проверка — по
* `--long --hours 24` — суточный прогон с CSV-отчётом. состоянию в HA), возврат к исходному.
* Проверить сценарии: 503 (два слота заняты), key_error и Repair, * `long --hours 24 --interval 3600 --report acceptance.csv` — суточный
offline/восстановление, несколько устройств (разные порты прослушивания), прогон, отчёт дописывается по ходу.
reconfigure без потери конверсий. * Сценарии: 503 (два слота заняты), key_error + Repair, offline и
восстановление, два устройства (разные порты прослушивания), options flow
(копирование/смена ключа), reconfigure без потери конверсий.
## 4. Ограничения текущего стенда ## 4. Ограничения текущего стенда
* Реального железа/облака в CI нет: протокол проверен mock-модулем * Реального железа/облака в CI нет: протокол проверен mock-модулем
(`tests/ayla/mock_ac.py`), облако — мок-сервером. (`tests/ayla/mock_ac.py`), облако — мок-сервером; приёмочный скрипт имеет
* Суточный soak и работа с реальными re-key — только на приборе. self-test на моке HA REST.
* Суточный soak, re-key и облачный вход — только на приборе/аккаунте.
* ESPHome-канал приёмки требует установленного `aioesphomeapi` и
работающего ESPHome-компонента (PLAN_ESPHOME); без них скрипт работает
только со стороны HA.
+1
View File
@@ -1,4 +1,5 @@
{ {
"name": "FGLair", "name": "FGLair",
"homeassistant": "2025.1.0",
"render_readme": true "render_readme": true
} }
+145 -37
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import json import json
import pathlib import pathlib
import socket
import sys import sys
import threading import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest import pytest
@@ -14,24 +15,21 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_esphome_ha import ( # noqa: E402 from test_esphome_ha import ( # noqa: E402
AcceptanceError, AcceptanceError,
EsphomeChannel,
HaRest, HaRest,
Step,
build_steps, build_steps,
main, 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: class MockHa:
def __init__(self) -> None: def __init__(self) -> None:
self.lock = threading.Lock() 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 = { self.state = {
"entity_id": "climate.test", "entity_id": "climate.test",
"state": "heat", "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): def handler(self):
mock = self mock = self
@@ -66,31 +75,35 @@ class MockHa:
self.close_connection = True self.close_connection = True
self.wfile.write(body) 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): def do_GET(self):
if self.path == "/api/states/climate.test": 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"}) self._json(404, {"message": "not found"})
return
with mock.lock:
if mock.non_json:
self._raw(200, b"<html>captive portal</html>")
return
self._json(200, json.loads(json.dumps(mock.state)))
def do_POST(self): def do_POST(self):
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
data = json.loads(self.rfile.read(length) or b"{}") data = json.loads(self.rfile.read(length) or b"{}")
with mock.lock: with mock.lock:
if mock.fail_next: mock.post_count += 1
mock.fail_next = False if mock.fail_at and mock.post_count == mock.fail_at:
self._json(500, {"message": "boom"}) self._json(500, {"message": "boom"})
return return
service = self.path.rsplit("/", 1)[-1] if not mock.ignore_services:
attrs = mock.state["attributes"] mock.apply(self.path.rsplit("/", 1)[-1], data)
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, []) self._json(200, [])
return Handler return Handler
@@ -109,6 +122,9 @@ def mock_ha():
thread.join(timeout=5) thread.join(timeout=5)
QUICK_ARGS = ["quick", "--ha-token", "t", "--entity", "climate.test"]
def test_build_steps_supported_only(): def test_build_steps_supported_only():
state = { state = {
"state": "heat", "state": "heat",
@@ -120,33 +136,59 @@ def test_build_steps_supported_only():
"max_temp": 30.0, "max_temp": 30.0,
}, },
} }
steps = build_steps(state) assert [step.name for step in build_steps(state)] == ["temperature"]
assert [step.name for step in steps] == ["temperature"]
def test_quick_success(mock_ha, capsys): def test_quick_success(mock_ha, capsys, tmp_path):
_, url = mock_ha _, url = mock_ha
assert main(["quick", "--ha-url", url, "--ha-token", "t", report = tmp_path / "quick.csv"
"--entity", "climate.test"]) == 0 assert main(
[*QUICK_ARGS, "--ha-url", url, "--report", str(report)]
) == 0
out = capsys.readouterr().out out = capsys.readouterr().out
assert "restore: expected=heat observed=heat" in out assert "restore: expected=heat observed=heat" in out
assert "ok" in report.read_text()
def test_quick_burst_success(mock_ha): def test_quick_burst_success(mock_ha):
_, url = mock_ha _, url = mock_ha
assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t", assert main([*QUICK_ARGS, "--burst", "--ha-url", url]) == 0
"--entity", "climate.test"]) == 0
def test_quick_service_error(mock_ha, capsys): def test_quick_service_error(mock_ha, capsys):
mock, url = mock_ha mock, url = mock_ha
mock.fail_next = True mock.fail_at = 1
assert main(["quick", "--ha-url", url, "--ha-token", "t", assert main([*QUICK_ARGS, "--ha-url", url]) == 2
"--entity", "climate.test"]) == 2
assert "HTTP 500" in capsys.readouterr().err 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 _, url = mock_ha
report = tmp_path / "acceptance.csv" report = tmp_path / "acceptance.csv"
rc = main([ 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 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): def test_wait_state_timeout(mock_ha):
_, url = mock_ha _, url = mock_ha
ha = HaRest(url, "t", timeout=1) ha = HaRest(url, "t", timeout=1)
with pytest.raises(AcceptanceError): with pytest.raises(AcceptanceError):
ha.wait_state("climate.test", lambda s: False, timeout=0.2) 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
+237 -77
View File
@@ -10,23 +10,28 @@ HA + ESPHome); работает через REST API Home Assistant по long-liv
python tests/acceptance/test_esphome_ha.py quick \ python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \ --ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac --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 \ --ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv --hours 24 --interval 3600 --report acceptance.csv
Режимы: Режимы:
quick — матрица изменений (hvac/fan/уставка/swing), последовательно и quick — матрица изменений (hvac/fan/уставка/swing), последовательно и
«burst» (без пауз), каждое с возвратом к исходному состоянию; «burst» (без пауз), каждое действие проверяется по состоянию в
--long — одно изменение в --interval секунд (по умолчанию 3600) на HA и завершается возвратом к исходному состоянию;
протяжении --hours часов, проверка и возврат, CSV-отчёт. long — одно изменение в --interval секунд (по умолчанию 3600) на
протяжении --hours часов, проверка и возврат, CSV-отчёт
дописывается по ходу.
ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity
и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome, и установленный `aioesphomeapi` — шаги «режим» и «уставка» отправляются
результат сверяется по состоянию в HA. через ESPHome native API, результат сверяется по состоянию в HA (то есть
проверяется сквозная цепочка ESPHome → модуль → HA). Fan/swing через
ESPHome в этой версии не отправляются.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio
import csv import csv
import datetime as dt import datetime as dt
import json import json
@@ -64,7 +69,12 @@ class HaRest:
try: try:
with urllib.request.urlopen(request, timeout=self.timeout) as resp: with urllib.request.urlopen(request, timeout=self.timeout) as resp:
body = resp.read().decode() or "null" body = resp.read().decode() or "null"
try:
return resp.status, json.loads(body) 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: except urllib.error.HTTPError as err:
try: try:
body = err.read().decode(errors="replace") 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 @dataclass
class Step: class Step:
name: str name: str
@@ -117,7 +200,8 @@ class Step:
def check(state: dict) -> bool: def check(state: dict) -> bool:
if self.attribute == "state": if self.attribute == "state":
return state.get("state") == self.expected 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 return check
@@ -130,12 +214,26 @@ def _pick(options: list, preferred: str) -> Optional[str]:
return options[0] 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]: def build_steps(state: dict) -> list[Step]:
"""Адаптивная матрица: только поддерживаемые режимы/диапазоны.""" """Адаптивная матрица: только поддерживаемые режимы/диапазоны."""
attrs = state.get("attributes", {}) attrs = state.get("attributes", {})
steps: list[Step] = [] 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") mode = _pick(hvac_modes, "cool")
if mode: if mode:
steps.append( steps.append(
@@ -171,14 +269,33 @@ def build_steps(state: dict) -> list[Step]:
return steps 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", {}) attrs = original.get("attributes", {})
state = original.get("state") state = original.get("state")
if state == "off" and "off" in attrs.get("hvac_modes", []): if state == "off" and "off" in attrs.get("hvac_modes", []):
ha.call("climate", "set_hvac_mode", ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": "off"}) {"entity_id": entity_id, "hvac_mode": "off"})
return elif state and state != "off":
if state and state != "off":
ha.call("climate", "set_hvac_mode", ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": state}) {"entity_id": entity_id, "hvac_mode": state})
if attrs.get("fan_mode") is not None: 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: if attrs.get("swing_mode") is not None:
ha.call("climate", "set_swing_mode", ha.call("climate", "set_swing_mode",
{"entity_id": entity_id, "swing_mode": attrs["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: def _apply_step(
if state is None: ha: HaRest, esphome: Optional[EsphomeChannel], entity_id: str, step: Step
return None ) -> None:
if step.attribute == "state": if esphome is not None and esphome.apply(step):
return state.get("state") return
return state.get("attributes", {}).get(step.attribute) ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
def _matches(observed: Any, expected: Any) -> bool: def _write_report(
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)): path: str, rows: list[list], write_header: bool = True
return abs(float(observed) - float(expected)) < 0.11 ) -> None:
return observed == expected 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) original = ha.state(entity_id)
steps = build_steps(original) steps = build_steps(original)
if not steps: if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага") raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
results = [] results: list[dict] = []
if burst: try:
if burst and esphome is None:
for step in steps: for step in steps:
ha.call(step.domain, step.service, _apply_step(ha, esphome, entity_id, step)
{"entity_id": entity_id, **step.service_data})
for step in steps: for step in steps:
state = ha.wait_state(entity_id, step.predicate()) try:
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({ results.append({
"step": step.name, "step": step.name,
"expected": step.expected, "expected": step.expected,
"observed": _observed(state, step), "observed": observed,
}) })
else: else:
for step in steps: for step in steps:
ha.call(step.domain, step.service, _apply_step(ha, esphome, entity_id, step)
{"entity_id": entity_id, **step.service_data}) try:
state = ha.wait_state(entity_id, step.predicate()) state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({ results.append({
"step": step.name, "step": step.name,
"expected": step.expected, "expected": step.expected,
"observed": _observed(state, step), "observed": observed,
}) })
_restore(ha, entity_id, original, entity_id) finally:
time.sleep(1.0) _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) restored = ha.state(entity_id)
results.append( results.append({
{
"step": "restore", "step": "restore",
"expected": original.get("state"), "expected": original.get("state"),
"observed": restored.get("state"), "observed": restored.get("state"),
} })
)
return results return results
@@ -253,6 +401,8 @@ def run_long(
hours: float, hours: float,
interval: float, interval: float,
report_path: Optional[str], report_path: Optional[str],
esphome: Optional[EsphomeChannel] = None,
settle_timeout: float = SETTLE_TIMEOUT,
) -> tuple[int, int]: ) -> tuple[int, int]:
original = ha.state(entity_id) original = ha.state(entity_id)
steps = build_steps(original) steps = build_steps(original)
@@ -260,53 +410,38 @@ def run_long(
raise AcceptanceError("climate-сущность не поддерживает ни одного шага") raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
iterations = max(1, int(hours * 3600 / interval)) iterations = max(1, int(hours * 3600 / interval))
ok = failed = 0 ok = failed = 0
rows = [] rows: list[list] = []
for index in range(iterations): for index in range(iterations):
step = steps[index % len(steps)] step = steps[index % len(steps)]
timestamp = dt.datetime.now(dt.timezone.utc).isoformat() timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
try: try:
ha.call(step.domain, step.service, _apply_step(ha, esphome, entity_id, step)
{"entity_id": entity_id, **step.service_data}) state = ha.wait_state(
state = ha.wait_state(entity_id, step.predicate()) entity_id, step.predicate(), settle_timeout
observed = (
state.get("state")
if step.attribute == "state"
else state.get("attributes", {}).get(step.attribute)
) )
passed = observed == step.expected observed = _observed(state, step)
_restore(ha, entity_id, original, entity_id) passed = _matches(observed, step.expected)
time.sleep(1.0)
except AcceptanceError as err: except AcceptanceError as err:
printed = str(err) observed = str(err)
passed = False
finally:
try:
_restore(ha, entity_id, original)
except AcceptanceError as err:
observed = f"restore failed: {err}"
passed = False passed = False
observed = printed
ok += 1 if passed else 0 ok += 1 if passed else 0
failed += 0 if passed else 1 failed += 0 if passed else 1
rows.append([timestamp, step.name, step.expected, observed, row = [timestamp, step.name, step.expected, observed,
"ok" if passed else "fail"]) "ok" if passed else "fail"]
rows.append(row)
if report_path:
_write_report(report_path, [row], write_header=index == 0)
if index + 1 < iterations: if index + 1 < iterations:
time.sleep(interval) 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 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: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("mode", nargs="?", choices=["quick", "long"], parser.add_argument("mode", nargs="?", choices=["quick", "long"],
@@ -318,26 +453,39 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("--entity", required=True, parser.add_argument("--entity", required=True,
help="entity_id climate-сущности FGLair") help="entity_id climate-сущности FGLair")
parser.add_argument("--burst", action="store_true", parser.add_argument("--burst", action="store_true",
help="quick: без пауз между изменениями") help="quick: без пауз между изменениями (без ESPHome)")
parser.add_argument("--hours", type=float, default=24.0) parser.add_argument("--hours", type=float, default=24.0)
parser.add_argument("--interval", type=float, default=3600.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("--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-host")
parser.add_argument("--esphome-key", default="") parser.add_argument("--esphome-key", default="")
parser.add_argument("--esphome-entity")
return parser 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: def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv) args = _build_parser().parse_args(argv)
if args.long_mode: if args.long_mode:
args.mode = "long" args.mode = "long"
esphome: Optional[EsphomeChannel] = None
try: try:
_esphome_client(args) esphome = _make_esphome(args)
ha = HaRest(args.ha_url, args.ha_token, args.timeout) ha = HaRest(args.ha_url, args.ha_token, args.timeout)
if args.mode == "quick": 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 = [ failed = [
row for row in results row for row in results
if not _matches(row.get("observed"), row.get("expected")) 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: for row in results:
print(f"{row['step']}: expected={row['expected']} " print(f"{row['step']}: expected={row['expected']} "
f"observed={row.get('observed', '-')}") 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: if failed:
print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr) print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr)
return 1 return 1
@@ -352,7 +508,8 @@ def main(argv: Optional[list[str]] = None) -> int:
ok, failed = run_long( ok, failed = run_long(
ha, args.entity, ha, args.entity,
hours=args.hours, interval=args.interval, 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}" print(f"long: ok={ok} fail={failed}"
+ (f", отчёт: {args.report}" if args.report else "")) + (f", отчёт: {args.report}" if args.report else ""))
@@ -360,6 +517,9 @@ def main(argv: Optional[list[str]] = None) -> int:
except AcceptanceError as err: except AcceptanceError as err:
print(f"приёмка: {err}", file=sys.stderr) print(f"приёмка: {err}", file=sys.stderr)
return 2 return 2
finally:
if esphome is not None:
esphome.close()
if __name__ == "__main__": if __name__ == "__main__":
@@ -479,3 +479,44 @@ async def test_reconfigure_wrong_device(hass: HomeAssistant):
) )
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "unique_id_mismatch" 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)