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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user