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 исчезнет, когда устройство выйдет на связь."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,5 +160,5 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
||||
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||
| 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` только оттуда |
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import SOURCE_RECONFIGURE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
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["data"]["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():
|
||||
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()
|
||||
proc = Proc([
|
||||
sys.executable, str(MOCK_AC),
|
||||
"--port", str(port),
|
||||
"--lanip-key", LANIP_KEY,
|
||||
"--key-id", str(KEY_ID),
|
||||
"--key-id", str(KEY_ID if key_id is None else key_id),
|
||||
*(extra or []),
|
||||
])
|
||||
procs.append(proc)
|
||||
|
||||
Reference in New Issue
Block a user