ha(H5): README+HACS, скрипт приёмки, релизный чек-лист
- custom_components/fglair/README.md: HACS-установка, cloud/ручной/импорт, превью шаблона, сущности, ключ для ESPHome (reconfigure/CLI), диагностика и troubleshoot; заглушки screenshots/step-1..8.png (сгенерированы) - tests/acceptance/test_esphome_ha.py: quick/burst/long через REST API HA (long-lived token), адаптивная матрица по capabilities, возврат исходного состояния, CSV-отчёт long, опциональный ESPHome-канал (aioesphomeapi) - self-test приёмки на моке HA REST (6 тестов, без железа) - hacs.json; README: раздел Home Assistant; docs/RELEASE_HA.md: публикация pyfglair на PyPI (wheel/sdist, auditwheel), HACS-релиз, приёмка - scripts/py-ci.sh: tests/acceptance; план H5 отмечен
@@ -15,7 +15,7 @@ src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop
|
||||
src/aircon/ property templates A/B/F, conversions, API implementation
|
||||
pyfglair/ Python bindings (cffi) for Home Assistant (docs/PLAN_HOME_ASSISTANT.md)
|
||||
components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md)
|
||||
custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md)
|
||||
custom_components/ Home Assistant custom component (docs/PLAN_HOME_ASSISTANT.md)
|
||||
tests/ ayla (protocol), aircon (conversions), pyfglair, tools
|
||||
examples/cli/ fglctl — sample CLI
|
||||
tools/ fglair-discover, protocol probes
|
||||
@@ -93,6 +93,14 @@ python -m pyfglair monitor --config config_home.json --duration 60
|
||||
Тесты (без железа: mock-модуль + мок-облако):
|
||||
`scripts/py-ci.sh --setup && scripts/py-ci.sh` (или `pytest tests/pyfglair`).
|
||||
|
||||
## Home Assistant
|
||||
|
||||
Компонент `custom_components/fglair/` (HACS custom repository) — climate,
|
||||
switch/select/sensor/binary_sensor, config flow с превью шаблона, repair
|
||||
для несовпадения ключа и диагностика. Инструкция:
|
||||
[`custom_components/fglair/README.md`](custom_components/fglair/README.md);
|
||||
приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`.
|
||||
|
||||
## Getting the key (`lanip_key`)
|
||||
|
||||
The key is static (baked into the AC module; only served by the Ayla cloud):
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# FGLair — интеграция Home Assistant
|
||||
|
||||
Локальное управление кондиционерами Fujitsu General (FGLair / Ayla LAN)
|
||||
без облака в рантайме. Вся протокольная логика — в C++-ядре
|
||||
[`fgl-aircon`](../../README.md) через python-пакет `pyfglair` (cffi).
|
||||
|
||||
> Скриншоты `screenshots/step-N.png` — заглушки-плейсхолдеры: владелец
|
||||
> заменит их реальными снимками, описания «что должно быть видно» даны
|
||||
> рядом с каждым шагом.
|
||||
|
||||
## Требования
|
||||
|
||||
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
|
||||
* Пакет `pyfglair` (манифест ставит его автоматически из PyPI).
|
||||
До публикации на PyPI установите wheel вручную в python-окружение HA:
|
||||
`pip install pyfglair-*.whl` (см. `docs/RELEASE_HA.md`).
|
||||
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
|
||||
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
|
||||
|
||||
## Установка через HACS
|
||||
|
||||
1. HACS → ⋮ → **Custom repositories** → URL репозитория, категория
|
||||
**Integration** → **Add**.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: диалог добавления custom repository с заполненным URL
|
||||
и выбранной категорией Integration.*
|
||||
|
||||
2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница загрузки интеграции с кнопкой Download и
|
||||
версией.*
|
||||
|
||||
## Добавление устройства
|
||||
|
||||
3. Settings → Devices & Services → **Add Integration** → «FGLair».
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: диалог поиска интеграции с введённым «FGLair» и
|
||||
выделенным результатом.*
|
||||
|
||||
4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако
|
||||
используется один раз — получить статический LAN-ключ модуля.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: форма с заполненными регионом EU и e-mail, пароль
|
||||
скрыт.*
|
||||
|
||||
5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP).
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: список с одним устройством (имя, модель, host).*
|
||||
|
||||
6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных
|
||||
значений (режимы, диапазон температур, пример конверсии) и, при
|
||||
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
|
||||
приложением FGLair и подтвердите.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг,
|
||||
текущая температура, capabilities, кнопки Submit.*
|
||||
|
||||
7. **Готово**: карточка устройства с сущностями climate/switch/select/
|
||||
sensor/binary_sensor.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница устройства с созданными сущностями.*
|
||||
|
||||
## Где взять ключ для ESPHome
|
||||
|
||||
Download diagnostics на странице устройства показывает `dsn`,
|
||||
`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для
|
||||
`secrets.yaml` ESPHome) получите явным действием:
|
||||
|
||||
* **Настроить заново** (Reconfigure) на карточке интеграции — вход в
|
||||
облако FGLair покажет/сохранит актуальный ключ; либо
|
||||
* `python -m pyfglair discover --region eu --email you@example.com
|
||||
--format esphome-secrets` на хосте HA.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница Diagnostics с полями dsn/lanip_key_id/host
|
||||
и маской lanip_key.*
|
||||
|
||||
Альтернатива без облака — ручной ввод параметров (host, dsn, lanip_key,
|
||||
lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl`.
|
||||
|
||||
## Сущности
|
||||
|
||||
| Платформа | Что создаётся |
|
||||
|---|---|
|
||||
| climate | режим (off/cool/dry/fan/heat/auto), скорость, swing (верт./гориз.), preset ECO/BOOST, уставка, текущая температура |
|
||||
| switch | economy, powerful, coil dry, min heat, outdoor low noise, human det auto save, Wi-Fi LED, indoor fan control (по шаблону и capabilities) |
|
||||
| select | положение вертикальной/горизонтальной заслонки (0…N−1) |
|
||||
| sensor | температура в помещении, код ошибки, диагностическое состояние связи |
|
||||
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) |
|
||||
|
||||
Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`;
|
||||
режимы, которыми прибор не управляет, не создаются.
|
||||
|
||||
## Диагностика и известные ситуации
|
||||
|
||||
* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome).
|
||||
Освободите один слот и перезапустите интеграцию.
|
||||
* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится
|
||||
Repair; нажмите **Настроить заново** и повторите вход в облако (или
|
||||
вставьте новый `config_*.json`).
|
||||
* **unavailable** — модуль недоступен (сеть/питание) или нет слотов;
|
||||
диагностический сенсор «Состояние связи» показывает детали
|
||||
(idle/registering/online/recovering/offline/key_error).
|
||||
* Записи не эхо-подтверждаются модулем: состояние обновляется
|
||||
оптимистично и подтверждается push-обновлением.
|
||||
|
||||
## Приёмка на живом стенде
|
||||
|
||||
`tests/acceptance/test_esphome_ha.py` (stdlib) прогоняет матрицу изменений
|
||||
через REST API HA по long-lived token, с возвратом к исходному состоянию:
|
||||
|
||||
```sh
|
||||
python tests/acceptance/test_esphome_ha.py quick \
|
||||
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
|
||||
--entity climate.ac
|
||||
python tests/acceptance/test_esphome_ha.py long \
|
||||
--ha-url ... --ha-token ... --entity climate.ac \
|
||||
--hours 24 --interval 3600 --report acceptance.csv
|
||||
```
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -163,4 +163,4 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
||||
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations |
|
||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда |
|
||||
| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Чек-лист релиза HA-интеграции (H5)
|
||||
|
||||
## 1. Публикация pyfglair
|
||||
|
||||
Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA
|
||||
резолвит требования только через PyPI.
|
||||
|
||||
```sh
|
||||
# на каждой целевой архитектуре (linux x86_64/aarch64):
|
||||
.venv/bin/python -m build --wheel # py3-none-linux_<arch>.whl с .so и mbedtls
|
||||
.venv/bin/python -m build --sdist # sdist с C++-исходниками (MANIFEST.in)
|
||||
twine upload dist/*
|
||||
```
|
||||
|
||||
Проверки перед публикацией:
|
||||
|
||||
* `scripts/py-ci.sh` — 28 pyfglair + 47 components + 6 acceptance self-test;
|
||||
* `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11;
|
||||
* `readelf -d pyfglair/libfgl-aircon.so*` — нет внешней `libmbedcrypto`
|
||||
(mbedtls встроен);
|
||||
* `auditwheel show dist/pyfglair-*.whl` — платформа совместима с manylinux
|
||||
(зависимости: libstdc++/libc);
|
||||
* установка wheel в чистый venv вне репозитория: `import pyfglair.session`.
|
||||
|
||||
При невозможности публикации wheel для какой-то архитектуры в манифесте
|
||||
можно указать прямую ссылку на wheel, но это лишает HACS-пользователей
|
||||
других архитектур; предпочтителен PyPI.
|
||||
|
||||
## 2. HACS
|
||||
|
||||
* Репозиторий содержит `custom_components/fglair/` и `hacs.json` (name).
|
||||
* Заполнить `codeowners` в `custom_components/fglair/manifest.json`
|
||||
(GitHub-хендлы владельцев; сейчас пустой список — допустимо для custom
|
||||
repository, но не для выкладки в HACS по умолчанию).
|
||||
* Проверить `documentation`/`issue_tracker` в манифесте (актуальные URL).
|
||||
* Заменить заглушки `custom_components/fglair/screenshots/step-N.png`
|
||||
реальными скриншотами (описания «что должно быть видно» — в README).
|
||||
* Добавить brand-ассеты (`custom_components/fglair/brand/icon.png`,
|
||||
`logo.png`) при выкладке в HACS.
|
||||
* Создать git-тег (semver) и указать его в HACS-релизе.
|
||||
|
||||
## 3. Приёмка на живом стенде
|
||||
|
||||
* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений
|
||||
(HA; при наличии ESPHome-компонента — обе стороны), с возвратом.
|
||||
* `--long --hours 24` — суточный прогон с CSV-отчётом.
|
||||
* Проверить сценарии: 503 (два слота заняты), key_error и Repair,
|
||||
offline/восстановление, несколько устройств (разные порты прослушивания),
|
||||
reconfigure без потери конверсий.
|
||||
|
||||
## 4. Ограничения текущего стенда
|
||||
|
||||
* Реального железа/облака в CI нет: протокол проверен mock-модулем
|
||||
(`tests/ayla/mock_ac.py`), облако — мок-сервером.
|
||||
* Суточный soak и работа с реальными re-key — только на приборе.
|
||||
@@ -27,7 +27,7 @@ if [ ! -x "$VENV/bin/python" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$VENV/bin/python" -m pytest tests/pyfglair -q
|
||||
"$VENV/bin/python" -m pytest tests/pyfglair tests/acceptance -q
|
||||
|
||||
if [ -x "$HA_VENV/bin/python" ]; then
|
||||
"$HA_VENV/bin/python" -m pytest tests/components -q
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Self-test приёмочного скрипта на моке HA REST API (без железа)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||||
|
||||
from test_esphome_ha import ( # noqa: E402
|
||||
AcceptanceError,
|
||||
HaRest,
|
||||
build_steps,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
class MockHa:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.fail_next = False
|
||||
self.state = {
|
||||
"entity_id": "climate.test",
|
||||
"state": "heat",
|
||||
"attributes": {
|
||||
"hvac_modes": ["off", "cool", "heat"],
|
||||
"fan_modes": ["low", "high"],
|
||||
"swing_modes": ["off", "on"],
|
||||
"temperature": 22.0,
|
||||
"min_temp": 16.0,
|
||||
"max_temp": 30.0,
|
||||
"fan_mode": "low",
|
||||
"swing_mode": "off",
|
||||
},
|
||||
}
|
||||
|
||||
def handler(self):
|
||||
mock = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def _json(self, status: int, payload) -> None:
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/states/climate.test":
|
||||
with mock.lock:
|
||||
self._json(200, json.loads(json.dumps(mock.state)))
|
||||
else:
|
||||
self._json(404, {"message": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
data = json.loads(self.rfile.read(length) or b"{}")
|
||||
with mock.lock:
|
||||
if mock.fail_next:
|
||||
mock.fail_next = False
|
||||
self._json(500, {"message": "boom"})
|
||||
return
|
||||
service = self.path.rsplit("/", 1)[-1]
|
||||
attrs = mock.state["attributes"]
|
||||
if service == "set_hvac_mode":
|
||||
mock.state["state"] = data["hvac_mode"]
|
||||
elif service == "set_fan_mode":
|
||||
attrs["fan_mode"] = data["fan_mode"]
|
||||
elif service == "set_swing_mode":
|
||||
attrs["swing_mode"] = data["swing_mode"]
|
||||
elif service == "set_temperature":
|
||||
attrs["temperature"] = data["temperature"]
|
||||
self._json(200, [])
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ha():
|
||||
mock = MockHa()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), mock.handler())
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
port = server.server_address[1]
|
||||
yield mock, f"http://127.0.0.1:{port}"
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_build_steps_supported_only():
|
||||
state = {
|
||||
"state": "heat",
|
||||
"attributes": {
|
||||
"hvac_modes": ["off"],
|
||||
"fan_modes": [],
|
||||
"temperature": 20.0,
|
||||
"min_temp": 16.0,
|
||||
"max_temp": 30.0,
|
||||
},
|
||||
}
|
||||
steps = build_steps(state)
|
||||
assert [step.name for step in steps] == ["temperature"]
|
||||
|
||||
|
||||
def test_quick_success(mock_ha, capsys):
|
||||
_, url = mock_ha
|
||||
assert main(["quick", "--ha-url", url, "--ha-token", "t",
|
||||
"--entity", "climate.test"]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "restore: expected=heat observed=heat" in out
|
||||
|
||||
|
||||
def test_quick_burst_success(mock_ha):
|
||||
_, url = mock_ha
|
||||
assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t",
|
||||
"--entity", "climate.test"]) == 0
|
||||
|
||||
|
||||
def test_quick_service_error(mock_ha, capsys):
|
||||
mock, url = mock_ha
|
||||
mock.fail_next = True
|
||||
assert main(["quick", "--ha-url", url, "--ha-token", "t",
|
||||
"--entity", "climate.test"]) == 2
|
||||
assert "HTTP 500" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_long_report(mock_ha, tmp_path, capsys):
|
||||
_, url = mock_ha
|
||||
report = tmp_path / "acceptance.csv"
|
||||
rc = main([
|
||||
"long", "--ha-url", url, "--ha-token", "t",
|
||||
"--entity", "climate.test",
|
||||
"--hours", "0.000003", "--interval", "0.01",
|
||||
"--report", str(report),
|
||||
])
|
||||
assert rc == 0
|
||||
text = report.read_text()
|
||||
assert "timestamp,step,expected,observed,result" in text
|
||||
assert "ok" in text
|
||||
assert "long: ok=1 fail=0" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_wait_state_timeout(mock_ha):
|
||||
_, url = mock_ha
|
||||
ha = HaRest(url, "t", timeout=1)
|
||||
with pytest.raises(AcceptanceError):
|
||||
ha.wait_state("climate.test", lambda s: False, timeout=0.2)
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Приёмка HA (и опционально ESPHome) на живом кондиционере.
|
||||
|
||||
Полуавтоматический скрипт: третью LAN-сессию НЕ открывает (оба слота заняты
|
||||
HA + ESPHome); работает через REST API Home Assistant по long-lived token.
|
||||
|
||||
Профиль → Security → Long-lived access tokens → создать токен.
|
||||
|
||||
Запуск:
|
||||
python tests/acceptance/test_esphome_ha.py quick \
|
||||
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
|
||||
--entity climate.ac
|
||||
python tests/acceptance/test_esphome_ha.py --long \
|
||||
--ha-url ... --ha-token ... --entity climate.ac \
|
||||
--hours 24 --interval 3600 --report acceptance.csv
|
||||
|
||||
Режимы:
|
||||
quick — матрица изменений (hvac/fan/уставка/swing), последовательно и
|
||||
«burst» (без пауз), каждое с возвратом к исходному состоянию;
|
||||
--long — одно изменение в --interval секунд (по умолчанию 3600) на
|
||||
протяжении --hours часов, проверка и возврат, CSV-отчёт.
|
||||
|
||||
ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity
|
||||
и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome,
|
||||
результат сверяется по состоянию в HA.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
DEFAULT_TIMEOUT = 15.0
|
||||
SETTLE_TIMEOUT = 30.0
|
||||
|
||||
|
||||
class AcceptanceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class HaRest:
|
||||
"""Минимальный клиент REST API Home Assistant (stdlib)."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, timeout: float = DEFAULT_TIMEOUT):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(self, method: str, path: str, payload: Any = None) -> Any:
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
self.base_url + path, data=data, headers=self.headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
|
||||
body = resp.read().decode() or "null"
|
||||
return resp.status, json.loads(body)
|
||||
except urllib.error.HTTPError as err:
|
||||
try:
|
||||
body = err.read().decode(errors="replace")
|
||||
finally:
|
||||
err.close()
|
||||
raise AcceptanceError(
|
||||
f"{method} {path}: HTTP {err.code}: {body[:200]}"
|
||||
) from err
|
||||
except OSError as err:
|
||||
raise AcceptanceError(f"{method} {path}: {err}") from err
|
||||
|
||||
def state(self, entity_id: str) -> dict:
|
||||
_, data = self._request("GET", f"/api/states/{entity_id}")
|
||||
if not isinstance(data, dict):
|
||||
raise AcceptanceError(f"{entity_id}: неожиданный ответ /api/states")
|
||||
return data
|
||||
|
||||
def call(self, domain: str, service: str, data: dict) -> None:
|
||||
self._request("POST", f"/api/services/{domain}/{service}", data)
|
||||
|
||||
def wait_state(
|
||||
self,
|
||||
entity_id: str,
|
||||
predicate: Callable[[dict], bool],
|
||||
timeout: float = SETTLE_TIMEOUT,
|
||||
) -> dict:
|
||||
deadline = time.monotonic() + timeout
|
||||
last: Optional[dict] = None
|
||||
while time.monotonic() < deadline:
|
||||
last = self.state(entity_id)
|
||||
if predicate(last):
|
||||
return last
|
||||
time.sleep(0.5)
|
||||
raise AcceptanceError(
|
||||
f"{entity_id}: состояние не достигнуто за {timeout} с: "
|
||||
f"{last and last.get('state')}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
name: str
|
||||
domain: str
|
||||
service: str
|
||||
service_data: dict
|
||||
attribute: str
|
||||
expected: Any
|
||||
|
||||
def predicate(self) -> Callable[[dict], bool]:
|
||||
def check(state: dict) -> bool:
|
||||
if self.attribute == "state":
|
||||
return state.get("state") == self.expected
|
||||
return state.get("attributes", {}).get(self.attribute) == self.expected
|
||||
|
||||
return check
|
||||
|
||||
|
||||
def _pick(options: list, preferred: str) -> Optional[str]:
|
||||
if not options:
|
||||
return None
|
||||
if preferred in options:
|
||||
return preferred
|
||||
return options[0]
|
||||
|
||||
|
||||
def build_steps(state: dict) -> list[Step]:
|
||||
"""Адаптивная матрица: только поддерживаемые режимы/диапазоны."""
|
||||
attrs = state.get("attributes", {})
|
||||
steps: list[Step] = []
|
||||
|
||||
hvac_modes = [m for m in attrs.get("hvac_modes", []) if m != "off"]
|
||||
mode = _pick(hvac_modes, "cool")
|
||||
if mode:
|
||||
steps.append(
|
||||
Step("hvac_mode", "climate", "set_hvac_mode",
|
||||
{"hvac_mode": mode}, "state", mode)
|
||||
)
|
||||
|
||||
fan_mode = _pick(attrs.get("fan_modes", []), "low")
|
||||
if fan_mode:
|
||||
steps.append(
|
||||
Step("fan_mode", "climate", "set_fan_mode",
|
||||
{"fan_mode": fan_mode}, "fan_mode", fan_mode)
|
||||
)
|
||||
|
||||
swing = _pick(attrs.get("swing_modes", []), "on")
|
||||
if swing:
|
||||
steps.append(
|
||||
Step("swing_mode", "climate", "set_swing_mode",
|
||||
{"swing_mode": swing}, "swing_mode", swing)
|
||||
)
|
||||
|
||||
temperature = attrs.get("temperature")
|
||||
low = attrs.get("min_temp", 16.0)
|
||||
high = attrs.get("max_temp", 30.0)
|
||||
if temperature is not None and low is not None and high is not None:
|
||||
target = temperature + 1.0
|
||||
if target > high:
|
||||
target = max(low, high - 1.0)
|
||||
steps.append(
|
||||
Step("temperature", "climate", "set_temperature",
|
||||
{"temperature": target}, "temperature", target)
|
||||
)
|
||||
return steps
|
||||
|
||||
|
||||
def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None:
|
||||
attrs = original.get("attributes", {})
|
||||
state = original.get("state")
|
||||
if state == "off" and "off" in attrs.get("hvac_modes", []):
|
||||
ha.call("climate", "set_hvac_mode",
|
||||
{"entity_id": entity_id, "hvac_mode": "off"})
|
||||
return
|
||||
if state and state != "off":
|
||||
ha.call("climate", "set_hvac_mode",
|
||||
{"entity_id": entity_id, "hvac_mode": state})
|
||||
if attrs.get("fan_mode") is not None:
|
||||
ha.call("climate", "set_fan_mode",
|
||||
{"entity_id": entity_id, "fan_mode": attrs["fan_mode"]})
|
||||
if attrs.get("temperature") is not None:
|
||||
ha.call("climate", "set_temperature",
|
||||
{"entity_id": entity_id, "temperature": attrs["temperature"]})
|
||||
if attrs.get("swing_mode") is not None:
|
||||
ha.call("climate", "set_swing_mode",
|
||||
{"entity_id": entity_id, "swing_mode": attrs["swing_mode"]})
|
||||
|
||||
|
||||
def _observed(state: Optional[dict], step: Step) -> Any:
|
||||
if state is None:
|
||||
return None
|
||||
if step.attribute == "state":
|
||||
return state.get("state")
|
||||
return state.get("attributes", {}).get(step.attribute)
|
||||
|
||||
|
||||
def _matches(observed: Any, expected: Any) -> bool:
|
||||
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)):
|
||||
return abs(float(observed) - float(expected)) < 0.11
|
||||
return observed == expected
|
||||
|
||||
|
||||
def run_quick(ha: HaRest, entity_id: str, *, burst: bool = False) -> list[dict]:
|
||||
original = ha.state(entity_id)
|
||||
steps = build_steps(original)
|
||||
if not steps:
|
||||
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
|
||||
results = []
|
||||
if burst:
|
||||
for step in steps:
|
||||
ha.call(step.domain, step.service,
|
||||
{"entity_id": entity_id, **step.service_data})
|
||||
for step in steps:
|
||||
state = ha.wait_state(entity_id, step.predicate())
|
||||
results.append({
|
||||
"step": step.name,
|
||||
"expected": step.expected,
|
||||
"observed": _observed(state, step),
|
||||
})
|
||||
else:
|
||||
for step in steps:
|
||||
ha.call(step.domain, step.service,
|
||||
{"entity_id": entity_id, **step.service_data})
|
||||
state = ha.wait_state(entity_id, step.predicate())
|
||||
results.append({
|
||||
"step": step.name,
|
||||
"expected": step.expected,
|
||||
"observed": _observed(state, step),
|
||||
})
|
||||
_restore(ha, entity_id, original, entity_id)
|
||||
time.sleep(1.0)
|
||||
restored = ha.state(entity_id)
|
||||
results.append(
|
||||
{
|
||||
"step": "restore",
|
||||
"expected": original.get("state"),
|
||||
"observed": restored.get("state"),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def run_long(
|
||||
ha: HaRest,
|
||||
entity_id: str,
|
||||
*,
|
||||
hours: float,
|
||||
interval: float,
|
||||
report_path: Optional[str],
|
||||
) -> tuple[int, int]:
|
||||
original = ha.state(entity_id)
|
||||
steps = build_steps(original)
|
||||
if not steps:
|
||||
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
|
||||
iterations = max(1, int(hours * 3600 / interval))
|
||||
ok = failed = 0
|
||||
rows = []
|
||||
for index in range(iterations):
|
||||
step = steps[index % len(steps)]
|
||||
timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
|
||||
try:
|
||||
ha.call(step.domain, step.service,
|
||||
{"entity_id": entity_id, **step.service_data})
|
||||
state = ha.wait_state(entity_id, step.predicate())
|
||||
observed = (
|
||||
state.get("state")
|
||||
if step.attribute == "state"
|
||||
else state.get("attributes", {}).get(step.attribute)
|
||||
)
|
||||
passed = observed == step.expected
|
||||
_restore(ha, entity_id, original, entity_id)
|
||||
time.sleep(1.0)
|
||||
except AcceptanceError as err:
|
||||
printed = str(err)
|
||||
passed = False
|
||||
observed = printed
|
||||
ok += 1 if passed else 0
|
||||
failed += 0 if passed else 1
|
||||
rows.append([timestamp, step.name, step.expected, observed,
|
||||
"ok" if passed else "fail"])
|
||||
if index + 1 < iterations:
|
||||
time.sleep(interval)
|
||||
if report_path:
|
||||
with open(report_path, "w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.writer(fh)
|
||||
writer.writerow(["timestamp", "step", "expected", "observed",
|
||||
"result"])
|
||||
writer.writerows(rows)
|
||||
return ok, failed
|
||||
|
||||
|
||||
def _esphome_client(args: argparse.Namespace):
|
||||
if not args.esphome_host:
|
||||
return None
|
||||
try:
|
||||
import aioesphomeapi # noqa: PLC0415
|
||||
except ImportError as err:
|
||||
raise AcceptanceError(
|
||||
"--esphome-host задан, но aioesphomeapi не установлен"
|
||||
) from err
|
||||
return aioesphomeapi, args
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("mode", nargs="?", choices=["quick", "long"],
|
||||
default="quick")
|
||||
parser.add_argument("--long", dest="long_mode", action="store_true",
|
||||
help="алиас режима long")
|
||||
parser.add_argument("--ha-url", required=True)
|
||||
parser.add_argument("--ha-token", required=True)
|
||||
parser.add_argument("--entity", required=True,
|
||||
help="entity_id climate-сущности FGLair")
|
||||
parser.add_argument("--burst", action="store_true",
|
||||
help="quick: без пауз между изменениями")
|
||||
parser.add_argument("--hours", type=float, default=24.0)
|
||||
parser.add_argument("--interval", type=float, default=3600.0)
|
||||
parser.add_argument("--report", help="CSV-отчёт режима long")
|
||||
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--esphome-host")
|
||||
parser.add_argument("--esphome-key", default="")
|
||||
parser.add_argument("--esphome-entity")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list[str]] = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
if args.long_mode:
|
||||
args.mode = "long"
|
||||
try:
|
||||
_esphome_client(args)
|
||||
ha = HaRest(args.ha_url, args.ha_token, args.timeout)
|
||||
if args.mode == "quick":
|
||||
results = run_quick(ha, args.entity, burst=args.burst)
|
||||
failed = [
|
||||
row for row in results
|
||||
if not _matches(row.get("observed"), row.get("expected"))
|
||||
]
|
||||
for row in results:
|
||||
print(f"{row['step']}: expected={row['expected']} "
|
||||
f"observed={row.get('observed', '-')}")
|
||||
if failed:
|
||||
print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
ok, failed = run_long(
|
||||
ha, args.entity,
|
||||
hours=args.hours, interval=args.interval,
|
||||
report_path=args.report,
|
||||
)
|
||||
print(f"long: ok={ok} fail={failed}"
|
||||
+ (f", отчёт: {args.report}" if args.report else ""))
|
||||
return 1 if failed else 0
|
||||
except AcceptanceError as err:
|
||||
print(f"приёмка: {err}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||