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
+189 -71
View File
@@ -22,14 +22,11 @@ from pyfglair.provision import Device
from pyfglair.templates import detect
from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON,
CONF_DEVICE_PORT,
CONF_DSN,
CONF_EMAIL,
CONF_FEATURES,
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
@@ -49,6 +46,8 @@ from .const import (
DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT,
DOMAIN,
FAN_VALUE_TO_MODE,
FEATURES,
REGIONS,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
@@ -56,8 +55,9 @@ from .const import (
TRIAL_ERRORS,
TRIAL_TIMEOUT,
)
from .features import resolve_features
from .overrides import ConversionSet, LinearOverride
from .trial import TrialResult, trial_connect
from .trial import TrialResult, probe_template, trial_connect
_LOGGER = logging.getLogger(__name__)
@@ -113,7 +113,24 @@ def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
return default
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
def _template_schema(template: Template) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
)
}
)
def _capabilities_schema(defaults: dict[str, bool]) -> vol.Schema:
fields = {}
for key in FEATURES:
fields[vol.Optional(key, default=bool(defaults.get(key)))] = bool
return vol.Schema(fields)
def _limits_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES)
spec = (
@@ -131,9 +148,6 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
step = TEMP_STEP_TENTHS[template]
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
),
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
@@ -156,44 +170,33 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
)
def _preview_text(
template: Template, properties: dict[Any, Any]
) -> str:
def _device_values_text(properties: dict[Any, Any]) -> str:
"""Что прочитано с устройства (человекочитаемо) для описаний шагов."""
def raw(prop) -> int | None:
value = properties.get(prop)
return value.int_value if value is not None else None
caps = raw(Prop.DEVICE_CAPABILITIES)
def supported(bit: int) -> bool:
return caps is None or bool(caps & (1 << bit))
hvac = ["off"] + [
mode.value for mode, bit in CAP_HVAC.items() if supported(bit)
]
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)]
tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10
step = TEMP_STEP_TENTHS[template] / 10
lines = [
f"- HVAC: {', '.join(hvac)}",
f"- Fan: {', '.join(fan) or '-'}",
f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C",
]
lines: list[str] = []
mode = raw(Prop.OPERATION_MODE)
if mode is not None:
names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
5: "fan", 6: "heat"}
lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
fan = raw(Prop.FAN_SPEED)
if fan is not None:
lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None:
lines.append(f"- Current temperature: {current / 10:.1f} °C")
swings = []
if supported(CAP_SWING_VERTICAL):
swings.append("vertical")
if supported(CAP_SWING_HORIZONTAL):
swings.append("horizontal")
if swings:
lines.append(f"- Swing: {', '.join(swings)}")
lines.append(f"- температура в помещении: {current / 10:.1f} °C")
caps = raw(Prop.DEVICE_CAPABILITIES)
if caps is not None:
lines.append(f"- Capabilities: 0x{caps:08X}")
lines.append(f"- Template: {template.name}")
return "\n".join(lines)
lines.append(f"- device_capabilities: 0x{caps:08X}")
vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
hnum = raw(Prop.AF_HORIZONTAL_NUM_DIR)
if vnum is not None or hnum is not None:
lines.append(f"- направлений заслонок: верт. {vnum or 0}, гориз. {hnum or 0}")
return "\n".join(lines) or "- данные не получены"
def _template_name(model: str | None) -> str:
@@ -263,17 +266,38 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
}
def _run_trial(data: dict[str, Any]) -> TrialResult:
return trial_connect(
def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
"""Пробная сессия; для неизвестной модели — предварительный пробинг A/B/F.
Возвращает (результат, шаблон, был_ли_пробинг).
"""
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
model = str(data.get(CONF_MODEL) or "")
probed = False
if not model or detect(model) is None:
detected, _probe_props = probe_template(
data[CONF_HOST],
data[CONF_DSN],
data[CONF_LANIP_KEY],
int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
probed = True
if detected is not None:
template = detected
result = trial_connect(
host=data[CONF_HOST],
dsn=data[CONF_DSN],
lanip_key=data[CONF_LANIP_KEY],
lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
template=Template[str(data.get(CONF_TEMPLATE, "A"))],
template=template,
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
return result, template, probed
class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -285,6 +309,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {}
self._trial_answered: set[Prop] = set()
self._resolved_template: Template = Template.A
self._probed: bool = False
self._selected_features: dict[str, bool] = {}
self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure(
@@ -391,7 +419,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
}
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
schema = vol.Schema(
{
@@ -413,7 +441,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input)
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions()
@@ -433,7 +461,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else:
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input)
@@ -447,16 +475,82 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
error = await self._async_trial(data)
if error is not None:
return self.async_abort(reason=error)
return self._create_entry(data)
entry_data = dict(self._pending_data)
entry_data[CONF_FEATURES] = resolve_features(
self._trial_properties, self._trial_answered
)
return self._create_entry(entry_data)
async def async_step_preview(
async def async_step_template(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Шаг 3: превью шаблона и ручные конверсии/диапазон уставки."""
"""Страница 1: шаблон (таблица свойств модуля) с пояснениями."""
data = dict(self._pending_data)
if user_input is not None:
template = Template[str(user_input[CONF_TEMPLATE])]
data[CONF_TEMPLATE] = template.name
self._pending_data = data
return await self.async_step_capabilities()
default = self._resolved_template.name
if self._reconfigure_entry is not None:
default = str(data.get(CONF_TEMPLATE, default))
placeholders = {
"device_values": _device_values_text(self._trial_properties),
"detected": default,
"model": str(data.get(CONF_MODEL) or "-"),
}
if self._probed:
placeholders["probe_note"] = (
"Модель модуля неизвестна — шаблон определён проверкой "
"свойств."
)
else:
placeholders["probe_note"] = (
"Шаблон определён по модели модуля."
)
return self.async_show_form(
step_id="template",
data_schema=self.add_suggested_values_to_schema(
_template_schema(Template[default]),
{CONF_TEMPLATE: default},
),
description_placeholders=placeholders,
)
async def async_step_capabilities(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 2: возможности прибора (тумблеры, дефолты с устройства)."""
if user_input is not None:
self._selected_features = {
key: bool(user_input.get(key, False)) for key in FEATURES
}
return await self.async_step_limits()
defaults = resolve_features(
self._trial_properties,
self._trial_answered,
self._reconfigure_defaults(),
)
schema = self.add_suggested_values_to_schema(
_capabilities_schema(defaults),
{key: value for key, value in defaults.items()},
)
return self.async_show_form(
step_id="capabilities",
data_schema=schema,
description_placeholders={
"device_values": _device_values_text(self._trial_properties),
},
)
async def async_step_limits(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 3: диапазон и шаг уставки (при необходимости — конверсия)."""
data = dict(self._pending_data)
errors: dict[str, str] = {}
if user_input is not None:
template = Template[user_input[CONF_TEMPLATE]]
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
@@ -482,11 +576,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
or bounds is None
or bounds[0] >= bounds[1]
):
# Достижимый диапазон пуст: конверсия выводит таблицу
# шаблона за пределы, которые не пересекаются с min/max.
errors["base"] = "invalid_range"
else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): {
@@ -497,29 +588,52 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
"max": temp_max,
}
}
data[CONF_FEATURES] = dict(self._selected_features)
_LOGGER.debug(
"превью: template=%s range=%d..%d step=%d",
"limits: template=%s range=%d..%d step=%d features=%d",
template.name, temp_min, temp_max, step,
len(self._selected_features),
)
return self._create_entry(data)
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
selected = str(user_input[CONF_TEMPLATE])
if user_input is None:
user_input = {CONF_TEMPLATE: selected}
return self.async_show_form(
step_id="preview",
data_schema=self.add_suggested_values_to_schema(
_preview_schema(data), user_input
),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[selected], self._trial_properties
)
},
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_limits_suggestions()
return self._show_form(
"limits",
_limits_schema(data),
errors,
user_input,
)
def _reconfigure_defaults(self) -> dict[str, bool] | None:
entry = self._reconfigure_entry
if entry is None:
return None
features = entry.data.get(CONF_FEATURES)
return features if isinstance(features, dict) else None
def _reconfigure_limits_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
spec = (entry.data.get(CONF_OVERRIDES) or {}).get(
Prop.ADJUST_TEMPERATURE.name.lower(), {}
)
if not isinstance(spec, dict):
return None
return {
CONF_TEMP_MIN: _spec_int(spec, "min", TEMP_MIN_TENTHS) / 10,
CONF_TEMP_MAX: _spec_int(spec, "max", TEMP_MAX_TENTHS) / 10,
CONF_TEMP_STEP: int(
entry.data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[
Template[str(entry.data.get(CONF_TEMPLATE, "A"))]
]
)
/ 10,
CONF_TEMP_NUM: _spec_int(spec, "num", 1),
CONF_TEMP_DEN: _spec_int(spec, "den", 1),
CONF_TEMP_OFFSET: _spec_int(spec, "offset", 0),
}
async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not None:
@@ -532,12 +646,16 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
result: TrialResult = await self.hass.async_add_executor_job(
result, template, probed = await self.hass.async_add_executor_job(
partial(_run_trial, data)
)
if result.ok:
self._pending_data = data
self._pending_data[CONF_TEMPLATE] = template.name
self._trial_properties = result.properties
self._trial_answered = result.answered
self._resolved_template = template
self._probed = probed
return None
_LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s",