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
+129 -49
View File
@@ -14,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair
from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Value, ValueKind
from pyfglair import Prop, Template, Value, ValueKind
from pyfglair.provision import Device
MANUAL_INPUT = {
@@ -27,8 +27,7 @@ MANUAL_INPUT = {
"model": "AP-WC1E",
}
PREVIEW_INPUT = {
"template": "A",
LIMITS_INPUT = {
"temp_min": 16.0,
"temp_max": 30.0,
"temp_step": 0.5,
@@ -37,8 +36,20 @@ PREVIEW_INPUT = {
"temp_offset": 0,
}
TRIAL_OK = TrialResult(ok=True)
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = (
TrialResult(ok=False, reason="unreachable"),
Template.A,
False,
)
def _trial_ok(properties=None):
return (
TrialResult(ok=True, properties=properties or {}),
Template.A,
False,
)
async def _menu(hass: HomeAssistant):
@@ -57,6 +68,22 @@ async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input)
async def _pages(
hass: HomeAssistant,
flow_id: str,
*,
template: str = "A",
caps: dict | None = None,
limits: dict | None = None,
):
"""Проходит страницы template → capabilities → limits."""
result = await _configure(hass, flow_id, {"template": template})
assert result["step_id"] == "capabilities", result
result = await _configure(hass, flow_id, caps or {})
assert result["step_id"] == "limits", result
return await _configure(hass, flow_id, limits or LIMITS_INPUT)
def _suggested(schema, field: str):
for key in schema.schema:
if getattr(key, "schema", None) == field:
@@ -81,9 +108,8 @@ async def test_manual_success(hass: HomeAssistant):
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN
@@ -98,11 +124,13 @@ async def test_manual_success(hass: HomeAssistant):
"min": 160,
"max": 300,
}
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_preview_template_change_and_conversion(hass: HomeAssistant):
async def test_template_change_and_conversion(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
@@ -110,17 +138,20 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview_input = {
**PREVIEW_INPUT,
"template": "B",
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
}
result = await _configure(hass, result["flow_id"], preview_input)
result = await _pages(
hass,
result["flow_id"],
template="B",
limits={
**LIMITS_INPUT,
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry"
assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10
@@ -133,7 +164,7 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
}
async def test_preview_invalid_range(hass: HomeAssistant):
async def test_limits_invalid_range(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
@@ -141,24 +172,23 @@ async def test_preview_invalid_range(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "B"}
)
result = await _configure(hass, result["flow_id"], {})
assert result["step_id"] == "limits"
result = await _configure(
hass,
result["flow_id"],
{
**PREVIEW_INPUT,
"template": "B",
"temp_min": 30.0,
"temp_max": 16.0,
},
{**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
assert "Template: B" in result["description_placeholders"]["preview"]
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
assert result["type"] == "create_entry"
async def test_preview_unreachable_range(hass: HomeAssistant):
async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch(
"custom_components.fglair.config_flow._run_trial",
@@ -167,11 +197,15 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "A"}
)
result = await _configure(hass, result["flow_id"], {})
result = await _configure(
hass,
result["flow_id"],
{
**PREVIEW_INPUT,
**LIMITS_INPUT,
"temp_min": 30.0,
"temp_max": 40.0,
"temp_num": 1,
@@ -182,22 +216,37 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
assert result["errors"] == {"base": "invalid_range"}
async def test_preview_contains_trial_values(hass: HomeAssistant):
async def test_template_device_values(hass: HomeAssistant):
props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.OPERATION_MODE: Value(ValueKind.INT, 3),
Prop.FAN_SPEED: Value(ValueKind.INT, 4),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=True, properties=props),
return_value=(
TrialResult(ok=True, properties=props),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview = result["description_placeholders"]["preview"]
assert "20.0" in preview
assert "0x0000001B" in preview
assert "cool" in preview and "heat" in preview
assert result["step_id"] == "template"
values = result["description_placeholders"]["device_values"]
assert "20.0" in values
assert "0x0000001B" in values
assert "cool" in values
assert "направлений заслонок: верт. 5" in values
# capabilities: дефолты из маски, swing выключен
result = await _configure(hass, result["flow_id"], {"template": "A"})
assert result["step_id"] == "capabilities"
assert _suggested(result["data_schema"], "swing_vertical") is False
assert _suggested(result["data_schema"], "mode_cool") is True
assert _suggested(result["data_schema"], "af_vertical_direction") is True
async def test_manual_connect_error(hass: HomeAssistant):
@@ -215,7 +264,11 @@ async def test_manual_connect_error(hass: HomeAssistant):
async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=False, reason="key_mismatch"),
return_value=(
TrialResult(ok=False, reason="key_mismatch"),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
@@ -265,7 +318,7 @@ async def test_import_json_success(hass: HomeAssistant):
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
return_value=(TrialResult(ok=True), Template.B, False),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
@@ -273,11 +326,9 @@ async def test_import_json_success(hass: HomeAssistant):
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "preview"
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
result = await _configure(
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
)
result = await _pages(hass, result["flow_id"], template="B")
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B"
@@ -319,8 +370,8 @@ async def test_cloud_flow_success(hass: HomeAssistant):
)
assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID
@@ -374,7 +425,7 @@ async def test_yaml_import(hass: HomeAssistant):
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
return_value=(TrialResult(ok=True), Template.F, False),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done()
@@ -382,6 +433,7 @@ async def test_yaml_import(hass: HomeAssistant):
assert len(entries) == 1
assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant):
@@ -410,7 +462,8 @@ async def test_import_json_null_device_port(hass: HomeAssistant):
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80
@@ -448,8 +501,8 @@ async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled)
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["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
@@ -520,3 +573,30 @@ async def test_options_flow_new_key_reloads(
assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_run_trial_probes_unknown_model(hass: HomeAssistant):
"""Без oem_model шаблон определяется пробингом A/B/F."""
from custom_components.fglair import config_flow as cf
with patch.object(
cf, "probe_template", return_value=(Template.B, {})
) as probe, patch.object(
cf, "trial_connect", return_value=TrialResult(ok=True)
):
result = await hass.async_add_executor_job(
cf._run_trial,
{
"host": "127.0.0.1",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "",
"template": "A",
},
)
trial_result, template, probed = result
assert trial_result.ok
assert template is Template.B
assert probed is True
probe.assert_called_once()