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:
@@ -1,5 +1,10 @@
|
||||
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
|
||||
базовых свойств. Выполняется в executor (блокирующие вызовы ядра).
|
||||
базовых свойств и зондирование необязательных (presence).
|
||||
|
||||
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
|
||||
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
|
||||
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
|
||||
``answered`` и используются как дефолты фич и для определения шаблона.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,12 +16,48 @@ from typing import Iterable
|
||||
from pyfglair import Config, Error, Prop, Session, State, Template, Value
|
||||
from pyfglair.templates import template_info
|
||||
|
||||
BASE_PROPS: tuple[Prop, ...] = (
|
||||
# Обязательные свойства: есть во всех шаблонах, должны ответить.
|
||||
REQUIRED_PROPS: tuple[Prop, ...] = (
|
||||
Prop.OPERATION_MODE,
|
||||
Prop.FAN_SPEED,
|
||||
Prop.DEVICE_CAPABILITIES,
|
||||
Prop.DISPLAY_TEMPERATURE,
|
||||
)
|
||||
|
||||
# Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе —
|
||||
# информация для дефолтов фич и распознавания шаблона.
|
||||
OPTIONAL_PROPS: tuple[Prop, ...] = (
|
||||
Prop.AF_VERTICAL_DIRECTION,
|
||||
Prop.AF_HORIZONTAL_DIRECTION,
|
||||
Prop.AF_VERTICAL_NUM_DIR,
|
||||
Prop.AF_HORIZONTAL_NUM_DIR,
|
||||
Prop.AF_VERTICAL_SWING,
|
||||
Prop.AF_HORIZONTAL_SWING,
|
||||
Prop.HUMAN_DET_AUTO_SAVE,
|
||||
Prop.WIFI_LED_ENABLE,
|
||||
Prop.FILTER_SIGN_RESET_DISPLAY,
|
||||
Prop.MONITOR1,
|
||||
)
|
||||
|
||||
# Подписи, по которым распознаётся шаблон при неизвестной oem_model:
|
||||
# B отвечает move_step1, F — monitor1/filter_sign_reset (у A их нет).
|
||||
PROBE_PROPS: dict[Template, tuple[Prop, ...]] = {
|
||||
Template.A: (
|
||||
Prop.AF_VERTICAL_DIRECTION,
|
||||
Prop.DISPLAY_TEMPERATURE,
|
||||
Prop.FILTER_SIGN_RESET_DISPLAY,
|
||||
),
|
||||
Template.B: (
|
||||
Prop.AF_VERTICAL_MOVE_STEP1,
|
||||
Prop.AF_HORIZONTAL_MOVE_STEP1,
|
||||
),
|
||||
Template.F: (
|
||||
Prop.MONITOR1,
|
||||
Prop.FILTER_SIGN_RESET,
|
||||
Prop.AF_VERTICAL_DIRECTION,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrialResult:
|
||||
@@ -25,6 +66,7 @@ class TrialResult:
|
||||
state: State = State.IDLE
|
||||
error: Error = Error.NONE
|
||||
properties: dict[Prop, Value] = field(default_factory=dict)
|
||||
answered: set[Prop] = field(default_factory=set)
|
||||
|
||||
|
||||
def _failure_reason(state: State, error: Error) -> str:
|
||||
@@ -37,6 +79,24 @@ def _failure_reason(state: State, error: Error) -> str:
|
||||
return "timeout"
|
||||
|
||||
|
||||
def classify_template(answered: set[Prop]) -> Template | None:
|
||||
"""Шаблон по ответам зондов; None — недостаточно данных."""
|
||||
if Prop.AF_VERTICAL_MOVE_STEP1 in answered and (
|
||||
Prop.AF_VERTICAL_DIRECTION not in answered
|
||||
):
|
||||
return Template.B
|
||||
if Prop.MONITOR1 in answered or Prop.FILTER_SIGN_RESET in answered:
|
||||
if Prop.AF_VERTICAL_DIRECTION in answered:
|
||||
return Template.F
|
||||
if (
|
||||
Prop.AF_VERTICAL_DIRECTION in answered
|
||||
or Prop.DISPLAY_TEMPERATURE in answered
|
||||
or Prop.FILTER_SIGN_RESET_DISPLAY in answered
|
||||
):
|
||||
return Template.A
|
||||
return None
|
||||
|
||||
|
||||
def trial_connect(
|
||||
host: str,
|
||||
dsn: str,
|
||||
@@ -48,9 +108,12 @@ def trial_connect(
|
||||
listen_port: int = 10275,
|
||||
keepalive_ms: int = 15000,
|
||||
timeout: float = 10.0,
|
||||
props: Iterable[Prop] = BASE_PROPS,
|
||||
props: Iterable[Prop] = REQUIRED_PROPS,
|
||||
optional_props: Iterable[Prop] = OPTIONAL_PROPS,
|
||||
props_timeout: float = 3.0,
|
||||
) -> TrialResult:
|
||||
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства.
|
||||
"""Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
|
||||
необязательные свойства.
|
||||
|
||||
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
|
||||
"""
|
||||
@@ -59,6 +122,9 @@ def trial_connect(
|
||||
values: dict[Prop, Value] = {}
|
||||
valid = {info.prop for info in template_info(template)}
|
||||
wanted = tuple(prop for prop in props if prop in valid)
|
||||
optional = tuple(
|
||||
prop for prop in optional_props if prop in valid and prop not in wanted
|
||||
)
|
||||
|
||||
def on_state(state: State, error: Error) -> None:
|
||||
with lock:
|
||||
@@ -115,12 +181,15 @@ def trial_connect(
|
||||
state=state, error=error,
|
||||
)
|
||||
|
||||
if wanted:
|
||||
requested = wanted + optional
|
||||
if requested:
|
||||
session.batch_begin()
|
||||
for prop in wanted:
|
||||
for prop in requested:
|
||||
session.get_prop(prop)
|
||||
session.batch_commit()
|
||||
prop_deadline = min(deadline, time.monotonic() + 2.0)
|
||||
prop_deadline = min(
|
||||
deadline, time.monotonic() + props_timeout
|
||||
)
|
||||
while time.monotonic() < prop_deadline:
|
||||
with lock:
|
||||
if all(prop in values for prop in wanted):
|
||||
@@ -136,7 +205,49 @@ def trial_connect(
|
||||
reason="properties_missing",
|
||||
state=State.ONLINE,
|
||||
properties=snapshot,
|
||||
answered=set(snapshot),
|
||||
)
|
||||
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
|
||||
return TrialResult(
|
||||
ok=True,
|
||||
state=State.ONLINE,
|
||||
properties=snapshot,
|
||||
answered=set(snapshot),
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def probe_template(
|
||||
host: str,
|
||||
dsn: str,
|
||||
lanip_key: str,
|
||||
lanip_key_id: int,
|
||||
*,
|
||||
device_port: int = 80,
|
||||
listen_port: int = 10275,
|
||||
timeout: float = 10.0,
|
||||
) -> tuple[Template | None, dict[Prop, Value]]:
|
||||
"""Определяет шаблон по ответам, перебирая A/B/F короткими сессиями.
|
||||
|
||||
Используется только когда oem_model неизвестна ядру: для известных
|
||||
моделей шаблон берётся из C-таблиц без лишних подключений.
|
||||
"""
|
||||
answered: set[Prop] = set()
|
||||
properties: dict[Prop, Value] = {}
|
||||
for template in (Template.A, Template.B, Template.F):
|
||||
result = trial_connect(
|
||||
host, dsn, lanip_key, lanip_key_id,
|
||||
device_port=device_port, template=template,
|
||||
listen_port=listen_port, timeout=timeout,
|
||||
props=PROBE_PROPS[template] + (Prop.OPERATION_MODE,),
|
||||
optional_props=(),
|
||||
props_timeout=2.0,
|
||||
)
|
||||
if not result.ok:
|
||||
continue
|
||||
answered |= result.answered
|
||||
properties.update(result.properties)
|
||||
detected = classify_template(answered)
|
||||
if detected is not None:
|
||||
return detected, properties
|
||||
return classify_template(answered), properties
|
||||
|
||||
Reference in New Issue
Block a user