From 43d3ff7dea1efe5b75eeaab64b8ee443f1976cca Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 14:29:36 +0300 Subject: [PATCH] =?UTF-8?q?ha(H4):=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=E2=80=94=20=D0=BC=D0=B0=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=B4=D0=B0=D0=BC=D0=BF=D0=B5,=20reload-repair,=20?= =?UTF-8?q?reconfigure=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BF=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - major: diagnostics больше не содержит полный lanip_key (esp_home — маска, entry_data redacted); ключ для ESPHome — через reconfigure/CLI; план §3 синхронизирован; дамп при незагруженной записи не падает - reconfigure: сохраняет listen_port/keepalive_ms и текущие overrides/ temp_step; превью-дефолты подхватывают значения записи - repair fix-flow: подтверждение перезагружает запись (issue снимается только когда сессия вышла из key_error; иначе создаётся заново) - key_issue_id helper, использование async_remove_issue (мёртвый код), CONF_EMAIL/PASSWORD в TO_REDACT, placeholders в fix_flow, тест плейсхолдеров ru - тесты обновлены (47 components) --- custom_components/fglair/__init__.py | 8 +- custom_components/fglair/config_flow.py | 37 +++++--- custom_components/fglair/coordinator.py | 9 +- custom_components/fglair/diagnostics.py | 92 ++++++++++--------- custom_components/fglair/repairs.py | 44 ++++++++- custom_components/fglair/strings.json | 4 +- custom_components/fglair/translations/en.json | 4 +- custom_components/fglair/translations/ru.json | 4 +- docs/PLAN_HOME_ASSISTANT.md | 8 +- tests/components/fglair/test_diagnostics.py | 9 +- tests/components/fglair/test_repairs.py | 4 +- tests/components/fglair/test_translations.py | 17 ++++ 12 files changed, 160 insertions(+), 80 deletions(-) diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index 3236b56..93f3201 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir import pyfglair from .const import CONF_DSN, DOMAIN -from .coordinator import FglairCoordinator, FglairRuntime +from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id from .fglair_client import FglairClient from .overrides import ConversionSet @@ -68,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conversions = ConversionSet.from_entry(entry.data) # Координатор создаётся (и подписывается) до старта сессии, чтобы не # пропустить первое ONLINE. - coordinator = FglairCoordinator(hass, client, conversions) + coordinator = FglairCoordinator(hass, client, conversions, entry.entry_id) try: await client.async_start() await coordinator.async_config_entry_first_refresh() @@ -98,8 +98,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unloaded: return False await runtime.coordinator.async_shutdown() + runtime.coordinator.async_remove_issue() 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) @@ -110,5 +110,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ir.async_delete_issue( - hass, DOMAIN, f"key_mismatch_{entry.data.get(CONF_DSN)}" + hass, DOMAIN, key_issue_id(str(entry.data.get(CONF_DSN, ""))) ) diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index 10e7cae..b923b16 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -103,29 +103,35 @@ def _import_schema() -> vol.Schema: def _preview_schema(data: dict[str, Any]) -> vol.Schema: template = Template[str(data.get(CONF_TEMPLATE, "A"))] + spec = (data.get(CONF_OVERRIDES) or {}).get( + Prop.ADJUST_TEMPERATURE.name.lower(), {} + ) + temp_min = int(spec.get("min", TEMP_MIN_TENTHS)) + temp_max = int(spec.get("max", TEMP_MAX_TENTHS)) + step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template]) return vol.Schema( { vol.Required(CONF_TEMPLATE, default=template.name): vol.In( ["A", "B", "F"] ), - vol.Required(CONF_TEMP_MIN, default=16.0): vol.All( + vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All( vol.Coerce(float), vol.Range(min=-10.0, max=45.0) ), - vol.Required(CONF_TEMP_MAX, default=30.0): vol.All( + vol.Required(CONF_TEMP_MAX, default=temp_max / 10): vol.All( vol.Coerce(float), vol.Range(min=-10.0, max=45.0) ), + vol.Required(CONF_TEMP_STEP, default=step / 10): vol.All( + vol.Coerce(float), vol.Range(min=0.1, max=5.0) + ), + vol.Required(CONF_TEMP_NUM, default=int(spec.get("num", 1))): vol.All( + int, vol.Range(min=1, max=1000) + ), + vol.Required(CONF_TEMP_DEN, default=int(spec.get("den", 1))): vol.All( + int, vol.Range(min=1, max=1000) + ), vol.Required( - CONF_TEMP_STEP, default=TEMP_STEP_TENTHS[template] / 10 - ): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)), - vol.Required(CONF_TEMP_NUM, default=1): vol.All( - int, vol.Range(min=1, max=1000) - ), - vol.Required(CONF_TEMP_DEN, default=1): vol.All( - int, vol.Range(min=1, max=1000) - ), - vol.Required(CONF_TEMP_OFFSET, default=0): vol.All( - int, vol.Range(min=-1000, max=1000) - ), + CONF_TEMP_OFFSET, default=int(spec.get("offset", 0)) + ): vol.All(int, vol.Range(min=-1000, max=1000)), } ) @@ -496,6 +502,11 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_trial(self, data: dict[str, Any]) -> str | None: """None — сессия поднялась; иначе ключ ошибки формы.""" + if self._reconfigure_entry is not None: + # Сохраняем служебные поля записи (listen_port/keepalive_ms) и + # текущие конверсии, если путь их не задаёт. + for key, value in self._reconfigure_entry.data.items(): + data.setdefault(key, value) await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) if self._reconfigure_entry is not None: self._abort_if_unique_id_mismatch() diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py index a422167..8344619 100644 --- a/custom_components/fglair/coordinator.py +++ b/custom_components/fglair/coordinator.py @@ -42,6 +42,10 @@ class FglairRuntime: conversions: ConversionSet +def key_issue_id(dsn: str) -> str: + return f"key_mismatch_{dsn}" + + def _writable_props(template: Template): for info in template_info(template): if info.kind != ValueKind.STRING: @@ -56,6 +60,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): hass: HomeAssistant, client: FglairClient, conversions: ConversionSet, + entry_id: str, ) -> None: super().__init__( hass, @@ -65,7 +70,8 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): ) self.client = client self.conversions = conversions - self.key_issue_id = f"key_mismatch_{client.data[CONF_DSN]}" + self.entry_id = entry_id + self.key_issue_id = key_issue_id(client.data[CONF_DSN]) self._synced = False self._sync_task: Optional[asyncio.Task] = None self._unsubs = [ @@ -144,6 +150,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): or self.client.data[CONF_DSN], "host": str(self.client.data.get(CONF_HOST, "?")), }, + data={"entry_id": self.entry_id}, ) else: ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id) diff --git a/custom_components/fglair/diagnostics.py b/custom_components/fglair/diagnostics.py index 76ae32e..3701f7d 100644 --- a/custom_components/fglair/diagnostics.py +++ b/custom_components/fglair/diagnostics.py @@ -1,13 +1,13 @@ -"""Диагностика fglair: параметры устройства для копирования в ESPHome. +"""Диагностика fglair: параметры устройства для проверки и поддержки. -План §3: на странице устройства видны dsn/lanip_key/lanip_key_id/host — -источник для secrets.yaml ESPHome; рядом маскированная копия ключа для -пересылки дампов. Дамп доступен только администратору HA; полный ключ -включён намеренно (это статический ключ модуля, не пароль облака). +Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в +issues). Полный ключ для secrets.yaml ESPHome берётся явным действием: +«Настроить заново» (reconfigure) на карточке интеграции или CLI +``python -m pyfglair discover`` на хосте HA. """ from __future__ import annotations -from typing import Any +from typing import Any, Optional from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -18,49 +18,31 @@ from pyfglair.templates import template_info from .const import ( CONF_DEVICE_PORT, CONF_DSN, + CONF_EMAIL, CONF_HOST, CONF_LANIP_KEY, CONF_LANIP_KEY_ID, CONF_LISTEN_PORT, CONF_MODEL, CONF_NAME, + CONF_PASSWORD, CONF_TEMP_STEP, CONF_TEMPLATE, ) from .coordinator import FglairRuntime -TO_REDACT = {CONF_LANIP_KEY, "password", "email"} REDACTED = "**REDACTED**" +TO_REDACT = {CONF_LANIP_KEY, CONF_PASSWORD, CONF_EMAIL} def _masked(value: Any) -> Any: - if not isinstance(value, str) or not value: - return REDACTED - if len(value) <= 4: + if not isinstance(value, str) or 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 - ), - } - +def _base_diagnostics(entry: ConfigEntry) -> dict[str, Any]: + data = entry.data return { "config": { CONF_HOST: data.get(CONF_HOST), @@ -74,22 +56,42 @@ async def async_get_config_entry_diagnostics( }, "esp_home": { CONF_DSN: data.get(CONF_DSN), - CONF_LANIP_KEY: key, + CONF_LANIP_KEY: _masked(data.get(CONF_LANIP_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, - }, + "entry_data": async_redact_data(dict(data), TO_REDACT), + "overrides": data.get("overrides", {}), } + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + diag = _base_diagnostics(entry) + runtime: Optional[FglairRuntime] = getattr(entry, "runtime_data", None) + if runtime is None: + diag["runtime"] = {"loaded": False} + return diag + + client = runtime.client + 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 + ), + } + + diag["runtime"] = { + "loaded": True, + "state": client.state.name.lower(), + "last_error": client.last_error.name.lower(), + "events_dropped": client.events_dropped, + "values": values, + } + return diag diff --git a/custom_components/fglair/repairs.py b/custom_components/fglair/repairs.py index 5ffe3cd..8803a6a 100644 --- a/custom_components/fglair/repairs.py +++ b/custom_components/fglair/repairs.py @@ -1,13 +1,49 @@ -"""Repair flows fglair: подтверждение issue key_mismatch.""" +"""Repair flow fglair: key_mismatch → подсказка и reload записи. + +Issue не удаляем молча: подтверждение перезагружает ConfigEntry, и если +ключ всё ещё неверен, coordinator создаёт issue заново (issue снимается +только когда сессия выходит из key_error). +""" from __future__ import annotations from typing import Any -from homeassistant.components.repairs import ConfirmRepairFlow +import voluptuous as vol +from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + + +class KeyMismatchRepairFlow(RepairsFlow): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + if user_input is not None: + entry_id = (self.data or {}).get("entry_id") + if entry_id: + self.hass.config_entries.async_schedule_reload(entry_id) + return self.async_create_entry(data={}) + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=self._placeholders(), + ) + + def _placeholders(self) -> dict[str, str]: + from homeassistant.helpers import issue_registry as ir + + issue = ir.async_get(self.hass).async_get_issue( + self.handler, self.issue_id + ) + return dict(issue.translation_placeholders or {}) if issue else {} async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None -) -> ConfirmRepairFlow: - return ConfirmRepairFlow() +) -> KeyMismatchRepairFlow: + return KeyMismatchRepairFlow() diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 10f1aba..1a70d40 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -179,12 +179,12 @@ "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.", + "description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Use Reconfigure on the integration card to fetch the current key (cloud sign-in) 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." + "description": "Confirm to reload device {device} ({host}). If the key is still wrong, use Reconfigure on the integration card: sign in to the FGLair cloud to fetch the current key or paste a fresh config_*.json. This repair item disappears once the device comes online." } } } diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index 10f1aba..1a70d40 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -179,12 +179,12 @@ "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.", + "description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Use Reconfigure on the integration card to fetch the current key (cloud sign-in) 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." + "description": "Confirm to reload device {device} ({host}). If the key is still wrong, use Reconfigure on the integration card: sign in to the FGLair cloud to fetch the current key or paste a fresh config_*.json. This repair item disappears once the device comes online." } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index ed59a9f..93f305a 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -179,12 +179,12 @@ "issues": { "key_mismatch": { "title": "FGLair: не совпадает LAN-ключ", - "description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Перепровижинируйте: «Настроить заново» на карточке интеграции — вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json.", + "description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный ключ, либо вставьте новый config_*.json.", "fix_flow": { "step": { "confirm": { "title": "FGLair: не совпадает LAN-ключ", - "description": "Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный LAN-ключ, либо вставьте новый config. Repair исчезнет, когда устройство выйдет на связь." + "description": "Подтвердите — интеграция перезагрузится (устройство {device}, {host}). Если ключ всё ещё неверен, используйте «Настроить заново» на карточке интеграции: вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json. Repair исчезнет, когда устройство выйдет на связь." } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 956ddb7..384d58f 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -87,9 +87,11 @@ Repair (теоретический): `key_error` → «Ключ устройст перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме не используется, ключ статичен. -**Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`, -`host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ -маскируется. +**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и +маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный +ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить +заново») на карточке интеграции или CLI `python -m pyfglair discover` на +хосте HA. ## 4. Сущности diff --git a/tests/components/fglair/test_diagnostics.py b/tests/components/fglair/test_diagnostics.py index 4ada2d6..9654f6c 100644 --- a/tests/components/fglair/test_diagnostics.py +++ b/tests/components/fglair/test_diagnostics.py @@ -39,10 +39,9 @@ async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled): 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"] != LANIP_KEY + assert "REDACTED" in diag["esp_home"]["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" @@ -54,3 +53,7 @@ async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled): ) assert await hass.config_entries.async_unload(entry.entry_id) + + unloaded = await async_get_config_entry_diagnostics(hass, entry) + assert unloaded["runtime"]["loaded"] is False + assert unloaded["config"]["dsn"] == DSN diff --git a/tests/components/fglair/test_repairs.py b/tests/components/fglair/test_repairs.py index 0b2d661..09cb9c6 100644 --- a/tests/components/fglair/test_repairs.py +++ b/tests/components/fglair/test_repairs.py @@ -46,7 +46,9 @@ async def test_key_error_creates_and_fixes_issue( 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.async_block_till_done() + # Ключ всё ещё неверен: после reload issue создан снова, а не потерян. + assert registry.async_get_issue(DOMAIN, issue_id) is not 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 index f8602e6..7363f65 100644 --- a/tests/components/fglair/test_translations.py +++ b/tests/components/fglair/test_translations.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import pathlib +import re ROOT = pathlib.Path(__file__).resolve().parents[3] COMPONENT = ROOT / "custom_components" / "fglair" @@ -27,3 +28,19 @@ 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) + + +def _placeholders(node, prefix: str = "") -> dict[str, set[str]]: + out: dict[str, set[str]] = {} + if isinstance(node, dict): + for key, value in node.items(): + out.update(_placeholders(value, prefix + key + ".")) + elif isinstance(node, str): + out[prefix] = set(re.findall(r"{(\w+)}", node)) + return out + + +def test_ru_placeholders_match(): + strings = json.loads((COMPONENT / "strings.json").read_text()) + russian = json.loads((COMPONENT / "translations" / "ru.json").read_text()) + assert _placeholders(strings) == _placeholders(russian)