From 57ee555704f343d52e53e03e3c87d057a387c660 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 14:06:39 +0300 Subject: [PATCH] =?UTF-8?q?ha(H4):=20repair=20key=5Fmismatch,=20=D0=B4?= =?UTF-8?q?=D0=B8=D0=B0=D0=B3=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20ESPHome,=20reconfigure,=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repairs: issue key_mismatch (ERROR, fixable) при состоянии key_error, ConfirmRepairFlow, снятие issue при online/unload/remove - config flow: async_step_reconfigure (меню, prefill ручной формы, unique_id_mismatch, update_reload_and_abort) — путь «перепровижинировать» - diagnostics: config/esp_home (dsn/lanip_key/key_id/host для secrets.yaml) + esp_home_masked и redacted entry_data, runtime-снимок значений (raw/display) из кэша ядра - translations: issues.key_mismatch (title/description/fix_flow) en/ru, abort unique_id_mismatch/reconfigure_successful; тест согласованности strings↔en↔ru - FglairClient.events_dropped; mock_ac: параметр key_id в фикстуре - тесты: diagnostics, repairs issue+fix-flow, reconfigure (успех/чужое устройство), translations (45 components) --- custom_components/fglair/__init__.py | 10 +- custom_components/fglair/config_flow.py | 36 ++++++- custom_components/fglair/coordinator.py | 37 +++++++- custom_components/fglair/diagnostics.py | 95 +++++++++++++++++++ custom_components/fglair/fglair_client.py | 6 ++ custom_components/fglair/repairs.py | 13 +++ custom_components/fglair/strings.json | 18 +++- custom_components/fglair/translations/en.json | 18 +++- custom_components/fglair/translations/ru.json | 18 +++- docs/PLAN_HOME_ASSISTANT.md | 2 +- tests/components/fglair/test_config_flow.py | 66 +++++++++++++ tests/components/fglair/test_diagnostics.py | 56 +++++++++++ tests/components/fglair/test_repairs.py | 51 ++++++++++ tests/components/fglair/test_translations.py | 29 ++++++ tests/conftest.py | 6 +- 15 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 custom_components/fglair/diagnostics.py create mode 100644 custom_components/fglair/repairs.py create mode 100644 tests/components/fglair/test_diagnostics.py create mode 100644 tests/components/fglair/test_repairs.py create mode 100644 tests/components/fglair/test_translations.py diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index 6d716a8..3236b56 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -7,10 +7,11 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import pyfglair -from .const import DOMAIN +from .const import CONF_DSN, DOMAIN from .coordinator import FglairCoordinator, FglairRuntime from .fglair_client import FglairClient from .overrides import ConversionSet @@ -98,9 +99,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False await runtime.coordinator.async_shutdown() await runtime.client.async_stop() + ir.async_delete_issue(hass, DOMAIN, runtime.coordinator.key_issue_id) clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) clients.pop(entry.entry_id, None) if not clients and not hass.data.get(DATA_PENDING, 0): await hass.async_add_executor_job(pyfglair.set_log_handler, None) return unloaded + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + ir.async_delete_issue( + hass, DOMAIN, f"key_mismatch_{entry.data.get(CONF_DSN)}" + ) diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index c25a6c4..10e7cae 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -8,7 +8,7 @@ from functools import partial from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.helpers import selector import pyfglair @@ -259,6 +259,29 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): self._devices: list[Device] = [] self._pending_data: dict[str, Any] = {} self._trial_properties: dict[Any, Any] = {} + self._reconfigure_entry: ConfigEntry | None = None + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Повторная настройка (в т.ч. смена LAN-ключа после key_error).""" + self._reconfigure_entry = self._get_reconfigure_entry() + return await self.async_step_user() + + def _reconfigure_suggestions(self) -> dict[str, Any] | None: + entry = self._reconfigure_entry + if entry is None: + return None + fields = ( + CONF_HOST, + CONF_DEVICE_PORT, + CONF_DSN, + CONF_LANIP_KEY, + CONF_LANIP_KEY_ID, + CONF_NAME, + CONF_MODEL, + ) + return {key: entry.data[key] for key in fields if key in entry.data} def _show_form( self, @@ -366,6 +389,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): if error is None: return await self.async_step_preview() errors["base"] = error + if user_input is None and self._reconfigure_entry is not None: + user_input = self._reconfigure_suggestions() return self._show_form( "manual", _manual_schema(), errors, user_input, exclude=(CONF_LANIP_KEY,), @@ -472,7 +497,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_trial(self, data: dict[str, Any]) -> str | None: """None — сессия поднялась; иначе ключ ошибки формы.""" await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) - self._abort_if_unique_id_configured() + if self._reconfigure_entry is not None: + self._abort_if_unique_id_mismatch() + else: + self._abort_if_unique_id_configured() result: TrialResult = await self.hass.async_add_executor_job( partial(_run_trial, data) ) @@ -488,4 +516,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: title = data.get(CONF_NAME) or data[CONF_DSN] + if self._reconfigure_entry is not None: + return self.async_update_reload_and_abort( + self._reconfigure_entry, title=title, data=data + ) return self.async_create_entry(title=title, data=data) diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py index aa85710..a422167 100644 --- a/custom_components/fglair/coordinator.py +++ b/custom_components/fglair/coordinator.py @@ -8,12 +8,19 @@ from typing import Optional from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind from pyfglair.templates import template_info -from .const import AVAILABLE_STATES, CONF_DSN, DOMAIN +from .const import ( + AVAILABLE_STATES, + CONF_DSN, + CONF_HOST, + CONF_NAME, + DOMAIN, +) from .fglair_client import FglairClient from .overrides import ConversionSet @@ -58,6 +65,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): ) self.client = client self.conversions = conversions + self.key_issue_id = f"key_mismatch_{client.data[CONF_DSN]}" self._synced = False self._sync_task: Optional[asyncio.Task] = None self._unsubs = [ @@ -94,6 +102,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): available=state in AVAILABLE_STATES, ) self.async_set_updated_data(self.data) + self.sync_issue(state) if state == State.ONLINE: if not self._synced and self._sync_task is None: self._sync_task = self.hass.async_create_task( @@ -116,6 +125,32 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): # не могут вклиниться между чтением и присваиванием). self.data = self._snapshot() self.async_set_updated_data(self.data) + self.sync_issue() + + @callback + def sync_issue(self, state: Optional[State] = None) -> None: + """Repair-issue key_error: создать/снять по состоянию сессии.""" + current = state if state is not None else self.data.state + if current == State.KEY_ERROR: + ir.async_create_issue( + self.hass, + DOMAIN, + self.key_issue_id, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="key_mismatch", + translation_placeholders={ + "device": self.client.data.get(CONF_NAME) + or self.client.data[CONF_DSN], + "host": str(self.client.data.get(CONF_HOST, "?")), + }, + ) + else: + ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id) + + @callback + def async_remove_issue(self) -> None: + ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id) @callback def _on_property(self, event: PropertyEvent) -> None: diff --git a/custom_components/fglair/diagnostics.py b/custom_components/fglair/diagnostics.py new file mode 100644 index 0000000..76ae32e --- /dev/null +++ b/custom_components/fglair/diagnostics.py @@ -0,0 +1,95 @@ +"""Диагностика fglair: параметры устройства для копирования в ESPHome. + +План §3: на странице устройства видны dsn/lanip_key/lanip_key_id/host — +источник для secrets.yaml ESPHome; рядом маскированная копия ключа для +пересылки дампов. Дамп доступен только администратору HA; полный ключ +включён намеренно (это статический ключ модуля, не пароль облака). +""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from pyfglair.templates import template_info + +from .const import ( + CONF_DEVICE_PORT, + CONF_DSN, + CONF_HOST, + CONF_LANIP_KEY, + CONF_LANIP_KEY_ID, + CONF_LISTEN_PORT, + CONF_MODEL, + CONF_NAME, + CONF_TEMP_STEP, + CONF_TEMPLATE, +) +from .coordinator import FglairRuntime + +TO_REDACT = {CONF_LANIP_KEY, "password", "email"} +REDACTED = "**REDACTED**" + + +def _masked(value: Any) -> Any: + if not isinstance(value, str) or not value: + return REDACTED + if len(value) <= 4: + return REDACTED + return f"{value[:4]}…{REDACTED}" + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + runtime: FglairRuntime = entry.runtime_data + client = runtime.client + data = client.data + key = entry.data.get(CONF_LANIP_KEY) + + values: dict[str, Any] = {} + for info in template_info(client.template): + value = client.cached(info.prop) + if value is None: + continue + values[info.name] = { + "raw": value.int_value, + "display": runtime.conversions.to_display( + info.prop, value.int_value + ), + } + + return { + "config": { + CONF_HOST: data.get(CONF_HOST), + CONF_DEVICE_PORT: data.get(CONF_DEVICE_PORT), + CONF_DSN: data.get(CONF_DSN), + CONF_NAME: data.get(CONF_NAME), + CONF_MODEL: data.get(CONF_MODEL), + CONF_TEMPLATE: data.get(CONF_TEMPLATE), + CONF_TEMP_STEP: data.get(CONF_TEMP_STEP), + CONF_LISTEN_PORT: data.get(CONF_LISTEN_PORT), + }, + "esp_home": { + CONF_DSN: data.get(CONF_DSN), + CONF_LANIP_KEY: key, + CONF_LANIP_KEY_ID: data.get(CONF_LANIP_KEY_ID), + CONF_HOST: data.get(CONF_HOST), + }, + "esp_home_masked": { + CONF_DSN: data.get(CONF_DSN), + CONF_LANIP_KEY: _masked(key), + CONF_LANIP_KEY_ID: data.get(CONF_LANIP_KEY_ID), + CONF_HOST: data.get(CONF_HOST), + }, + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "overrides": entry.data.get("overrides", {}), + "runtime": { + "state": client.state.name.lower(), + "last_error": client.last_error.name.lower(), + "events_dropped": client.events_dropped, + "values": values, + }, + } diff --git a/custom_components/fglair/fglair_client.py b/custom_components/fglair/fglair_client.py index 6ab2f28..fbb2a6e 100644 --- a/custom_components/fglair/fglair_client.py +++ b/custom_components/fglair/fglair_client.py @@ -82,6 +82,12 @@ class FglairClient: def template(self) -> Template: return resolve_template(self._data) + @property + def events_dropped(self) -> int: + if self._session is None: + return 0 + return self._session.events_dropped + @property def data(self) -> Mapping[str, Any]: return self._data diff --git a/custom_components/fglair/repairs.py b/custom_components/fglair/repairs.py new file mode 100644 index 0000000..5ffe3cd --- /dev/null +++ b/custom_components/fglair/repairs.py @@ -0,0 +1,13 @@ +"""Repair flows fglair: подтверждение issue key_mismatch.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.repairs import ConfirmRepairFlow +from homeassistant.core import HomeAssistant + + +async def async_create_fix_flow( + hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None +) -> ConfirmRepairFlow: + return ConfirmRepairFlow() diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 7bfd348..10f1aba 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -90,7 +90,9 @@ "no_slots": "Both LAN slots are busy", "key_mismatch": "LAN key does not match the device", "timeout": "Timed out waiting for the device", - "properties_missing": "The device connected but did not report its base properties" + "properties_missing": "The device connected but did not report its base properties", + "unique_id_mismatch": "This is a different device (unique ID mismatch)", + "reconfigure_successful": "Reconfiguration successful" } }, "entity": { @@ -173,5 +175,19 @@ "name": "Horizontal airflow direction" } } + }, + "issues": { + "key_mismatch": { + "title": "FGLair: LAN key mismatch", + "description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Re-provision the device: use Reconfigure on the integration card and sign in to the FGLair cloud again, or paste a fresh config_*.json.", + "fix_flow": { + "step": { + "confirm": { + "title": "FGLair: LAN key mismatch", + "description": "Use Reconfigure on the integration card to fetch the current LAN key (cloud sign-in) or paste a fresh config. This repair item disappears when the device comes online." + } + } + } + } } } diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index 7bfd348..10f1aba 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -90,7 +90,9 @@ "no_slots": "Both LAN slots are busy", "key_mismatch": "LAN key does not match the device", "timeout": "Timed out waiting for the device", - "properties_missing": "The device connected but did not report its base properties" + "properties_missing": "The device connected but did not report its base properties", + "unique_id_mismatch": "This is a different device (unique ID mismatch)", + "reconfigure_successful": "Reconfiguration successful" } }, "entity": { @@ -173,5 +175,19 @@ "name": "Horizontal airflow direction" } } + }, + "issues": { + "key_mismatch": { + "title": "FGLair: LAN key mismatch", + "description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Re-provision the device: use Reconfigure on the integration card and sign in to the FGLair cloud again, or paste a fresh config_*.json.", + "fix_flow": { + "step": { + "confirm": { + "title": "FGLair: LAN key mismatch", + "description": "Use Reconfigure on the integration card to fetch the current LAN key (cloud sign-in) or paste a fresh config. This repair item disappears when the device comes online." + } + } + } + } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index f319c7b..ed59a9f 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -90,7 +90,9 @@ "no_slots": "Оба LAN-слота заняты", "key_mismatch": "LAN-ключ не совпадает с устройством", "timeout": "Устройство не вышло на связь за отведённое время", - "properties_missing": "Устройство подключилось, но не отдало базовые свойства" + "properties_missing": "Устройство подключилось, но не отдало базовые свойства", + "unique_id_mismatch": "Это другое устройство (не совпадает unique ID)", + "reconfigure_successful": "Перенастройка выполнена" } }, "entity": { @@ -173,5 +175,19 @@ "name": "Положение заслонки (гориз.)" } } + }, + "issues": { + "key_mismatch": { + "title": "FGLair: не совпадает LAN-ключ", + "description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Перепровижинируйте: «Настроить заново» на карточке интеграции — вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json.", + "fix_flow": { + "step": { + "confirm": { + "title": "FGLair: не совпадает LAN-ключ", + "description": "Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный LAN-ключ, либо вставьте новый config. Repair исчезнет, когда устройство выйдет на связь." + } + } + } + } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 0d03d48..956ddb7 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -160,5 +160,5 @@ Security → Long-lived access tokens) и REST API: вызов сервисов | H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности | -| H4 | repair, диагностика (ключ для ESPHome), translations | +| H4 ✅ | repair, диагностика (ключ для ESPHome), translations | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда | diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index 26892ed..ef93ddc 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch from helpers import DSN, KEY_ID, LANIP_KEY from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_RECONFIGURE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -413,3 +414,68 @@ async def test_import_json_null_device_port(hass: HomeAssistant): assert result["type"] == "create_entry" assert result["data"]["device_port"] == 80 assert trial.call_args.args[0]["device_port"] == 80 + + +RECONFIGURE_ENTRY_DATA = { + "host": "127.0.0.1", + "device_port": 9, + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "name": "Old name", + "model": "AP-WC1E", + "template": "A", +} + + +async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled): + from test_entities import _setup_entry + + proc = mock_ac(["--set", "operation_mode=6"]) + entry = await _setup_entry(hass, proc.port) + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}, + ) + assert result["type"] == "menu" + result = await _select(hass, result["flow_id"], "manual") + assert result["type"] == "form" + assert _suggested(result["data_schema"], "host") == "127.0.0.1" + result = await _configure( + hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"} + ) + assert result["step_id"] == "preview" + result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) + assert result["type"] == "abort" + assert result["reason"] == "reconfigure_successful" + await hass.async_block_till_done() + assert entry.data["name"] == "Renamed" + assert entry.state.name == "LOADED" + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_reconfigure_wrong_device(hass: HomeAssistant): + entry = MockConfigEntry( + domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN + ) + entry.add_to_hass(hass) + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}, + ) + result = await _select(hass, result["flow_id"], "manual") + result = await _configure( + hass, + result["flow_id"], + {**MANUAL_INPUT, "dsn": "AC000W00OTHER001"}, + ) + assert result["type"] == "abort" + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/components/fglair/test_diagnostics.py b/tests/components/fglair/test_diagnostics.py new file mode 100644 index 0000000..4ada2d6 --- /dev/null +++ b/tests/components/fglair/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Диагностика: параметры для ESPHome и маскировка ключа.""" +from __future__ import annotations + +import asyncio +import time + +from helpers import DSN, LANIP_KEY +from homeassistant.core import HomeAssistant +from test_entities import MOCK_SETTINGS, _setup_entry + +from custom_components.fglair.diagnostics import ( + async_get_config_entry_diagnostics, +) +from pyfglair import Prop, State + + +async def _wait_for(predicate, timeout: float = 20.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + await asyncio.sleep(0.1) + raise AssertionError("условие не выполнено за отведённое время") + + +async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled): + proc = mock_ac(MOCK_SETTINGS) + entry = await _setup_entry(hass, proc.port) + runtime = entry.runtime_data + await _wait_for( + lambda: runtime.client.state == State.ONLINE + and runtime.client.cached(Prop.OPERATION_MODE) is not None + and runtime.client.cached(Prop.DISPLAY_TEMPERATURE) is not None + ) + + diag = await async_get_config_entry_diagnostics(hass, entry) + assert diag["config"]["dsn"] == DSN + assert diag["config"]["host"] == "127.0.0.1" + assert diag["config"]["template"] == "A" + + assert diag["esp_home"]["lanip_key"] == LANIP_KEY + assert diag["esp_home"]["lanip_key_id"] == 64201 + assert diag["esp_home_masked"]["lanip_key"] != LANIP_KEY + assert "REDACTED" in diag["esp_home_masked"]["lanip_key"] + assert diag["entry_data"]["lanip_key"] == "**REDACTED**" + + assert diag["runtime"]["state"] == "online" + assert diag["runtime"]["events_dropped"] == 0 + assert diag["runtime"]["values"]["operation_mode"]["raw"] == 6 + assert diag["runtime"]["values"]["operation_mode"]["display"] == 6 + assert ( + diag["runtime"]["values"]["display_temperature"]["display"] == 200 + ) + + assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/fglair/test_repairs.py b/tests/components/fglair/test_repairs.py new file mode 100644 index 0000000..1c5bc75 --- /dev/null +++ b/tests/components/fglair/test_repairs.py @@ -0,0 +1,51 @@ +"""Repair issue при несовпадении LAN-ключа и его fix-flow.""" +from __future__ import annotations + +import asyncio +import time + +from helpers import DSN, KEY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component +from test_entities import _setup_entry + +from custom_components.fglair.const import DOMAIN +from pyfglair import State + + +async def _wait_for(predicate, timeout: float = 20.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + await asyncio.sleep(0.1) + raise AssertionError("условие не выполнено за отведённое время") + + +async def test_key_error_creates_and_fixes_issue( + hass: HomeAssistant, mock_ac, socket_enabled +): + assert await async_setup_component(hass, "repairs", {}) + proc = mock_ac([], key_id=KEY_ID + 1) + entry = await _setup_entry(hass, proc.port) + runtime = entry.runtime_data + await _wait_for(lambda: runtime.client.state == State.KEY_ERROR) + + registry = ir.async_get(hass) + issue_id = f"key_mismatch_{DSN}" + issue = registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.is_fixable + assert issue.translation_key == "key_mismatch" + assert issue.translation_placeholders["device"] == "Mock AC" + + flow_manager = hass.data["repairs"]["flow_manager"] + result = await flow_manager.async_init(DOMAIN, data={"issue_id": issue_id}) + assert result["type"] == "form" + result = await flow_manager.async_configure(result["flow_id"], {}) + assert result["type"] == "create_entry" + assert registry.async_get_issue(DOMAIN, issue_id) is None + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/fglair/test_translations.py b/tests/components/fglair/test_translations.py new file mode 100644 index 0000000..f8602e6 --- /dev/null +++ b/tests/components/fglair/test_translations.py @@ -0,0 +1,29 @@ +"""Согласованность strings.json / translations en/ru.""" +from __future__ import annotations + +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[3] +COMPONENT = ROOT / "custom_components" / "fglair" + + +def _keys(node, prefix: str = "") -> set[str]: + keys: set[str] = set() + if isinstance(node, dict): + for key, value in node.items(): + keys.add(prefix + key) + keys |= _keys(value, prefix + key + ".") + return keys + + +def test_en_matches_strings(): + strings = json.loads((COMPONENT / "strings.json").read_text()) + english = json.loads((COMPONENT / "translations" / "en.json").read_text()) + assert strings == english + + +def test_ru_covers_all_keys(): + strings = json.loads((COMPONENT / "strings.json").read_text()) + russian = json.loads((COMPONENT / "translations" / "ru.json").read_text()) + assert _keys(strings) == _keys(russian) diff --git a/tests/conftest.py b/tests/conftest.py index daf4054..5986e5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -146,13 +146,15 @@ async def cloud(): def mock_ac(): procs: list[Proc] = [] - def start(extra: list[str] | None = None) -> Proc: + def start( + extra: list[str] | None = None, key_id: int | None = None + ) -> Proc: port = free_port() proc = Proc([ sys.executable, str(MOCK_AC), "--port", str(port), "--lanip-key", LANIP_KEY, - "--key-id", str(KEY_ID), + "--key-id", str(KEY_ID if key_id is None else key_id), *(extra or []), ]) procs.append(proc)