ha(H4): repair key_mismatch, диагностика для ESPHome, reconfigure, переводы

- 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)
This commit is contained in:
2026-09-29 14:06:39 +03:00
parent dee5819743
commit 57ee555704
15 changed files with 451 additions and 10 deletions
+9 -1
View File
@@ -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)}"
)
+34 -2
View File
@@ -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)
+36 -1
View File
@@ -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:
+95
View File
@@ -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,
},
}
@@ -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
+13
View File
@@ -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()
+17 -1
View File
@@ -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."
}
}
}
}
}
}
+17 -1
View File
@@ -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."
}
}
}
}
}
}
+17 -1
View File
@@ -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 исчезнет, когда устройство выйдет на связь."
}
}
}
}
}
}