diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md index ceda294..eb6b71a 100644 --- a/custom_components/fglair/README.md +++ b/custom_components/fglair/README.md @@ -13,7 +13,7 @@ * Home Assistant ≥ 2025.1 (Linux x86_64/aarch64). * Пакет `pyfglair` (манифест ставит его автоматически из PyPI). До публикации на 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-сессии**: телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index 6e2dd04..c440e83 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -196,7 +196,7 @@ "title": "LAN-ключ FGLair", "description": "Полный LAN-ключ (для secrets.yaml ESPHome). Скопируйте его или вставьте новый, чтобы заменить сохранённый. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Сохранение изменённого ключа перезагружает устройство.", "data": { - "lanip_key": "LAN IP key" + "lanip_key": "LAN-ключ" } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 8ab0fc0..ca39996 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -50,7 +50,7 @@ Fallback, если сборка wheel станет блокером: сборк fglair_client.py # фоновый поток с FglSession coordinator.py # push-driven coordinator 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 ``` @@ -138,7 +138,7 @@ asyncio → push-coordinator. Состояния: `online` → available; 6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница устройства с созданными climate/sensor/switch сущностями. 7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница - Diagnostics с полями dsn/lanip_key/lanip_key_id. + Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key. 8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error, недоступность. diff --git a/docs/RELEASE_HA.md b/docs/RELEASE_HA.md index 78032bb..9aa089a 100644 --- a/docs/RELEASE_HA.md +++ b/docs/RELEASE_HA.md @@ -16,9 +16,10 @@ pip install build auditwheel twine # (например, quay.io/pypa/manylinux2014_): python -m build --wheel # .so без внешнего mbedcrypto auditwheel repair dist/pyfglair-*.whl \ - --plat manylinux2014_x86_64 -w dist/ # или manylinux_2_28 и т.п. -python -m build --sdist # sdist с C++-исходниками (MANIFEST.in) -twine upload dist/* + --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) +twine upload dist/manylinux/* dist/*.tar.gz ``` Проверки перед публикацией: diff --git a/tests/acceptance/test_acceptance.py b/tests/acceptance/test_acceptance.py index 9fffb06..3b10b57 100644 --- a/tests/acceptance/test_acceptance.py +++ b/tests/acceptance/test_acceptance.py @@ -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() diff --git a/tests/acceptance/test_esphome_ha.py b/tests/acceptance/test_esphome_ha.py index 3e88945..2d00ed1 100644 --- a/tests/acceptance/test_esphome_ha.py +++ b/tests/acceptance/test_esphome_ha.py @@ -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