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:
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 исчезнет, когда устройство выйдет на связь."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user