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.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
import pyfglair
|
import pyfglair
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_DSN, DOMAIN
|
||||||
from .coordinator import FglairCoordinator, FglairRuntime
|
from .coordinator import FglairCoordinator, FglairRuntime
|
||||||
from .fglair_client import FglairClient
|
from .fglair_client import FglairClient
|
||||||
from .overrides import ConversionSet
|
from .overrides import ConversionSet
|
||||||
@@ -98,9 +99,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return False
|
return False
|
||||||
await runtime.coordinator.async_shutdown()
|
await runtime.coordinator.async_shutdown()
|
||||||
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)
|
||||||
if not clients and not hass.data.get(DATA_PENDING, 0):
|
if not clients and not hass.data.get(DATA_PENDING, 0):
|
||||||
await hass.async_add_executor_job(pyfglair.set_log_handler, None)
|
await hass.async_add_executor_job(pyfglair.set_log_handler, None)
|
||||||
return unloaded
|
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
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
|
|
||||||
import pyfglair
|
import pyfglair
|
||||||
@@ -259,6 +259,29 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self._devices: list[Device] = []
|
self._devices: list[Device] = []
|
||||||
self._pending_data: dict[str, Any] = {}
|
self._pending_data: dict[str, Any] = {}
|
||||||
self._trial_properties: dict[Any, 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(
|
def _show_form(
|
||||||
self,
|
self,
|
||||||
@@ -366,6 +389,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if error is None:
|
if error is None:
|
||||||
return await self.async_step_preview()
|
return await self.async_step_preview()
|
||||||
errors["base"] = error
|
errors["base"] = error
|
||||||
|
if user_input is None and self._reconfigure_entry is not None:
|
||||||
|
user_input = self._reconfigure_suggestions()
|
||||||
return self._show_form(
|
return self._show_form(
|
||||||
"manual", _manual_schema(), errors, user_input,
|
"manual", _manual_schema(), errors, user_input,
|
||||||
exclude=(CONF_LANIP_KEY,),
|
exclude=(CONF_LANIP_KEY,),
|
||||||
@@ -472,6 +497,9 @@ 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 — сессия поднялась; иначе ключ ошибки формы."""
|
||||||
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:
|
||||||
|
self._abort_if_unique_id_mismatch()
|
||||||
|
else:
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
result: TrialResult = await self.hass.async_add_executor_job(
|
result: TrialResult = await self.hass.async_add_executor_job(
|
||||||
partial(_run_trial, data)
|
partial(_run_trial, data)
|
||||||
@@ -488,4 +516,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||||
title = data.get(CONF_NAME) or data[CONF_DSN]
|
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)
|
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.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||||
|
|
||||||
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
|
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
|
||||||
from pyfglair.templates import template_info
|
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 .fglair_client import FglairClient
|
||||||
from .overrides import ConversionSet
|
from .overrides import ConversionSet
|
||||||
|
|
||||||
@@ -58,6 +65,7 @@ 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._synced = False
|
self._synced = False
|
||||||
self._sync_task: Optional[asyncio.Task] = None
|
self._sync_task: Optional[asyncio.Task] = None
|
||||||
self._unsubs = [
|
self._unsubs = [
|
||||||
@@ -94,6 +102,7 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
|||||||
available=state in AVAILABLE_STATES,
|
available=state in AVAILABLE_STATES,
|
||||||
)
|
)
|
||||||
self.async_set_updated_data(self.data)
|
self.async_set_updated_data(self.data)
|
||||||
|
self.sync_issue(state)
|
||||||
if state == State.ONLINE:
|
if state == State.ONLINE:
|
||||||
if not self._synced and self._sync_task is None:
|
if not self._synced and self._sync_task is None:
|
||||||
self._sync_task = self.hass.async_create_task(
|
self._sync_task = self.hass.async_create_task(
|
||||||
@@ -116,6 +125,32 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
|||||||
# не могут вклиниться между чтением и присваиванием).
|
# не могут вклиниться между чтением и присваиванием).
|
||||||
self.data = self._snapshot()
|
self.data = self._snapshot()
|
||||||
self.async_set_updated_data(self.data)
|
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
|
@callback
|
||||||
def _on_property(self, event: PropertyEvent) -> None:
|
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:
|
def template(self) -> Template:
|
||||||
return resolve_template(self._data)
|
return resolve_template(self._data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def events_dropped(self) -> int:
|
||||||
|
if self._session is None:
|
||||||
|
return 0
|
||||||
|
return self._session.events_dropped
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def data(self) -> Mapping[str, Any]:
|
def data(self) -> Mapping[str, Any]:
|
||||||
return self._data
|
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",
|
"no_slots": "Both LAN slots are busy",
|
||||||
"key_mismatch": "LAN key does not match the device",
|
"key_mismatch": "LAN key does not match the device",
|
||||||
"timeout": "Timed out waiting for 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": {
|
"entity": {
|
||||||
@@ -173,5 +175,19 @@
|
|||||||
"name": "Horizontal airflow direction"
|
"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",
|
"no_slots": "Both LAN slots are busy",
|
||||||
"key_mismatch": "LAN key does not match the device",
|
"key_mismatch": "LAN key does not match the device",
|
||||||
"timeout": "Timed out waiting for 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": {
|
"entity": {
|
||||||
@@ -173,5 +175,19 @@
|
|||||||
"name": "Horizontal airflow direction"
|
"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-слота заняты",
|
"no_slots": "Оба LAN-слота заняты",
|
||||||
"key_mismatch": "LAN-ключ не совпадает с устройством",
|
"key_mismatch": "LAN-ключ не совпадает с устройством",
|
||||||
"timeout": "Устройство не вышло на связь за отведённое время",
|
"timeout": "Устройство не вышло на связь за отведённое время",
|
||||||
"properties_missing": "Устройство подключилось, но не отдало базовые свойства"
|
"properties_missing": "Устройство подключилось, но не отдало базовые свойства",
|
||||||
|
"unique_id_mismatch": "Это другое устройство (не совпадает unique ID)",
|
||||||
|
"reconfigure_successful": "Перенастройка выполнена"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
@@ -173,5 +175,19 @@
|
|||||||
"name": "Положение заслонки (гориз.)"
|
"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 исчезнет, когда устройство выйдет на связь."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,5 +160,5 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
|||||||
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||||
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||||
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||||
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations |
|
||||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда |
|
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
|
|
||||||
from helpers import DSN, KEY_ID, LANIP_KEY
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.config_entries import SOURCE_RECONFIGURE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
@@ -413,3 +414,68 @@ async def test_import_json_null_device_port(hass: HomeAssistant):
|
|||||||
assert result["type"] == "create_entry"
|
assert result["type"] == "create_entry"
|
||||||
assert result["data"]["device_port"] == 80
|
assert result["data"]["device_port"] == 80
|
||||||
assert trial.call_args.args[0]["device_port"] == 80
|
assert trial.call_args.args[0]["device_port"] == 80
|
||||||
|
|
||||||
|
|
||||||
|
RECONFIGURE_ENTRY_DATA = {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"device_port": 9,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"name": "Old name",
|
||||||
|
"model": "AP-WC1E",
|
||||||
|
"template": "A",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
proc = mock_ac(["--set", "operation_mode=6"])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert _suggested(result["data_schema"], "host") == "127.0.0.1"
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "preview"
|
||||||
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "reconfigure_successful"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.data["name"] == "Renamed"
|
||||||
|
assert entry.state.name == "LOADED"
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_wrong_device(hass: HomeAssistant):
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(
|
||||||
|
hass,
|
||||||
|
result["flow_id"],
|
||||||
|
{**MANUAL_INPUT, "dsn": "AC000W00OTHER001"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "unique_id_mismatch"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Диагностика: параметры для ESPHome и маскировка ключа."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from helpers import DSN, LANIP_KEY
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from test_entities import MOCK_SETTINGS, _setup_entry
|
||||||
|
|
||||||
|
from custom_components.fglair.diagnostics import (
|
||||||
|
async_get_config_entry_diagnostics,
|
||||||
|
)
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 20.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise AssertionError("условие не выполнено за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||||
|
proc = mock_ac(MOCK_SETTINGS)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(
|
||||||
|
lambda: runtime.client.state == State.ONLINE
|
||||||
|
and runtime.client.cached(Prop.OPERATION_MODE) is not None
|
||||||
|
and runtime.client.cached(Prop.DISPLAY_TEMPERATURE) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
diag = await async_get_config_entry_diagnostics(hass, entry)
|
||||||
|
assert diag["config"]["dsn"] == DSN
|
||||||
|
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_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"
|
||||||
|
assert diag["runtime"]["events_dropped"] == 0
|
||||||
|
assert diag["runtime"]["values"]["operation_mode"]["raw"] == 6
|
||||||
|
assert diag["runtime"]["values"]["operation_mode"]["display"] == 6
|
||||||
|
assert (
|
||||||
|
diag["runtime"]["values"]["display_temperature"]["display"] == 200
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Repair issue при несовпадении LAN-ключа и его fix-flow."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from helpers import DSN, KEY_ID
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import DOMAIN
|
||||||
|
from pyfglair import State
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for(predicate, timeout: float = 20.0):
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
raise AssertionError("условие не выполнено за отведённое время")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_key_error_creates_and_fixes_issue(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
assert await async_setup_component(hass, "repairs", {})
|
||||||
|
proc = mock_ac([], key_id=KEY_ID + 1)
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
runtime = entry.runtime_data
|
||||||
|
await _wait_for(lambda: runtime.client.state == State.KEY_ERROR)
|
||||||
|
|
||||||
|
registry = ir.async_get(hass)
|
||||||
|
issue_id = f"key_mismatch_{DSN}"
|
||||||
|
issue = registry.async_get_issue(DOMAIN, issue_id)
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.is_fixable
|
||||||
|
assert issue.translation_key == "key_mismatch"
|
||||||
|
assert issue.translation_placeholders["device"] == "Mock AC"
|
||||||
|
|
||||||
|
flow_manager = hass.data["repairs"]["flow_manager"]
|
||||||
|
result = await flow_manager.async_init(DOMAIN, data={"issue_id": issue_id})
|
||||||
|
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.config_entries.async_unload(entry.entry_id)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Согласованность strings.json / translations en/ru."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||||
|
COMPONENT = ROOT / "custom_components" / "fglair"
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(node, prefix: str = "") -> set[str]:
|
||||||
|
keys: set[str] = set()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
keys.add(prefix + key)
|
||||||
|
keys |= _keys(value, prefix + key + ".")
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_en_matches_strings():
|
||||||
|
strings = json.loads((COMPONENT / "strings.json").read_text())
|
||||||
|
english = json.loads((COMPONENT / "translations" / "en.json").read_text())
|
||||||
|
assert strings == english
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
+4
-2
@@ -146,13 +146,15 @@ async def cloud():
|
|||||||
def mock_ac():
|
def mock_ac():
|
||||||
procs: list[Proc] = []
|
procs: list[Proc] = []
|
||||||
|
|
||||||
def start(extra: list[str] | None = None) -> Proc:
|
def start(
|
||||||
|
extra: list[str] | None = None, key_id: int | None = None
|
||||||
|
) -> Proc:
|
||||||
port = free_port()
|
port = free_port()
|
||||||
proc = Proc([
|
proc = Proc([
|
||||||
sys.executable, str(MOCK_AC),
|
sys.executable, str(MOCK_AC),
|
||||||
"--port", str(port),
|
"--port", str(port),
|
||||||
"--lanip-key", LANIP_KEY,
|
"--lanip-key", LANIP_KEY,
|
||||||
"--key-id", str(KEY_ID),
|
"--key-id", str(KEY_ID if key_id is None else key_id),
|
||||||
*(extra or []),
|
*(extra or []),
|
||||||
])
|
])
|
||||||
procs.append(proc)
|
procs.append(proc)
|
||||||
|
|||||||
Reference in New Issue
Block a user