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

- critical: README честно описывает HACS (только публичный GitHub-зеркало)
  + ручная установка копированием; RELEASE_HA — шаг зеркала и codeowners
- critical: ESPHome-канал приёмки реализован реально (aioesphomeapi:
  connect/list_entities/climate_command для режима и уставки; fan/swing —
  только HA); unit-тест на фейковом модуле
- major: quick/long восстанавливают исходное состояние в finally даже при
  сбое; quick ждёт восстановления; ошибки шага пишутся в результат (rc 1),
  а не фаталят (rc 2)
- major: RELEASE_HA — manylinux через auditwheel repair (PyPI отклоняет
  linux_x86_64), build/twine, aarch64, корректные проверки .so, тег/версия
- major: полный ключ для ESPHome теперь реально виден в options flow
  («Настройка» на карточке интеграции, поле LAN IP key, можно заменить) —
  README/diagnostics синхронизированы
- minor: non-JSON ответ → rc 2; CSV quick+long и инкрементальная запись;
  допуск _matches в long; --settle-timeout; hacs.json homeassistant=2025.1;
  brand/{icon,logo}.png заглушки; план §6/§7 уточнён
- тесты: acceptance self-test 11 (restore-after-failure, long fail, non-JSON,
  esphome channel), options flow (2), всего 28+11+49
