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:
2026-09-29 14:29:36 +03:00
parent dcbf9a6c7e
commit 43d3ff7dea
12 changed files with 160 additions and 80 deletions
+4 -4
View File
@@ -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, "")))
) )
+24 -13
View File
@@ -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()
+8 -1
View File
@@ -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)
+43 -41
View File
@@ -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),
}, async def async_get_config_entry_diagnostics(
"entry_data": async_redact_data(dict(entry.data), TO_REDACT), hass: HomeAssistant, entry: ConfigEntry
"overrides": entry.data.get("overrides", {}), ) -> dict[str, Any]:
"runtime": { 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(), "state": client.state.name.lower(),
"last_error": client.last_error.name.lower(), "last_error": client.last_error.name.lower(),
"events_dropped": client.events_dropped, "events_dropped": client.events_dropped,
"values": values, "values": values,
},
} }
return diag
+40 -4
View File
@@ -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()
+2 -2
View File
@@ -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 исчезнет, когда устройство выйдет на связь."
} }
} }
} }
+5 -3
View File
@@ -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. Сущности
+6 -3
View File
@@ -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
+3 -1
View File
@@ -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)