ha(H5): обёртка ошибок ESPHome-канала, README/RELEASE/ru-ниты

- ESPHome connect/command/импорт оборачиваются в AcceptanceError; при сбое
  connect канал закрывается (loop закрыт), CLI даёт rc 2 без traceback
- self-test: connect error, command error, отсутствие aioesphomeapi через
  main (rc 2) — всего 15 acceptance-тестов
- RELEASE_HA: repaired-wheel в dist/manylinux + удаление linux-тега перед
  upload; README компонента — корректная ссылка на docs/RELEASE_HA.md
- translations/ru: options data «LAN-ключ»; план §2/§6 синхронизирован
This commit is contained in:
2026-09-29 15:04:20 +03:00
parent a03458f9bc
commit 1fc845d7f5
6 changed files with 132 additions and 16 deletions
+88
View File
@@ -275,3 +275,91 @@ def test_esphome_channel_sends_mode_and_temperature(monkeypatch):
assert instances[0].calls == [(7, "COOL", None), (7, None, 23.5)]
assert instances[0].login is True
assert instances[0].disconnected is True
def _fake_module(client_cls, climate_info=None):
fake = types.ModuleType("aioesphomeapi")
fake.APIClient = client_cls
fake.ClimateMode = SimpleNamespace(COOL="COOL", HEAT="HEAT")
if climate_info is not None:
fake.ClimateInfo = climate_info
return fake
def test_esphome_connect_error_is_acceptance_error(monkeypatch):
class BadClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
raise RuntimeError("connect boom")
async def disconnect(self):
pass
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadClient))
channel = EsphomeChannel("bad.local", "")
try:
with pytest.raises(AcceptanceError, match="подключение"):
channel.connect()
finally:
channel.close()
assert channel._loop.is_closed()
def test_main_esphome_connect_error_rc2(monkeypatch, capsys, mock_ha):
class BadClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
raise RuntimeError("connect boom")
_, url = mock_ha
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadClient))
rc = main([*QUICK_ARGS, "--ha-url", url, "--esphome-host", "1.2.3.4"])
assert rc == 2
assert "подключение" in capsys.readouterr().err
def test_main_esphome_missing_dependency_rc2(monkeypatch, capsys, mock_ha):
_, url = mock_ha
monkeypatch.setitem(sys.modules, "aioesphomeapi", None)
rc = main([*QUICK_ARGS, "--ha-url", url, "--esphome-host", "1.2.3.4"])
assert rc == 2
assert "aioesphomeapi" in capsys.readouterr().err
def test_esphome_command_error_is_acceptance_error(monkeypatch):
class ClimateInfo:
key = 3
class BadCommandClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
pass
async def list_entities_services(self):
return [ClimateInfo()], []
async def climate_command(self, **kwargs):
raise RuntimeError("command boom")
async def disconnect(self):
pass
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadCommandClient, ClimateInfo))
channel = EsphomeChannel("bad.local", "")
try:
channel.connect()
step = Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": "cool"}, "state", "cool")
with pytest.raises(AcceptanceError, match="команда"):
channel.apply(step)
finally:
channel.close()
+36 -9
View File
@@ -130,10 +130,25 @@ class EsphomeChannel:
self._loop.run_until_complete(self._connect())
async def _connect(self) -> None:
import aioesphomeapi # noqa: PLC0415
try:
import aioesphomeapi # noqa: PLC0415
except ImportError as err:
raise AcceptanceError(
"ESPHome-канал: пакет aioesphomeapi не установлен"
) from err
self._aio = aioesphomeapi
self._client = aioesphomeapi.APIClient(
try:
await self._connect_inner()
except AcceptanceError:
raise
except Exception as err: # noqa: BLE001
raise AcceptanceError(
f"ESPHome {self.host}: подключение/список сущностей: {err}"
) from err
async def _connect_inner(self) -> None:
self._client = self._aio.APIClient(
self.host, 6053, noise_psk=self.key or None
)
await asyncio.wait_for(
@@ -165,12 +180,15 @@ class EsphomeChannel:
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,
)
try:
await asyncio.wait_for(
self._client.climate_command(
key=self._entity_key, mode=mode, target_temperature=target
),
timeout=self.timeout,
)
except Exception as err: # noqa: BLE001
raise AcceptanceError(f"ESPHome: команда не отправлена: {err}") from err
def close(self) -> None:
try:
@@ -469,7 +487,16 @@ 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()
try:
channel.connect()
except AcceptanceError:
channel.close()
raise
except Exception as err: # noqa: BLE001
channel.close()
raise AcceptanceError(
f"ESPHome {args.esphome_host}: подключение не удалось: {err}"
) from err
return channel