diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index b1e1435..0fa4813 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -24,6 +24,7 @@ _LOG_LEVELS = { } DATA_CLIENTS = f"{DOMAIN}_clients" +DATA_PENDING = f"{DOMAIN}_pending" def _log_handler(level: int, message: str) -> None: @@ -31,22 +32,34 @@ def _log_handler(level: int, message: str) -> None: async def async_setup(hass: HomeAssistant, config: dict) -> bool: - if isinstance(config.get(DOMAIN), dict): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config[DOMAIN], - ) + if DOMAIN not in config: + return True + if not isinstance(config[DOMAIN], dict): + _LOGGER.warning( + "fglair: ожидался словарь параметров устройства, получено %s", + type(config[DOMAIN]).__name__, ) + return True + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: clients: dict[str, FglairClient] = hass.data.setdefault(DATA_CLIENTS, {}) + hass.data[DATA_PENDING] = hass.data.get(DATA_PENDING, 0) + 1 client = FglairClient(hass, entry.data) - await client.async_start() + try: + await client.async_start() + finally: + hass.data[DATA_PENDING] -= 1 + clients[entry.entry_id] = client entry.runtime_data = client if len(clients) == 1: @@ -64,6 +77,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) clients.pop(entry.entry_id, None) - if not clients: + if not clients and not hass.data.get(DATA_PENDING, 0): await hass.async_add_executor_job(pyfglair.set_log_handler, None) return True diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py index 8c5221c..77f0c3d 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -36,5 +36,5 @@ TRIAL_ERRORS = { "unreachable": "cannot_connect", "timeout": "timeout", "start_failed": "cannot_connect", - "properties_missing": "cannot_connect", + "properties_missing": "properties_missing", } diff --git a/custom_components/fglair/fglair_client.py b/custom_components/fglair/fglair_client.py index dc3a80c..6ab2f28 100644 --- a/custom_components/fglair/fglair_client.py +++ b/custom_components/fglair/fglair_client.py @@ -155,9 +155,10 @@ class FglairClient: raise if not started: await session.async_stop() + port = self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) raise ConfigEntryNotReady( - "не удалось запустить локальный HTTP-сервер FGLair " - f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})" + "не удалось запустить локальный HTTP-сервер FGLair" + + (f" (порт {port})" if port else "") ) self._session = session self._state = session.state diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 84ff0a4..0c03876 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -61,7 +61,8 @@ "invalid_auth": "Invalid email or password", "invalid_import": "Invalid config JSON", "no_devices": "No devices found in the account", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "properties_missing": "The device connected but did not report its base properties" }, "abort": { "already_configured": "This device is already configured", @@ -69,7 +70,8 @@ "cannot_connect": "Could not connect to the device", "no_slots": "Both LAN slots are busy", "key_mismatch": "LAN key does not match the device", - "timeout": "Timed out waiting for the device" + "timeout": "Timed out waiting for the device", + "properties_missing": "The device connected but did not report its base properties" } } } diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index 84ff0a4..0c03876 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -61,7 +61,8 @@ "invalid_auth": "Invalid email or password", "invalid_import": "Invalid config JSON", "no_devices": "No devices found in the account", - "unknown": "Unexpected error" + "unknown": "Unexpected error", + "properties_missing": "The device connected but did not report its base properties" }, "abort": { "already_configured": "This device is already configured", @@ -69,7 +70,8 @@ "cannot_connect": "Could not connect to the device", "no_slots": "Both LAN slots are busy", "key_mismatch": "LAN key does not match the device", - "timeout": "Timed out waiting for the device" + "timeout": "Timed out waiting for the device", + "properties_missing": "The device connected but did not report its base properties" } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index 9cab6ad..e154cb2 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -61,7 +61,8 @@ "invalid_auth": "Неверный e-mail или пароль", "invalid_import": "Некорректный config JSON", "no_devices": "В аккаунте не найдено устройств", - "unknown": "Неожиданная ошибка" + "unknown": "Неожиданная ошибка", + "properties_missing": "Устройство подключилось, но не отдало базовые свойства" }, "abort": { "already_configured": "Устройство уже настроено", @@ -69,7 +70,8 @@ "cannot_connect": "Не удалось подключиться к устройству", "no_slots": "Оба LAN-слота заняты", "key_mismatch": "LAN-ключ не совпадает с устройством", - "timeout": "Устройство не вышло на связь за отведённое время" + "timeout": "Устройство не вышло на связь за отведённое время", + "properties_missing": "Устройство подключилось, но не отдало базовые свойства" } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 06673b3..5c5715c 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -158,4 +158,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 или иной канал) — без неё `requirements` манифеста не разрешается в чистой HA | +| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда | diff --git a/pyfglair/session.py b/pyfglair/session.py index d5ab0b3..a01bc2c 100644 --- a/pyfglair/session.py +++ b/pyfglair/session.py @@ -66,6 +66,7 @@ class _LogPump(threading.Thread): def __init__(self) -> None: super().__init__(name="pyfglair-log", daemon=True) self._stop = threading.Event() + self.stopping = False def run(self) -> None: out = ffi.new("fgl_log_event_t[]", 8) @@ -95,6 +96,7 @@ class _LogPump(threading.Thread): _LOGGER.exception("log handler failed") def stop(self) -> None: + self.stopping = True self._stop.set() self.join(timeout=2.0) @@ -102,19 +104,30 @@ class _LogPump(threading.Thread): def set_log_handler(handler: Optional[LogHandler]) -> None: """Глобальный приёмник логов ядра (0=debug..3=error). - ``None`` останавливает pump-поток (если он запущен). + ``None`` останавливает pump-поток (если он запущен). Новый pump не + поднимается, пока предыдущий не завершился: параллельные читатели одного + кольца недопустимы. """ global _GLOBAL_LOG, _LOG_PUMP with _LOG_PUMP_LOCK: _GLOBAL_LOG = handler if handler is None: - pump, _LOG_PUMP = _LOG_PUMP, None - if pump is not None: - pump.stop() + if _LOG_PUMP is not None: + _LOG_PUMP.stop() return - if _LOG_PUMP is None: - _LOG_PUMP = _LogPump() - _LOG_PUMP.start() + pump = _LOG_PUMP + if pump is not None and pump.is_alive(): + if not pump.stopping: + return # уже работает и не остановлен + pump.join(timeout=2.0) + if pump.is_alive(): + _LOGGER.warning( + "лог-памп не завершился; новый не запускаю (обработчик " + "логов блокируется?)" + ) + return + _LOG_PUMP = _LogPump() + _LOG_PUMP.start() def set_log_level(level: int) -> None: diff --git a/tests/components/fglair/test_init.py b/tests/components/fglair/test_init.py index 2845995..ba9b627 100644 --- a/tests/components/fglair/test_init.py +++ b/tests/components/fglair/test_init.py @@ -1,6 +1,7 @@ """Setup/unload ConfigEntry: клиент стартует, останавливается, ошибки порта.""" from __future__ import annotations +import asyncio import socket from helpers import free_port @@ -50,6 +51,55 @@ async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_ assert await hass.config_entries.async_unload(second.entry_id) +async def test_two_entries_same_explicit_port( + hass: HomeAssistant, entry_data, socket_enabled +): + """Явно заданный один порт на две записи — вторая SETUP_RETRY.""" + port = free_port() + first = MockConfigEntry( + domain=DOMAIN, + data={**entry_data, CONF_LISTEN_PORT: port}, + unique_id="DSN-1", + ) + first.add_to_hass(hass) + assert await hass.config_entries.async_setup(first.entry_id) + await hass.async_block_till_done() + + second = MockConfigEntry( + domain=DOMAIN, + data={**entry_data, "dsn": "DSN-2", CONF_LISTEN_PORT: port}, + unique_id="DSN-2", + ) + second.add_to_hass(hass) + assert not await hass.config_entries.async_setup(second.entry_id) + await hass.async_block_till_done() + assert second.state.name == "SETUP_RETRY" + assert await hass.config_entries.async_unload(first.entry_id) + + +async def test_unload_does_not_block_loop( + hass: HomeAssistant, config_entry, socket_enabled +): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + ticks = 0 + + async def ticker() -> None: + nonlocal ticks + while True: + await asyncio.sleep(0.01) + ticks += 1 + + task = asyncio.create_task(ticker()) + await asyncio.sleep(0.05) + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + task.cancel() + assert ticks >= 3 + + async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled): port = free_port() sock = socket.socket()