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:
@@ -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`)
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||

|

|
||||||
@@ -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-ключ модуля.
|
||||||
|
|
||||||

|

|
||||||
@@ -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 |
@@ -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,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "FGLair",
|
"name": "FGLair",
|
||||||
|
"homeassistant": "2025.1.0",
|
||||||
"render_readme": true
|
"render_readme": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
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:
|
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:
|
||||||
for step in steps:
|
if burst and esphome is None:
|
||||||
ha.call(step.domain, step.service,
|
for step in steps:
|
||||||
{"entity_id": entity_id, **step.service_data})
|
_apply_step(ha, esphome, entity_id, step)
|
||||||
for step in steps:
|
for step in steps:
|
||||||
state = ha.wait_state(entity_id, step.predicate())
|
try:
|
||||||
results.append({
|
state = ha.wait_state(
|
||||||
"step": step.name,
|
entity_id, step.predicate(), settle_timeout
|
||||||
"expected": step.expected,
|
)
|
||||||
"observed": _observed(state, step),
|
observed = _observed(state, step)
|
||||||
})
|
except AcceptanceError as err:
|
||||||
else:
|
observed = str(err)
|
||||||
for step in steps:
|
results.append({
|
||||||
ha.call(step.domain, step.service,
|
"step": step.name,
|
||||||
{"entity_id": entity_id, **step.service_data})
|
"expected": step.expected,
|
||||||
state = ha.wait_state(entity_id, step.predicate())
|
"observed": observed,
|
||||||
results.append({
|
})
|
||||||
"step": step.name,
|
else:
|
||||||
"expected": step.expected,
|
for step in steps:
|
||||||
"observed": _observed(state, step),
|
_apply_step(ha, esphome, entity_id, step)
|
||||||
})
|
try:
|
||||||
_restore(ha, entity_id, original, entity_id)
|
state = ha.wait_state(
|
||||||
time.sleep(1.0)
|
entity_id, step.predicate(), settle_timeout
|
||||||
restored = ha.state(entity_id)
|
)
|
||||||
results.append(
|
observed = _observed(state, step)
|
||||||
{
|
except AcceptanceError as err:
|
||||||
"step": "restore",
|
observed = str(err)
|
||||||
"expected": original.get("state"),
|
results.append({
|
||||||
"observed": restored.get("state"),
|
"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
|
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
|
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
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user