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 отмечен
This commit is contained in:
2026-09-29 14:42:41 +03:00
parent 4d0bbd1d8e
commit 8013781e09
16 changed files with 739 additions and 3 deletions
+9 -1
View File
@@ -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):
+134
View File
@@ -0,0 +1,134 @@
# FGLair — интеграция Home Assistant
Локальное управление кондиционерами Fujitsu General (FGLair / Ayla LAN)
без облака в рантайме. Вся протокольная логика — в C++-ядре
[`fgl-aircon`](../../README.md) через python-пакет `pyfglair` (cffi).
> Скриншоты `screenshots/step-N.png` — заглушки-плейсхолдеры: владелец
> заменит их реальными снимками, описания «что должно быть видно» даны
> рядом с каждым шагом.
## Требования
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
* Пакет `pyfglair` (манифест ставит его автоматически из PyPI).
До публикации на PyPI установите wheel вручную в python-окружение HA:
`pip install pyfglair-*.whl` (см. `docs/RELEASE_HA.md`).
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
## Установка через HACS
1. HACS → ⋮ → **Custom repositories** → URL репозитория, категория
**Integration** → **Add**.
![шаг 1](screenshots/step-1.png)
*Должно быть видно: диалог добавления custom repository с заполненным URL
и выбранной категорией Integration.*
2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant.
![шаг 2](screenshots/step-2.png)
*Должно быть видно: страница загрузки интеграции с кнопкой Download и
версией.*
## Добавление устройства
3. Settings → Devices & Services → **Add Integration** → «FGLair».
![шаг 3](screenshots/step-3.png)
*Должно быть видно: диалог поиска интеграции с введённым «FGLair» и
выделенным результатом.*
4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако
используется один раз — получить статический LAN-ключ модуля.
![шаг 4](screenshots/step-4.png)
*Должно быть видно: форма с заполненными регионом EU и e-mail, пароль
скрыт.*
5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP).
![шаг 5](screenshots/step-5.png)
*Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных
значений (режимы, диапазон температур, пример конверсии) и, при
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
приложением FGLair и подтвердите.
![шаг 6](screenshots/step-6.png)
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг,
текущая температура, capabilities, кнопки Submit.*
7. **Готово**: карточка устройства с сущностями climate/switch/select/
sensor/binary_sensor.
![шаг 7](screenshots/step-7.png)
*Должно быть видно: страница устройства с созданными сущностями.*
## Где взять ключ для ESPHome
Download diagnostics на странице устройства показывает `dsn`,
`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для
`secrets.yaml` ESPHome) получите явным действием:
* **Настроить заново** (Reconfigure) на карточке интеграции — вход в
облако FGLair покажет/сохранит актуальный ключ; либо
* `python -m pyfglair discover --region eu --email you@example.com
--format esphome-secrets` на хосте HA.
![шаг 8](screenshots/step-8.png)
*Должно быть видно: страница Diagnostics с полями dsn/lanip_key_id/host
и маской lanip_key.*
Альтернатива без облака — ручной ввод параметров (host, dsn, lanip_key,
lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl`.
## Сущности
| Платформа | Что создаётся |
|---|---|
| climate | режим (off/cool/dry/fan/heat/auto), скорость, swing (верт./гориз.), preset ECO/BOOST, уставка, текущая температура |
| switch | economy, powerful, coil dry, min heat, outdoor low noise, human det auto save, Wi-Fi LED, indoor fan control (по шаблону и capabilities) |
| select | положение вертикальной/горизонтальной заслонки (0…N−1) |
| sensor | температура в помещении, код ошибки, диагностическое состояние связи |
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) |
Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`;
режимы, которыми прибор не управляет, не создаются.
## Диагностика и известные ситуации
* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome).
Освободите один слот и перезапустите интеграцию.
* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится
Repair; нажмите **Настроить заново** и повторите вход в облако (или
вставьте новый `config_*.json`).
* **unavailable** — модуль недоступен (сеть/питание) или нет слотов;
диагностический сенсор «Состояние связи» показывает детали
(idle/registering/online/recovering/offline/key_error).
* Записи не эхо-подтверждаются модулем: состояние обновляется
оптимистично и подтверждается push-обновлением.
## Приёмка на живом стенде
`tests/acceptance/test_esphome_ha.py` (stdlib) прогоняет матрицу изменений
через REST API HA по long-lived token, с возвратом к исходному состоянию:
```sh
python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac
python tests/acceptance/test_esphome_ha.py long \
--ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -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` |
+55
View File
@@ -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 — только на приборе.
+4
View File
@@ -0,0 +1,4 @@
{
"name": "FGLair",
"render_readme": true
}
+1 -1
View File
@@ -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
+169
View File
@@ -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)
+366
View File
@@ -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())