This commit is contained in:
2026-09-29 14:54:50 +03:00
parent 8013781e09
commit a03458f9bc
15 changed files with 609 additions and 178 deletions
+145 -37
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import json
import pathlib
import socket
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
@@ -14,24 +15,21 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_esphome_ha import ( # noqa: E402
AcceptanceError,
EsphomeChannel,
HaRest,
Step,
build_steps,
main,
)
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
class MockHa:
def __init__(self) -> None:
self.lock = threading.Lock()
self.fail_next = False
self.fail_at = 0
self.post_count = 0
self.ignore_services = False
self.non_json = False
self.state = {
"entity_id": "climate.test",
"state": "heat",
@@ -47,6 +45,17 @@ class MockHa:
},
}
def apply(self, service: str, data: dict) -> None:
attrs = self.state["attributes"]
if service == "set_hvac_mode":
self.state["state"] = data["hvac_mode"]
elif service == "set_fan_mode":
attrs["fan_mode"] = data["fan_mode"]
elif service == "set_swing_mode":
attrs["swing_mode"] = data["swing_mode"]
elif service == "set_temperature":
attrs["temperature"] = data["temperature"]
def handler(self):
mock = self
@@ -66,31 +75,35 @@ class MockHa:
self.close_connection = True
self.wfile.write(body)
def _raw(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.close_connection = True
self.wfile.write(body)
def do_GET(self):
if self.path == "/api/states/climate.test":
with mock.lock:
self._json(200, json.loads(json.dumps(mock.state)))
else:
if self.path != "/api/states/climate.test":
self._json(404, {"message": "not found"})
return
with mock.lock:
if mock.non_json:
self._raw(200, b"<html>captive portal</html>")
return
self._json(200, json.loads(json.dumps(mock.state)))
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
data = json.loads(self.rfile.read(length) or b"{}")
with mock.lock:
if mock.fail_next:
mock.fail_next = False
mock.post_count += 1
if mock.fail_at and mock.post_count == mock.fail_at:
self._json(500, {"message": "boom"})
return
service = self.path.rsplit("/", 1)[-1]
attrs = mock.state["attributes"]
if service == "set_hvac_mode":
mock.state["state"] = data["hvac_mode"]
elif service == "set_fan_mode":
attrs["fan_mode"] = data["fan_mode"]
elif service == "set_swing_mode":
attrs["swing_mode"] = data["swing_mode"]
elif service == "set_temperature":
attrs["temperature"] = data["temperature"]
if not mock.ignore_services:
mock.apply(self.path.rsplit("/", 1)[-1], data)
self._json(200, [])
return Handler
@@ -109,6 +122,9 @@ def mock_ha():
thread.join(timeout=5)
QUICK_ARGS = ["quick", "--ha-token", "t", "--entity", "climate.test"]
def test_build_steps_supported_only():
state = {
"state": "heat",
@@ -120,33 +136,59 @@ def test_build_steps_supported_only():
"max_temp": 30.0,
},
}
steps = build_steps(state)
assert [step.name for step in steps] == ["temperature"]
assert [step.name for step in build_steps(state)] == ["temperature"]
def test_quick_success(mock_ha, capsys):
def test_quick_success(mock_ha, capsys, tmp_path):
_, url = mock_ha
assert main(["quick", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test"]) == 0
report = tmp_path / "quick.csv"
assert main(
[*QUICK_ARGS, "--ha-url", url, "--report", str(report)]
) == 0
out = capsys.readouterr().out
assert "restore: expected=heat observed=heat" in out
assert "ok" in report.read_text()
def test_quick_burst_success(mock_ha):
_, url = mock_ha
assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test"]) == 0
assert main([*QUICK_ARGS, "--burst", "--ha-url", url]) == 0
def test_quick_service_error(mock_ha, capsys):
mock, url = mock_ha
mock.fail_next = True
assert main(["quick", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test"]) == 2
mock.fail_at = 1
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert "HTTP 500" in capsys.readouterr().err
def test_long_report(mock_ha, tmp_path, capsys):
def test_quick_restores_after_partial_failure(mock_ha):
"""Сбой на втором шаге — исходное состояние возвращается (finally)."""
mock, url = mock_ha
mock.fail_at = 2
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert mock.state["state"] == "heat"
assert mock.state["attributes"]["fan_mode"] == "low"
def test_quick_timeout_is_failure(mock_ha, capsys):
mock, url = mock_ha
mock.ignore_services = True
rc = main(
[*QUICK_ARGS, "--ha-url", url, "--settle-timeout", "0.3"]
)
assert rc == 1
assert "ПРОВАЛЕНО" in capsys.readouterr().err
def test_quick_non_json_answer(mock_ha, capsys):
mock, url = mock_ha
mock.non_json = True
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert "не JSON" in capsys.readouterr().err
def test_long_report_on_success(mock_ha, tmp_path, capsys):
_, url = mock_ha
report = tmp_path / "acceptance.csv"
rc = main([
@@ -162,8 +204,74 @@ def test_long_report(mock_ha, tmp_path, capsys):
assert "long: ok=1 fail=0" in capsys.readouterr().out
def test_long_failure_restores_and_reports(mock_ha, tmp_path):
mock, url = mock_ha
mock.ignore_services = True # шаг не достигается, восстановление — работает
report = tmp_path / "fail.csv"
rc = main([
"long", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test",
"--hours", "0.000003", "--interval", "0.01",
"--settle-timeout", "0.3",
"--report", str(report),
])
assert rc == 1
assert "fail" in report.read_text()
assert mock.state["state"] == "heat"
def test_wait_state_timeout(mock_ha):
_, url = mock_ha
ha = HaRest(url, "t", timeout=1)
with pytest.raises(AcceptanceError):
ha.wait_state("climate.test", lambda s: False, timeout=0.2)
def test_esphome_channel_sends_mode_and_temperature(monkeypatch):
instances: list = []
class ClimateInfo:
key = 7
class FakeClient:
def __init__(self, host, port, noise_psk=None):
self.calls: list = []
instances.append(self)
async def connect(self, login=False):
self.login = login
async def list_entities_services(self):
return [ClimateInfo()], []
async def climate_command(self, key=None, mode=None,
target_temperature=None):
self.calls.append((key, mode, target_temperature))
async def disconnect(self):
self.disconnected = True
fake = types.ModuleType("aioesphomeapi")
fake.APIClient = FakeClient
fake.ClimateMode = SimpleNamespace(COOL="COOL", HEAT="HEAT")
fake.ClimateInfo = ClimateInfo
monkeypatch.setitem(sys.modules, "aioesphomeapi", fake)
channel = EsphomeChannel("esphome.local", "key")
try:
channel.connect()
mode_step = Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": "cool"}, "state", "cool")
temp_step = Step("temperature", "climate", "set_temperature",
{"temperature": 23.5}, "temperature", 23.5)
fan_step = Step("fan_mode", "climate", "set_fan_mode",
{"fan_mode": "low"}, "fan_mode", "low")
assert channel.apply(mode_step) is True
assert channel.apply(temp_step) is True
assert channel.apply(fan_step) is False
finally:
channel.close()
assert instances[0].calls == [(7, "COOL", None), (7, None, 23.5)]
assert instances[0].login is True
assert instances[0].disconnected is True
+254 -94
View File
@@ -10,23 +10,28 @@ HA + ESPHome); работает через REST API Home Assistant по long-liv
python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac
python tests/acceptance/test_esphome_ha.py --long \
python tests/acceptance/test_esphome_ha.py long \
--ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv
Режимы:
quick — матрица изменений (hvac/fan/уставка/swing), последовательно и
«burst» (без пауз), каждое с возвратом к исходному состоянию;
--long — одно изменение в --interval секунд (по умолчанию 3600) на
протяжении --hours часов, проверка и возврат, CSV-отчёт.
«burst» (без пауз), каждое действие проверяется по состоянию в
HA и завершается возвратом к исходному состоянию;
long — одно изменение в --interval секунд (по умолчанию 3600) на
протяжении --hours часов, проверка и возврат, CSV-отчёт
дописывается по ходу.
ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity
и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome,
результат сверяется по состоянию в HA.
и установленный `aioesphomeapi` — шаги «режим» и «уставка» отправляются
через ESPHome native API, результат сверяется по состоянию в HA (то есть
проверяется сквозная цепочка ESPHome → модуль → HA). Fan/swing через
ESPHome в этой версии не отправляются.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import datetime as dt
import json
@@ -64,7 +69,12 @@ class HaRest:
try:
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
body = resp.read().decode() or "null"
return resp.status, json.loads(body)
try:
return resp.status, json.loads(body)
except ValueError as err:
raise AcceptanceError(
f"{method} {path}: ответ не JSON: {body[:120]!r}"
) from err
except urllib.error.HTTPError as err:
try:
body = err.read().decode(errors="replace")
@@ -104,6 +114,79 @@ class HaRest:
)
class EsphomeChannel:
"""Канал ESPHome native API: режим и уставка (best-effort)."""
def __init__(self, host: str, key: str, timeout: float = DEFAULT_TIMEOUT):
self.host = host
self.key = key
self.timeout = timeout
self._loop = asyncio.new_event_loop()
self._aio = None
self._client = None
self._entity_key: Optional[int] = None
def connect(self) -> None:
self._loop.run_until_complete(self._connect())
async def _connect(self) -> None:
import aioesphomeapi # noqa: PLC0415
self._aio = aioesphomeapi
self._client = aioesphomeapi.APIClient(
self.host, 6053, noise_psk=self.key or None
)
await asyncio.wait_for(
self._client.connect(login=True), timeout=self.timeout
)
entities, _services = await self._client.list_entities_services()
climate_info = getattr(self._aio, "ClimateInfo", None)
for entity in entities:
if climate_info is not None and isinstance(entity, climate_info):
self._entity_key = entity.key
break
if entity.__class__.__name__ == "ClimateInfo":
self._entity_key = entity.key
break
if self._entity_key is None:
raise AcceptanceError("ESPHome: climate-сущность не найдена")
def apply(self, step: "Step") -> bool:
"""Отправляет шаг через ESPHome; False — шаг не поддержан каналом."""
if step.name not in ("hvac_mode", "temperature"):
return False
self._loop.run_until_complete(self._apply(step))
return True
async def _apply(self, step: "Step") -> None:
mode = None
target = None
if step.name == "hvac_mode":
mode = getattr(self._aio.ClimateMode, str(step.expected).upper())
else:
target = float(step.expected)
await asyncio.wait_for(
self._client.climate_command(
key=self._entity_key, mode=mode, target_temperature=target
),
timeout=self.timeout,
)
def close(self) -> None:
try:
if self._client is not None:
try:
self._loop.run_until_complete(
asyncio.wait_for(
self._client.disconnect(), timeout=self.timeout
)
)
except Exception: # noqa: BLE001 - best-effort
pass
finally:
self._loop.close()
@dataclass
class Step:
name: str
@@ -117,7 +200,8 @@ class Step:
def check(state: dict) -> bool:
if self.attribute == "state":
return state.get("state") == self.expected
return state.get("attributes", {}).get(self.attribute) == self.expected
value = state.get("attributes", {}).get(self.attribute)
return _matches(value, self.expected)
return check
@@ -130,12 +214,26 @@ def _pick(options: list, preferred: str) -> Optional[str]:
return options[0]
def _matches(observed: Any, expected: Any) -> bool:
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)):
return abs(float(observed) - float(expected)) < 0.11
return observed == expected
def _observed(state: Optional[dict], step: Step) -> Any:
if state is None:
return None
if step.attribute == "state":
return state.get("state")
return state.get("attributes", {}).get(step.attribute)
def build_steps(state: dict) -> list[Step]:
"""Адаптивная матрица: только поддерживаемые режимы/диапазоны."""
attrs = state.get("attributes", {})
steps: list[Step] = []
hvac_modes = [m for m in attrs.get("hvac_modes", []) if m != "off"]
hvac_modes = [mode for mode in attrs.get("hvac_modes", []) if mode != "off"]
mode = _pick(hvac_modes, "cool")
if mode:
steps.append(
@@ -171,14 +269,33 @@ def build_steps(state: dict) -> list[Step]:
return steps
def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None:
def _original_predicate(original: dict) -> Callable[[dict], bool]:
state = original.get("state")
attrs = original.get("attributes", {})
def check(current: dict) -> bool:
if current.get("state") != state:
return False
current_attrs = current.get("attributes", {})
for key in ("fan_mode", "swing_mode", "temperature"):
if attrs.get(key) is not None and not _matches(
current_attrs.get(key), attrs.get(key)
):
return False
return True
return check
def _restore(
ha: HaRest, entity_id: str, original: dict, *, wait: bool = True
) -> None:
attrs = original.get("attributes", {})
state = original.get("state")
if state == "off" and "off" in attrs.get("hvac_modes", []):
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": "off"})
return
if state and state != "off":
elif state and state != "off":
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": state})
if attrs.get("fan_mode") is not None:
@@ -190,59 +307,90 @@ def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None:
if attrs.get("swing_mode") is not None:
ha.call("climate", "set_swing_mode",
{"entity_id": entity_id, "swing_mode": attrs["swing_mode"]})
if wait:
ha.wait_state(entity_id, _original_predicate(original))
def _observed(state: Optional[dict], step: Step) -> Any:
if state is None:
return None
if step.attribute == "state":
return state.get("state")
return state.get("attributes", {}).get(step.attribute)
def _apply_step(
ha: HaRest, esphome: Optional[EsphomeChannel], entity_id: str, step: Step
) -> None:
if esphome is not None and esphome.apply(step):
return
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
def _matches(observed: Any, expected: Any) -> bool:
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)):
return abs(float(observed) - float(expected)) < 0.11
return observed == expected
def _write_report(
path: str, rows: list[list], write_header: bool = True
) -> None:
mode = "w" if write_header else "a"
with open(path, mode, newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
if write_header:
writer.writerow(
["timestamp", "step", "expected", "observed", "result"]
)
writer.writerows(rows)
def run_quick(ha: HaRest, entity_id: str, *, burst: bool = False) -> list[dict]:
def run_quick(
ha: HaRest,
entity_id: str,
*,
burst: bool = False,
esphome: Optional[EsphomeChannel] = None,
settle_timeout: float = SETTLE_TIMEOUT,
) -> list[dict]:
original = ha.state(entity_id)
steps = build_steps(original)
if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
results = []
if burst:
for step in steps:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
for step in steps:
state = ha.wait_state(entity_id, step.predicate())
results.append({
"step": step.name,
"expected": step.expected,
"observed": _observed(state, step),
})
else:
for step in steps:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
state = ha.wait_state(entity_id, step.predicate())
results.append({
"step": step.name,
"expected": step.expected,
"observed": _observed(state, step),
})
_restore(ha, entity_id, original, entity_id)
time.sleep(1.0)
restored = ha.state(entity_id)
results.append(
{
"step": "restore",
"expected": original.get("state"),
"observed": restored.get("state"),
}
)
results: list[dict] = []
try:
if burst and esphome is None:
for step in steps:
_apply_step(ha, esphome, entity_id, step)
for step in steps:
try:
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({
"step": step.name,
"expected": step.expected,
"observed": observed,
})
else:
for step in steps:
_apply_step(ha, esphome, entity_id, step)
try:
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({
"step": step.name,
"expected": step.expected,
"observed": observed,
})
finally:
_restore(ha, entity_id, original, wait=False)
try:
restored = ha.wait_state(
entity_id, _original_predicate(original), settle_timeout
)
except AcceptanceError:
restored = ha.state(entity_id)
results.append({
"step": "restore",
"expected": original.get("state"),
"observed": restored.get("state"),
})
return results
@@ -253,6 +401,8 @@ def run_long(
hours: float,
interval: float,
report_path: Optional[str],
esphome: Optional[EsphomeChannel] = None,
settle_timeout: float = SETTLE_TIMEOUT,
) -> tuple[int, int]:
original = ha.state(entity_id)
steps = build_steps(original)
@@ -260,53 +410,38 @@ def run_long(
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
iterations = max(1, int(hours * 3600 / interval))
ok = failed = 0
rows = []
rows: list[list] = []
for index in range(iterations):
step = steps[index % len(steps)]
timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
try:
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
state = ha.wait_state(entity_id, step.predicate())
observed = (
state.get("state")
if step.attribute == "state"
else state.get("attributes", {}).get(step.attribute)
_apply_step(ha, esphome, entity_id, step)
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
passed = observed == step.expected
_restore(ha, entity_id, original, entity_id)
time.sleep(1.0)
observed = _observed(state, step)
passed = _matches(observed, step.expected)
except AcceptanceError as err:
printed = str(err)
observed = str(err)
passed = False
observed = printed
finally:
try:
_restore(ha, entity_id, original)
except AcceptanceError as err:
observed = f"restore failed: {err}"
passed = False
ok += 1 if passed else 0
failed += 0 if passed else 1
rows.append([timestamp, step.name, step.expected, observed,
"ok" if passed else "fail"])
row = [timestamp, step.name, step.expected, observed,
"ok" if passed else "fail"]
rows.append(row)
if report_path:
_write_report(report_path, [row], write_header=index == 0)
if index + 1 < iterations:
time.sleep(interval)
if report_path:
with open(report_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["timestamp", "step", "expected", "observed",
"result"])
writer.writerows(rows)
return ok, failed
def _esphome_client(args: argparse.Namespace):
if not args.esphome_host:
return None
try:
import aioesphomeapi # noqa: PLC0415
except ImportError as err:
raise AcceptanceError(
"--esphome-host задан, но aioesphomeapi не установлен"
) from err
return aioesphomeapi, args
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("mode", nargs="?", choices=["quick", "long"],
@@ -318,26 +453,39 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("--entity", required=True,
help="entity_id climate-сущности FGLair")
parser.add_argument("--burst", action="store_true",
help="quick: без пауз между изменениями")
help="quick: без пауз между изменениями (без ESPHome)")
parser.add_argument("--hours", type=float, default=24.0)
parser.add_argument("--interval", type=float, default=3600.0)
parser.add_argument("--report", help="CSV-отчёт режима long")
parser.add_argument("--report", help="CSV-отчёт (quick и long)")
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--settle-timeout", type=float,
default=SETTLE_TIMEOUT)
parser.add_argument("--esphome-host")
parser.add_argument("--esphome-key", default="")
parser.add_argument("--esphome-entity")
return parser
def _make_esphome(args: argparse.Namespace) -> Optional[EsphomeChannel]:
if not args.esphome_host:
return None
channel = EsphomeChannel(args.esphome_host, args.esphome_key, args.timeout)
channel.connect()
return channel
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
if args.long_mode:
args.mode = "long"
esphome: Optional[EsphomeChannel] = None
try:
_esphome_client(args)
esphome = _make_esphome(args)
ha = HaRest(args.ha_url, args.ha_token, args.timeout)
if args.mode == "quick":
results = run_quick(ha, args.entity, burst=args.burst)
results = run_quick(
ha, args.entity, burst=args.burst, esphome=esphome,
settle_timeout=args.settle_timeout,
)
failed = [
row for row in results
if not _matches(row.get("observed"), row.get("expected"))
@@ -345,6 +493,14 @@ def main(argv: Optional[list[str]] = None) -> int:
for row in results:
print(f"{row['step']}: expected={row['expected']} "
f"observed={row.get('observed', '-')}")
if args.report:
_write_report(args.report, [
[dt.datetime.now(dt.timezone.utc).isoformat(),
row["step"], row["expected"], row.get("observed"),
"ok" if _matches(row.get("observed"),
row.get("expected")) else "fail"]
for row in results
])
if failed:
print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr)
return 1
@@ -352,7 +508,8 @@ def main(argv: Optional[list[str]] = None) -> int:
ok, failed = run_long(
ha, args.entity,
hours=args.hours, interval=args.interval,
report_path=args.report,
report_path=args.report, esphome=esphome,
settle_timeout=args.settle_timeout,
)
print(f"long: ok={ok} fail={failed}"
+ (f", отчёт: {args.report}" if args.report else ""))
@@ -360,6 +517,9 @@ def main(argv: Optional[list[str]] = None) -> int:
except AcceptanceError as err:
print(f"приёмка: {err}", file=sys.stderr)
return 2
finally:
if esphome is not None:
esphome.close()
if __name__ == "__main__":