From 976a2dcbb82b0ddc57b8da7b24ebbd68a759abc6 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 18:02:52 +0300 Subject: [PATCH] =?UTF-8?q?ha(ux):=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20=E2=80=94=20=D0=B1=D1=8B=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=B8=D0=BD=D0=B3=D0=B0,=20prefill=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0,=20=D0=BD=D0=B8=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - major: probe_template прекращает перебор на unreachable/no_slots/key_error (недоступный модуль: ~10 c вместо ~49 c), продолжает только при properties_missing (доступен, но чужой шаблон); +тест - m1: extra_state_attributes не падает до добавления в hass - m2: reconfigure берёт сохранённый шаблон как дефолт (не пересчитывает по модели); +тест - m3: device_capabilities/display_temperature переведены в необязательные зонды (trial не падает на молчащем caps; feature-модель трактует None как «не режем») - m4: async_sync_now(online_timeout) — честное имя/докстринг - ниты: удалены PRESET_CAP/as_dict/лишние плейсхолдеры, DIRECTION_KEYS упрощён, тавтология в тесте исправлена, en-тире - 59 components + 47 pyfglair/acceptance зелёные --- custom_components/fglair/config_flow.py | 14 +++++++--- custom_components/fglair/const.py | 7 +---- custom_components/fglair/coordinator.py | 9 ++++--- custom_components/fglair/entity.py | 2 +- custom_components/fglair/features.py | 12 +++------ custom_components/fglair/trial.py | 10 ++++--- tests/components/fglair/test_config_flow.py | 29 +++++++++++++++++++++ tests/components/fglair/test_features.py | 22 +++++++++++++--- 8 files changed, 76 insertions(+), 29 deletions(-) diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index dac6d5c..339d615 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -496,8 +496,6 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): 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"] = ( @@ -641,6 +639,12 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): # текущие конверсии, если путь их не задаёт. for key, value in self._reconfigure_entry.data.items(): data.setdefault(key, value) + # Сохранённый шаблон важнее пересчёта по модели: пользователь + # мог выбрать его вручную (страница подтвердит/изменит). + if self._reconfigure_entry.data.get(CONF_TEMPLATE): + data[CONF_TEMPLATE] = self._reconfigure_entry.data[ + CONF_TEMPLATE + ] await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) if self._reconfigure_entry is not None: self._abort_if_unique_id_mismatch() @@ -651,7 +655,11 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): ) if result.ok: self._pending_data = data - self._pending_data[CONF_TEMPLATE] = template.name + if ( + self._reconfigure_entry is None + or not self._reconfigure_entry.data.get(CONF_TEMPLATE) + ): + self._pending_data[CONF_TEMPLATE] = template.name self._trial_properties = result.properties self._trial_answered = result.answered self._resolved_template = template diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py index 7768ef9..f2083c3 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -94,11 +94,6 @@ PRESET_PROP = { PRESET_ECO: Prop.ECONOMY_MODE, PRESET_BOOST: Prop.POWERFUL_MODE, } -PRESET_CAP = { - PRESET_ECO: CAP_ECONOMY, - PRESET_BOOST: CAP_POWERFUL, -} - SWING_MODES = ["off", "on"] # switch-свойства: prop -> (translation_key, capability bit | None) @@ -218,7 +213,7 @@ ENTITY_DESCRIPTIONS = { "en": { "climate": "Air conditioner control: mode, fan, setpoint, louvers, presets", "room_temperature": "Room air temperature", - "error_code": "Air conditioner error code (0 — no errors)", + "error_code": "Air conditioner error code (0 = no errors)", "connection_state": "Connection state with the AC module", "connectivity": "Whether the module is reachable", "defrost": "Outdoor unit defrost in progress", diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py index adf2ff7..303770c 100644 --- a/custom_components/fglair/coordinator.py +++ b/custom_components/fglair/coordinator.py @@ -123,13 +123,14 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): # После разрыва при следующем ONLINE нужна повторная синхронизация. self._synced = False - async def async_sync_now(self, timeout: float = 2.0) -> None: + async def async_sync_now(self, online_timeout: float = 2.0) -> None: """Гарантирует первичный GET-батч и ожидание ключевых свойств. - Ждёт ONLINE (ограниченно), затем — завершения синхронизации, чтобы - сущности создавались с корректными feature-дефолтами. + Ждёт ONLINE не дольше ``online_timeout``, затем — завершения + синхронизации (сама синхронизация ограничена своим таймаутом), + чтобы сущности создавались с корректными feature-дефолтами. """ - deadline = self.hass.loop.time() + timeout + deadline = self.hass.loop.time() + online_timeout while ( self._sync_task is None and self.client.state != State.ONLINE diff --git a/custom_components/fglair/entity.py b/custom_components/fglair/entity.py index ada68b1..eae41fe 100644 --- a/custom_components/fglair/entity.py +++ b/custom_components/fglair/entity.py @@ -52,7 +52,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): def extra_state_attributes(self) -> Optional[dict]: """HA не поддерживает тултипы сущностей: краткое описание видно в атрибутах (More info → Атрибуты).""" - if self._description_key is None: + if self._description_key is None or self.hass is None: return None text = entity_description(self.hass.config.language, self._description_key) return {"description": text} if text else None diff --git a/custom_components/fglair/features.py b/custom_components/fglair/features.py index a4b83b1..4e99ef2 100644 --- a/custom_components/fglair/features.py +++ b/custom_components/fglair/features.py @@ -13,11 +13,8 @@ from pyfglair import Prop, Value from .const import FEATURES DIRECTION_KEYS = { - "af_vertical_direction": ("af_vertical_num_dir", Prop.AF_VERTICAL_NUM_DIR), - "af_horizontal_direction": ( - "af_horizontal_num_dir", - Prop.AF_HORIZONTAL_NUM_DIR, - ), + "af_vertical_direction": Prop.AF_VERTICAL_NUM_DIR, + "af_horizontal_direction": Prop.AF_HORIZONTAL_NUM_DIR, } @@ -44,7 +41,7 @@ def device_default( return True # возможностей не сообщили — не режем return bool(caps & (1 << bit)) if key in DIRECTION_KEYS: - num = _int_value(values, DIRECTION_KEYS[key][1]) + num = _int_value(values, DIRECTION_KEYS[key]) return bool(num and num > 1) if prop is not None: if answered is not None: @@ -113,6 +110,3 @@ class FeatureSet: if key in self._explicit: return self._explicit[key] return device_default(key, self._values) - - def as_dict(self) -> dict[str, bool]: - return dict(self._explicit) diff --git a/custom_components/fglair/trial.py b/custom_components/fglair/trial.py index 021a4cd..cbc69cc 100644 --- a/custom_components/fglair/trial.py +++ b/custom_components/fglair/trial.py @@ -20,13 +20,13 @@ from pyfglair.templates import template_info REQUIRED_PROPS: tuple[Prop, ...] = ( Prop.OPERATION_MODE, Prop.FAN_SPEED, - Prop.DEVICE_CAPABILITIES, - Prop.DISPLAY_TEMPERATURE, ) # Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе — # информация для дефолтов фич и распознавания шаблона. OPTIONAL_PROPS: tuple[Prop, ...] = ( + Prop.DEVICE_CAPABILITIES, + Prop.DISPLAY_TEMPERATURE, Prop.AF_VERTICAL_DIRECTION, Prop.AF_HORIZONTAL_DIRECTION, Prop.AF_VERTICAL_NUM_DIR, @@ -244,7 +244,11 @@ def probe_template( props_timeout=2.0, ) if not result.ok: - continue + if result.reason == "properties_missing": + # Модуль доступен, но свойств шаблона нет — пробуем дальше. + continue + # Недоступен/нет слотов/ключ — перебор шаблонов бессмыслен. + return None, properties answered |= result.answered properties.update(result.properties) detected = classify_template(answered) diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index 0013923..8359c7c 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -600,3 +600,32 @@ async def test_run_trial_probes_unknown_model(hass: HomeAssistant): assert template is Template.B assert probed is True probe.assert_called_once() + + +async def test_reconfigure_prefills_stored_template( + hass: HomeAssistant, mock_ac, socket_enabled +): + """При reconfigure дефолт — сохранённый шаблон, а не пересчитанный.""" + from test_entities import _setup_entry + + proc = mock_ac(["--set", "operation_mode=6"]) + entry = await _setup_entry(hass, proc.port) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "template": "B"} + ) + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=(TrialResult(ok=True), Template.A, False), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}, + ) + result = await _select(hass, result["flow_id"], "manual") + result = await _configure( + hass, result["flow_id"], {**MANUAL_INPUT, "host": "127.0.0.1", + "device_port": proc.port} + ) + assert result["step_id"] == "template" + assert _suggested(result["data_schema"], "template") == "B" + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/fglair/test_features.py b/tests/components/fglair/test_features.py index 5d16c58..eb6338b 100644 --- a/tests/components/fglair/test_features.py +++ b/tests/components/fglair/test_features.py @@ -60,9 +60,8 @@ def test_resolve_and_featureset_override(): resolved = resolve_features(values, set(), {"swing_vertical": True}) assert resolved["swing_vertical"] is True assert resolved["powerful_mode"] is False - assert len(resolved) == len( - {key for key in resolved} - ) + from custom_components.fglair.const import FEATURES + assert len(resolved) == len(FEATURES) feature_set = FeatureSet({"swing_vertical": True}, values) assert feature_set.enabled("swing_vertical") is True assert feature_set.enabled("powerful_mode") is False @@ -103,3 +102,20 @@ def test_resolved_features_for_ap_wc1e_without_sensor(): assert resolved["mode_cool"] is True assert resolved["economy_mode"] is True assert resolved["powerful_mode"] is False + + +def test_probe_stops_on_unreachable(monkeypatch): + """Недоступный модуль не перебираем трижды — выходим после первой сессии.""" + from custom_components.fglair import trial as tr + + calls: list = [] + + def fake_trial(*args, **kwargs): + calls.append(kwargs.get("template")) + return tr.TrialResult(ok=False, reason="unreachable") + + monkeypatch.setattr(tr, "trial_connect", fake_trial) + template, props = tr.probe_template("host", "dsn", "key", 1) + assert template is None + assert props == {} + assert calls == [Template.A]