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:
@@ -13,7 +13,7 @@
|
|||||||
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
|
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
|
||||||
* Пакет `pyfglair` (манифест ставит его автоматически из PyPI).
|
* Пакет `pyfglair` (манифест ставит его автоматически из PyPI).
|
||||||
До публикации на PyPI установите wheel вручную в python-окружение HA:
|
До публикации на PyPI установите wheel вручную в python-окружение HA:
|
||||||
`pip install pyfglair-*.whl` (см. `docs/RELEASE_HA.md`).
|
`pip install pyfglair-*.whl` (см. [`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md)).
|
||||||
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
|
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
|
||||||
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
|
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
|
||||||
|
|
||||||
|
|||||||
@@ -196,7 +196,7 @@
|
|||||||
"title": "LAN-ключ FGLair",
|
"title": "LAN-ключ FGLair",
|
||||||
"description": "Полный LAN-ключ (для secrets.yaml ESPHome). Скопируйте его или вставьте новый, чтобы заменить сохранённый. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Сохранение изменённого ключа перезагружает устройство.",
|
"description": "Полный LAN-ключ (для secrets.yaml ESPHome). Скопируйте его или вставьте новый, чтобы заменить сохранённый. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Сохранение изменённого ключа перезагружает устройство.",
|
||||||
"data": {
|
"data": {
|
||||||
"lanip_key": "LAN IP key"
|
"lanip_key": "LAN-ключ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Fallback, если сборка wheel станет блокером: сборк
|
|||||||
fglair_client.py # фоновый поток с FglSession
|
fglair_client.py # фоновый поток с FglSession
|
||||||
coordinator.py # push-driven coordinator
|
coordinator.py # push-driven coordinator
|
||||||
climate.py sensor.py switch.py select.py binary_sensor.py
|
climate.py sensor.py switch.py select.py binary_sensor.py
|
||||||
diagnostics.py # lanip_key/key_id/dsn видны для копирования в ESPHome
|
diagnostics.py # dsn/key_id/host + маска lanip_key; полный ключ — options flow
|
||||||
translations/{en,ru}.json
|
translations/{en,ru}.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ asyncio → push-coordinator. Состояния: `online` → available;
|
|||||||
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
||||||
устройства с созданными climate/sensor/switch сущностями.
|
устройства с созданными climate/sensor/switch сущностями.
|
||||||
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
||||||
Diagnostics с полями dsn/lanip_key/lanip_key_id.
|
Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key.
|
||||||
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
||||||
недоступность.
|
недоступность.
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -16,9 +16,10 @@ pip install build auditwheel twine
|
|||||||
# (например, quay.io/pypa/manylinux2014_<arch>):
|
# (например, quay.io/pypa/manylinux2014_<arch>):
|
||||||
python -m build --wheel # .so без внешнего mbedcrypto
|
python -m build --wheel # .so без внешнего mbedcrypto
|
||||||
auditwheel repair dist/pyfglair-*.whl \
|
auditwheel repair dist/pyfglair-*.whl \
|
||||||
--plat manylinux2014_x86_64 -w dist/ # или manylinux_2_28 и т.п.
|
--plat manylinux2014_x86_64 -w dist/manylinux/ # или manylinux_2_28
|
||||||
|
rm -f dist/pyfglair-*-linux_*.whl # linux-тег PyPI отклонит
|
||||||
python -m build --sdist # sdist с C++-исходниками (MANIFEST.in)
|
python -m build --sdist # sdist с C++-исходниками (MANIFEST.in)
|
||||||
twine upload dist/*
|
twine upload dist/manylinux/* dist/*.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверки перед публикацией:
|
Проверки перед публикацией:
|
||||||
|
|||||||
@@ -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].calls == [(7, "COOL", None), (7, None, 23.5)]
|
||||||
assert instances[0].login is True
|
assert instances[0].login is True
|
||||||
assert instances[0].disconnected 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()
|
||||||
|
|||||||
@@ -130,10 +130,25 @@ class EsphomeChannel:
|
|||||||
self._loop.run_until_complete(self._connect())
|
self._loop.run_until_complete(self._connect())
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
async def _connect(self) -> None:
|
||||||
|
try:
|
||||||
import aioesphomeapi # noqa: PLC0415
|
import aioesphomeapi # noqa: PLC0415
|
||||||
|
except ImportError as err:
|
||||||
|
raise AcceptanceError(
|
||||||
|
"ESPHome-канал: пакет aioesphomeapi не установлен"
|
||||||
|
) from err
|
||||||
|
|
||||||
self._aio = aioesphomeapi
|
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
|
self.host, 6053, noise_psk=self.key or None
|
||||||
)
|
)
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
@@ -165,12 +180,15 @@ class EsphomeChannel:
|
|||||||
mode = getattr(self._aio.ClimateMode, str(step.expected).upper())
|
mode = getattr(self._aio.ClimateMode, str(step.expected).upper())
|
||||||
else:
|
else:
|
||||||
target = float(step.expected)
|
target = float(step.expected)
|
||||||
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
self._client.climate_command(
|
self._client.climate_command(
|
||||||
key=self._entity_key, mode=mode, target_temperature=target
|
key=self._entity_key, mode=mode, target_temperature=target
|
||||||
),
|
),
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
|
except Exception as err: # noqa: BLE001
|
||||||
|
raise AcceptanceError(f"ESPHome: команда не отправлена: {err}") from err
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -469,7 +487,16 @@ def _make_esphome(args: argparse.Namespace) -> Optional[EsphomeChannel]:
|
|||||||
if not args.esphome_host:
|
if not args.esphome_host:
|
||||||
return None
|
return None
|
||||||
channel = EsphomeChannel(args.esphome_host, args.esphome_key, args.timeout)
|
channel = EsphomeChannel(args.esphome_host, args.esphome_key, args.timeout)
|
||||||
|
try:
|
||||||
channel.connect()
|
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
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user