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:
2026-09-29 14:06:39 +03:00
parent dee5819743
commit 57ee555704
15 changed files with 451 additions and 10 deletions
+9 -1
View File
@@ -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)}"
)
+34 -2
View File
@@ -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,7 +497,10 @@ 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)
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( 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)
+36 -1
View File
@@ -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:
+95
View File
@@ -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
+13
View File
@@ -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()
+17 -1
View File
@@ -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."
}
}
}
}
} }
} }
+17 -1
View File
@@ -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."
}
}
}
}
} }
} }
+17 -1
View File
@@ -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 исчезнет, когда устройство выйдет на связь."
}
}
}
}
} }
} }
+1 -1
View File
@@ -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)
+51
View File
@@ -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
View File
@@ -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)