ha(H4): исправления по ревью — маскировка ключа в дампе, reload-repair, reconfigure сохраняет поля
- 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)
This commit is contained in:
@@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir
|
|||||||
import pyfglair
|
import pyfglair
|
||||||
|
|
||||||
from .const import CONF_DSN, DOMAIN
|
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 .fglair_client import FglairClient
|
||||||
from .overrides import ConversionSet
|
from .overrides import ConversionSet
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
conversions = ConversionSet.from_entry(entry.data)
|
conversions = ConversionSet.from_entry(entry.data)
|
||||||
# Координатор создаётся (и подписывается) до старта сессии, чтобы не
|
# Координатор создаётся (и подписывается) до старта сессии, чтобы не
|
||||||
# пропустить первое ONLINE.
|
# пропустить первое ONLINE.
|
||||||
coordinator = FglairCoordinator(hass, client, conversions)
|
coordinator = FglairCoordinator(hass, client, conversions, entry.entry_id)
|
||||||
try:
|
try:
|
||||||
await client.async_start()
|
await client.async_start()
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
@@ -98,8 +98,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
if not unloaded:
|
if not unloaded:
|
||||||
return False
|
return False
|
||||||
await runtime.coordinator.async_shutdown()
|
await runtime.coordinator.async_shutdown()
|
||||||
|
runtime.coordinator.async_remove_issue()
|
||||||
await runtime.client.async_stop()
|
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: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
|
||||||
clients.pop(entry.entry_id, None)
|
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:
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
ir.async_delete_issue(
|
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, "")))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,29 +103,35 @@ def _import_schema() -> vol.Schema:
|
|||||||
|
|
||||||
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
|
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
|
||||||
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
|
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(
|
return vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
|
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
|
||||||
["A", "B", "F"]
|
["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.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.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(
|
vol.Required(
|
||||||
CONF_TEMP_STEP, default=TEMP_STEP_TENTHS[template] / 10
|
CONF_TEMP_OFFSET, default=int(spec.get("offset", 0))
|
||||||
): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)),
|
): vol.All(int, vol.Range(min=-1000, max=1000)),
|
||||||
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)
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,6 +502,11 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
async def _async_trial(self, data: dict[str, Any]) -> str | None:
|
async def _async_trial(self, data: dict[str, Any]) -> str | None:
|
||||||
"""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)
|
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
|
||||||
if self._reconfigure_entry is not None:
|
if self._reconfigure_entry is not None:
|
||||||
self._abort_if_unique_id_mismatch()
|
self._abort_if_unique_id_mismatch()
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ class FglairRuntime:
|
|||||||
conversions: ConversionSet
|
conversions: ConversionSet
|
||||||
|
|
||||||
|
|
||||||
|
def key_issue_id(dsn: str) -> str:
|
||||||
|
return f"key_mismatch_{dsn}"
|
||||||
|
|
||||||
|
|
||||||
def _writable_props(template: Template):
|
def _writable_props(template: Template):
|
||||||
for info in template_info(template):
|
for info in template_info(template):
|
||||||
if info.kind != ValueKind.STRING:
|
if info.kind != ValueKind.STRING:
|
||||||
@@ -56,6 +60,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
client: FglairClient,
|
client: FglairClient,
|
||||||
conversions: ConversionSet,
|
conversions: ConversionSet,
|
||||||
|
entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
@@ -65,7 +70,8 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
|||||||
)
|
)
|
||||||
self.client = client
|
self.client = client
|
||||||
self.conversions = conversions
|
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._synced = False
|
||||||
self._sync_task: Optional[asyncio.Task] = None
|
self._sync_task: Optional[asyncio.Task] = None
|
||||||
self._unsubs = [
|
self._unsubs = [
|
||||||
@@ -144,6 +150,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
|||||||
or self.client.data[CONF_DSN],
|
or self.client.data[CONF_DSN],
|
||||||
"host": str(self.client.data.get(CONF_HOST, "?")),
|
"host": str(self.client.data.get(CONF_HOST, "?")),
|
||||||
},
|
},
|
||||||
|
data={"entry_id": self.entry_id},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id)
|
ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Диагностика fglair: параметры устройства для копирования в ESPHome.
|
"""Диагностика fglair: параметры устройства для проверки и поддержки.
|
||||||
|
|
||||||
План §3: на странице устройства видны dsn/lanip_key/lanip_key_id/host —
|
Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в
|
||||||
источник для secrets.yaml ESPHome; рядом маскированная копия ключа для
|
issues). Полный ключ для secrets.yaml ESPHome берётся явным действием:
|
||||||
пересылки дампов. Дамп доступен только администратору HA; полный ключ
|
«Настроить заново» (reconfigure) на карточке интеграции или CLI
|
||||||
включён намеренно (это статический ключ модуля, не пароль облака).
|
``python -m pyfglair discover`` на хосте HA.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from homeassistant.components.diagnostics import async_redact_data
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -18,49 +18,31 @@ from pyfglair.templates import template_info
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_DEVICE_PORT,
|
CONF_DEVICE_PORT,
|
||||||
CONF_DSN,
|
CONF_DSN,
|
||||||
|
CONF_EMAIL,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_LANIP_KEY,
|
CONF_LANIP_KEY,
|
||||||
CONF_LANIP_KEY_ID,
|
CONF_LANIP_KEY_ID,
|
||||||
CONF_LISTEN_PORT,
|
CONF_LISTEN_PORT,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
|
CONF_PASSWORD,
|
||||||
CONF_TEMP_STEP,
|
CONF_TEMP_STEP,
|
||||||
CONF_TEMPLATE,
|
CONF_TEMPLATE,
|
||||||
)
|
)
|
||||||
from .coordinator import FglairRuntime
|
from .coordinator import FglairRuntime
|
||||||
|
|
||||||
TO_REDACT = {CONF_LANIP_KEY, "password", "email"}
|
|
||||||
REDACTED = "**REDACTED**"
|
REDACTED = "**REDACTED**"
|
||||||
|
TO_REDACT = {CONF_LANIP_KEY, CONF_PASSWORD, CONF_EMAIL}
|
||||||
|
|
||||||
|
|
||||||
def _masked(value: Any) -> Any:
|
def _masked(value: Any) -> Any:
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or len(value) <= 4:
|
||||||
return REDACTED
|
|
||||||
if len(value) <= 4:
|
|
||||||
return REDACTED
|
return REDACTED
|
||||||
return f"{value[:4]}…{REDACTED}"
|
return f"{value[:4]}…{REDACTED}"
|
||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
def _base_diagnostics(entry: ConfigEntry) -> dict[str, Any]:
|
||||||
hass: HomeAssistant, entry: ConfigEntry
|
data = entry.data
|
||||||
) -> 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 {
|
return {
|
||||||
"config": {
|
"config": {
|
||||||
CONF_HOST: data.get(CONF_HOST),
|
CONF_HOST: data.get(CONF_HOST),
|
||||||
@@ -74,22 +56,42 @@ async def async_get_config_entry_diagnostics(
|
|||||||
},
|
},
|
||||||
"esp_home": {
|
"esp_home": {
|
||||||
CONF_DSN: data.get(CONF_DSN),
|
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_LANIP_KEY_ID: data.get(CONF_LANIP_KEY_ID),
|
||||||
CONF_HOST: data.get(CONF_HOST),
|
CONF_HOST: data.get(CONF_HOST),
|
||||||
},
|
},
|
||||||
"esp_home_masked": {
|
"entry_data": async_redact_data(dict(data), TO_REDACT),
|
||||||
CONF_DSN: data.get(CONF_DSN),
|
"overrides": data.get("overrides", {}),
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
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.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(
|
async def async_create_fix_flow(
|
||||||
hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None
|
hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None
|
||||||
) -> ConfirmRepairFlow:
|
) -> KeyMismatchRepairFlow:
|
||||||
return ConfirmRepairFlow()
|
return KeyMismatchRepairFlow()
|
||||||
|
|||||||
@@ -179,12 +179,12 @@
|
|||||||
"issues": {
|
"issues": {
|
||||||
"key_mismatch": {
|
"key_mismatch": {
|
||||||
"title": "FGLair: LAN 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": {
|
"fix_flow": {
|
||||||
"step": {
|
"step": {
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "FGLair: LAN key mismatch",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,12 +179,12 @@
|
|||||||
"issues": {
|
"issues": {
|
||||||
"key_mismatch": {
|
"key_mismatch": {
|
||||||
"title": "FGLair: LAN 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": {
|
"fix_flow": {
|
||||||
"step": {
|
"step": {
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "FGLair: LAN key mismatch",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,12 +179,12 @@
|
|||||||
"issues": {
|
"issues": {
|
||||||
"key_mismatch": {
|
"key_mismatch": {
|
||||||
"title": "FGLair: не совпадает LAN-ключ",
|
"title": "FGLair: не совпадает LAN-ключ",
|
||||||
"description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Перепровижинируйте: «Настроить заново» на карточке интеграции — вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json.",
|
"description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный ключ, либо вставьте новый config_*.json.",
|
||||||
"fix_flow": {
|
"fix_flow": {
|
||||||
"step": {
|
"step": {
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"title": "FGLair: не совпадает LAN-ключ",
|
"title": "FGLair: не совпадает LAN-ключ",
|
||||||
"description": "Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный LAN-ключ, либо вставьте новый config. Repair исчезнет, когда устройство выйдет на связь."
|
"description": "Подтвердите — интеграция перезагрузится (устройство {device}, {host}). Если ключ всё ещё неверен, используйте «Настроить заново» на карточке интеграции: вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json. Repair исчезнет, когда устройство выйдет на связь."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ Repair (теоретический): `key_error` → «Ключ устройст
|
|||||||
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
||||||
не используется, ключ статичен.
|
не используется, ключ статичен.
|
||||||
|
|
||||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`,
|
**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и
|
||||||
`host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ
|
маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный
|
||||||
маскируется.
|
ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить
|
||||||
|
заново») на карточке интеграции или CLI `python -m pyfglair discover` на
|
||||||
|
хосте HA.
|
||||||
|
|
||||||
## 4. Сущности
|
## 4. Сущности
|
||||||
|
|
||||||
|
|||||||
@@ -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"]["host"] == "127.0.0.1"
|
||||||
assert diag["config"]["template"] == "A"
|
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"]["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["entry_data"]["lanip_key"] == "**REDACTED**"
|
||||||
|
|
||||||
assert diag["runtime"]["state"] == "online"
|
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)
|
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
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ async def test_key_error_creates_and_fixes_issue(
|
|||||||
assert result["type"] == "form"
|
assert result["type"] == "form"
|
||||||
result = await flow_manager.async_configure(result["flow_id"], {})
|
result = await flow_manager.async_configure(result["flow_id"], {})
|
||||||
assert result["type"] == "create_entry"
|
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)
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
COMPONENT = ROOT / "custom_components" / "fglair"
|
COMPONENT = ROOT / "custom_components" / "fglair"
|
||||||
@@ -27,3 +28,19 @@ def test_ru_covers_all_keys():
|
|||||||
strings = json.loads((COMPONENT / "strings.json").read_text())
|
strings = json.loads((COMPONENT / "strings.json").read_text())
|
||||||
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
|
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
|
||||||
assert _keys(strings) == _keys(russian)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user