ha(ux): мастер из трёх страниц, feature-модель и capabilities-фильтрация

- config flow: template (A/B/F с пояснениями, значения с устройства,
  определение по oem_model; для неизвестных моделей — пробинг A/B/F тремя
  короткими сессиями) -> capabilities (23 тумблера с описаниями, дефолты
  из device_capabilities/num_dir/ответов) -> limits (диапазон/шаг, ручная
  конверсия); каждая страница с пометкой «значения определены
  автоматически, можно пропустить»
- features.py: FeatureSet/LiveValues/device_default/resolve_features;
  entry.data["features"] хранит выбор пользователя; reconfigure
  предзаполняет; YAML-import получает дефолты
- сущности создаются только для включённых фич: climate (режимы/скорости/
  swing/пресеты), switch, select заслонок (caps+num_dir), датчик комнаты;
  исправлено появление select без ламелей
- trial: caps/num_dir и presence-зонды (необязательные свойства), answered;
  координатор ждёт первые свойства перед созданием сущностей (feature-
  дефолты без гонки)
- атрибут description у сущностей (HA не поддерживает тултипы) + описания
  фич в мастере (en/ru)
- тесты: features, probing, обновлённые flow/entities/repairs; 57 components
This commit is contained in:
2026-09-29 17:39:34 +03:00
parent 4ee428a6fd
commit d1c02d7c4b
20 changed files with 1131 additions and 212 deletions
+42 -3
View File
@@ -12,7 +12,7 @@ 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 pyfglair.templates import prop_info, template_info
from .const import (
AVAILABLE_STATES,
@@ -21,6 +21,7 @@ from .const import (
CONF_NAME,
DOMAIN,
)
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient
from .overrides import ConversionSet
@@ -40,6 +41,7 @@ class FglairRuntime:
client: FglairClient
coordinator: "FglairCoordinator"
conversions: ConversionSet
features: "FeatureSet"
def key_issue_id(dsn: str) -> str:
@@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
)
self.client = client
self.conversions = conversions
self.features = FeatureSet(
explicit=None, values=LiveValues(client.cached)
)
self.entry_id = entry_id
self.key_issue_id = key_issue_id(client.data[CONF_DSN])
self._synced = False
@@ -118,8 +123,19 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
# После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False
async def async_sync_now(self) -> None:
"""Гарантирует первичный GET-батч (если уже online)."""
async def async_sync_now(self, timeout: float = 2.0) -> None:
"""Гарантирует первичный GET-батч и ожидание ключевых свойств.
Ждёт ONLINE (ограниченно), затем — завершения синхронизации, чтобы
сущности создавались с корректными feature-дефолтами.
"""
deadline = self.hass.loop.time() + timeout
while (
self._sync_task is None
and self.client.state != State.ONLINE
and self.hass.loop.time() < deadline
):
await asyncio.sleep(0.05)
if self._sync_task is not None:
await self._sync_task
elif self.client.state == State.ONLINE and not self._synced:
@@ -169,12 +185,35 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
async def _async_initial_sync(self) -> None:
try:
await self.hass.async_add_executor_job(self._initial_sync)
await self._async_wait_first_props()
self._synced = True
except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась")
finally:
self._sync_task = None
async def _async_wait_first_props(self, timeout: float = 3.0) -> None:
"""Ждёт ключевые свойства (caps/режим/num_dir), чтобы сущности
создавались с корректными feature-дефолтами."""
template = self.client.template
wanted = [
Prop.OPERATION_MODE,
Prop.DEVICE_CAPABILITIES,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.DISPLAY_TEMPERATURE,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
]
wanted = [
prop for prop in wanted if prop_info(template, prop) is not None
]
deadline = self.hass.loop.time() + timeout
while self.hass.loop.time() < deadline:
if all(self.client.cached(prop) is not None for prop in wanted):
return
await asyncio.sleep(0.05)
def _initial_sync(self) -> None:
self.client.batch_begin()
for prop in _writable_props(self.client.template):