diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md index 25119e5..13203ba 100644 --- a/custom_components/fglair/README.md +++ b/custom_components/fglair/README.md @@ -72,15 +72,22 @@ Assistant. Убедитесь, что `pyfglair` установлен в python- *Должно быть видно: список с одним устройством (имя, модель, host).* -6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных - значений (режимы, диапазон температур, пример конверсии) и, при - необходимости, ручные коэффициенты/диапазон уставки. Сверьте с - приложением FGLair и подтвердите. +6. **Три страницы настройки** (все значения уже подставлены по данным + устройства — можно просто нажимать «Далее»): + 1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели + модуля, а для неизвестных моделей — проверкой свойств. Рядом + показаны прочитанные с устройства значения. + 2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам + (режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями; + включены/выключены по `device_capabilities` и ответам устройства. + Сущности создаются только для включённых возможностей. + 3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и, + при необходимости, ручная конверсия коэффициентами. ![шаг 6](screenshots/step-6.png) - *Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг, - текущая температура, capabilities, кнопки Submit.* + *Должно быть видно: страница «Возможности прибора» с тумблерами и + описаниями, часть выключена по данным устройства.* 7. **Готово**: карточка устройства с сущностями climate/switch/select/ sensor/binary_sensor. @@ -121,8 +128,11 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl | sensor | температура в помещении, код ошибки, диагностическое состояние связи | | binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) | -Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`; -режимы, которыми прибор не управляет, не создаются. +Список зависит от шаблона устройства (A/B/F), маски `device_capabilities` +и выбранных в мастере возможностей: то, что прибор не умеет или что +отключено, не создаётся. У каждой сущности есть краткое описание в +атрибуте `description` (карточка сущности → «Атрибуты»): HA не +поддерживает нативные тултипы, поэтому описание видно там. ## Диагностика и известные ситуации diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index 93f3201..bd88c54 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -11,8 +11,9 @@ from homeassistant.helpers import issue_registry as ir import pyfglair -from .const import CONF_DSN, DOMAIN +from .const import CONF_DSN, CONF_FEATURES, DOMAIN from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id +from .features import FeatureSet, LiveValues from .fglair_client import FglairClient from .overrides import ConversionSet @@ -80,8 +81,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: hass.data[DATA_PENDING] -= 1 + features = FeatureSet( + explicit=entry.data.get(CONF_FEATURES), + values=LiveValues(coordinator.client.cached), + ) + coordinator.features = features + clients[entry.entry_id] = client - entry.runtime_data = FglairRuntime(client, coordinator, conversions) + entry.runtime_data = FglairRuntime(client, coordinator, conversions, features) if len(clients) == 1: pyfglair.set_log_handler(_log_handler) pyfglair.set_log_level(1) diff --git a/custom_components/fglair/climate.py b/custom_components/fglair/climate.py index a066a88..ea8e6a2 100644 --- a/custom_components/fglair/climate.py +++ b/custom_components/fglair/climate.py @@ -16,10 +16,6 @@ from pyfglair import Prop from pyfglair.templates import prop_info from .const import ( - CAP_FAN, - CAP_HVAC, - CAP_SWING_HORIZONTAL, - CAP_SWING_VERTICAL, FAN_MODE_TO_VALUE, FAN_VALUE_TO_MODE, HVAC_MODE_TO_VALUE, @@ -27,7 +23,6 @@ from .const import ( OPERATION_MODE_OFF, OPERATION_MODE_ON, PRESET_BOOST, - PRESET_CAP, PRESET_ECO, PRESET_PROP, SWING_MODES, @@ -41,13 +36,24 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: async_add_entities([FglairClimate(runtime.coordinator)]) +MODE_FEATURE = { + HVACMode.AUTO: "mode_auto", + HVACMode.COOL: "mode_cool", + HVACMode.DRY: "mode_dry", + HVACMode.FAN_ONLY: "mode_fan", + HVACMode.HEAT: "mode_heat", +} +FAN_FEATURE = {mode: f"fan_{mode}" for mode in FAN_VALUE_TO_MODE.values()} +PRESET_FEATURE = {PRESET_ECO: "economy_mode", PRESET_BOOST: "powerful_mode"} + + class FglairClimate(FglairEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, coordinator: FglairCoordinator) -> None: # translation_key не нужен: имя = имя устройства (has_entity_name). - super().__init__(coordinator, "climate", None) + super().__init__(coordinator, "climate", None, description_key="climate") self._last_hvac_mode: Optional[HVACMode] = None # -- свойства шаблона -------------------------------------------------- @@ -56,37 +62,37 @@ class FglairClimate(FglairEntity, ClimateEntity): def _hvac_modes(self) -> list[HVACMode]: modes: list[HVACMode] = [HVACMode.OFF] - for mode, bit in CAP_HVAC.items(): - if self.supports_capability(bit): + for mode, key in MODE_FEATURE.items(): + if self.feature_enabled(key): modes.append(mode) return modes def _fan_modes(self) -> Optional[list[str]]: if not self._in_template(Prop.FAN_SPEED): return None - return [ - mode - for mode, bit in CAP_FAN.items() - if self.supports_capability(bit) + modes = [ + mode for mode, key in FAN_FEATURE.items() + if self.feature_enabled(key) ] + return modes or None def _preset_modes(self) -> Optional[list[str]]: presets = [ preset - for preset, bit in PRESET_CAP.items() + for preset, key in PRESET_FEATURE.items() if self._in_template(PRESET_PROP[preset]) - and self.supports_capability(bit) + and self.feature_enabled(key) ] return presets or None def _vertical_swing_supported(self) -> bool: return self._in_template(Prop.AF_VERTICAL_SWING) and ( - self.supports_capability(CAP_SWING_VERTICAL) + self.feature_enabled("swing_vertical") ) def _horizontal_swing_supported(self) -> bool: return self._in_template(Prop.AF_HORIZONTAL_SWING) and ( - self.supports_capability(CAP_SWING_HORIZONTAL) + self.feature_enabled("swing_horizontal") ) # -- ClimateEntity ---------------------------------------------------- diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index 23544a7..dac6d5c 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -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", diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py index 9e58d5a..7768ef9 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -24,6 +24,7 @@ CONF_PASSWORD = "password" CONF_CONFIG_JSON = "config_json" CONF_OVERRIDES = "overrides" CONF_TEMP_STEP = "temp_step" +CONF_FEATURES = "features" CONF_TEMP_MIN = "temp_min" CONF_TEMP_MAX = "temp_max" CONF_TEMP_NUM = "temp_num" @@ -112,6 +113,131 @@ SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = { Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL), } +# --------------------------------------------------------------------------- +# Feature-модель (план: страница capabilities в мастере + фильтрация сущностей). +# key -> (capability bit | None, свойство-источник | None). +# Для битовых фич дефолт = бит в device_capabilities; для небитовых — +# наличие свойства в ответах устройства; для заслонок — num_dir > 1. +# --------------------------------------------------------------------------- +FEATURES: dict[str, tuple[int | None, Prop | None]] = { + "mode_cool": (CAP_HVAC[HVACMode.COOL], Prop.OPERATION_MODE), + "mode_dry": (CAP_HVAC[HVACMode.DRY], Prop.OPERATION_MODE), + "mode_fan": (CAP_HVAC[HVACMode.FAN_ONLY], Prop.OPERATION_MODE), + "mode_heat": (CAP_HVAC[HVACMode.HEAT], Prop.OPERATION_MODE), + "mode_auto": (CAP_HVAC[HVACMode.AUTO], Prop.OPERATION_MODE), + "fan_quiet": (CAP_FAN["quiet"], Prop.FAN_SPEED), + "fan_low": (CAP_FAN["low"], Prop.FAN_SPEED), + "fan_medium": (CAP_FAN["medium"], Prop.FAN_SPEED), + "fan_high": (CAP_FAN["high"], Prop.FAN_SPEED), + "fan_auto": (CAP_FAN["auto"], Prop.FAN_SPEED), + "swing_vertical": (CAP_SWING_VERTICAL, Prop.AF_VERTICAL_SWING), + "swing_horizontal": (CAP_SWING_HORIZONTAL, Prop.AF_HORIZONTAL_SWING), + "economy_mode": (CAP_ECONOMY, Prop.ECONOMY_MODE), + "min_heat": (CAP_MIN_HEAT, Prop.MIN_HEAT), + "indoor_fan_control": (CAP_INDOOR_FAN_CONTROL, Prop.INDOOR_FAN_CONTROL), + "powerful_mode": (CAP_POWERFUL, Prop.POWERFUL_MODE), + "outdoor_low_noise": (CAP_OUTDOOR_LOW_NOISE, Prop.OUTDOOR_LOW_NOISE), + "coil_dry_mode": (CAP_COIL_DRY, Prop.COIL_DRY_MODE), + "display_temperature": (None, Prop.DISPLAY_TEMPERATURE), + "human_det_auto_save": (None, Prop.HUMAN_DET_AUTO_SAVE), + "wifi_led_enable": (None, Prop.WIFI_LED_ENABLE), + "af_vertical_direction": (None, Prop.AF_VERTICAL_DIRECTION), + "af_horizontal_direction": (None, Prop.AF_HORIZONTAL_DIRECTION), +} + +# Описания для мастера и для атрибута description сущностей. +FEATURE_DESCRIPTIONS = { + "ru": { + "mode_cool": "Охлаждение", + "mode_dry": "Осушение", + "mode_fan": "Вентиляция (без нагрева/охлаждения)", + "mode_heat": "Обогрев", + "mode_auto": "Автоматический режим", + "fan_auto": "Автоскорость вентилятора", + "fan_quiet": "Тихая скорость", + "fan_low": "Низкая скорость", + "fan_medium": "Средняя скорость", + "fan_high": "Высокая скорость", + "swing_vertical": "Качание заслонки по вертикали", + "swing_horizontal": "Качание заслонки по горизонтали", + "economy_mode": "Экономичный режим", + "min_heat": "Минимальный обогрев (поддержание +10 °C)", + "indoor_fan_control": "Управление вентилятором при работе наружного блока", + "powerful_mode": "Мощный режим (ускоренный выход на режим)", + "outdoor_low_noise": "Тихий наружный блок", + "coil_dry_mode": "Сушка испарителя", + "display_temperature": "Датчик температуры помещения", + "human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)", + "wifi_led_enable": "Индикатор Wi-Fi на модуле", + "af_vertical_direction": "Выбор положения вертикальной заслонки", + "af_horizontal_direction": "Выбор положения горизонтальной заслонки", + }, + "en": { + "mode_cool": "Cooling", + "mode_dry": "Dry", + "mode_fan": "Fan only", + "mode_heat": "Heating", + "mode_auto": "Automatic mode", + "fan_auto": "Auto fan speed", + "fan_quiet": "Quiet fan speed", + "fan_low": "Low fan speed", + "fan_medium": "Medium fan speed", + "fan_high": "High fan speed", + "swing_vertical": "Vertical louver swing", + "swing_horizontal": "Horizontal louver swing", + "economy_mode": "Economy mode", + "min_heat": "Minimum heat (keep +10 °C)", + "indoor_fan_control": "Indoor fan control while outdoor unit runs", + "powerful_mode": "Powerful mode", + "outdoor_low_noise": "Outdoor unit low noise", + "coil_dry_mode": "Coil dry", + "display_temperature": "Room temperature sensor", + "human_det_auto_save": "Human detection auto save", + "wifi_led_enable": "Wi-Fi LED indicator", + "af_vertical_direction": "Vertical louver position", + "af_horizontal_direction": "Horizontal louver position", + }, +} + +ENTITY_DESCRIPTIONS = { + "ru": { + "climate": "Управление кондиционером: режим, скорость, уставка, " + "заслонки, пресеты", + "room_temperature": "Температура воздуха в помещении", + "error_code": "Код ошибки кондиционера (0 — ошибок нет)", + "connection_state": "Состояние связи с модулем кондиционера", + "connectivity": "Есть ли связь с модулем", + "defrost": "Идёт разморозка наружного блока", + "maintenance": "Требуется обслуживание", + "oil_recovery": "Возврат масла в компрессор", + "pump_down": "Откачка хладагента (pump down)", + "check_operation": "Проверочный прогон", + "simultaneous": "Одновременные операции", + "different_modes": "Разморозка/масло/прочие режимы (шаблон B)", + }, + "en": { + "climate": "Air conditioner control: mode, fan, setpoint, louvers, presets", + "room_temperature": "Room air temperature", + "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", + "maintenance": "Maintenance required", + "oil_recovery": "Compressor oil recovery", + "pump_down": "Refrigerant pump down", + "check_operation": "Check operation", + "simultaneous": "Simultaneous operations", + "different_modes": "Defrost/oil/other modes (template B)", + }, +} + + +def entity_description(language: str, key: str) -> str | None: + lang = "ru" if str(language).lower().startswith("ru") else "en" + return ENTITY_DESCRIPTIONS[lang].get(key) or FEATURE_DESCRIPTIONS[lang].get( + key + ) + # op_status, биты (PROTOCOL §8.4): translation_key -> bit OP_STATUS_BITS: dict[str, int] = { "defrost": 24, diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py index 8344619..adf2ff7 100644 --- a/custom_components/fglair/coordinator.py +++ b/custom_components/fglair/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind -from pyfglair.templates import template_info +from pyfglair.templates import prop_info, template_info from .const import ( AVAILABLE_STATES, @@ -21,6 +21,7 @@ from .const import ( CONF_NAME, DOMAIN, ) +from .features import FeatureSet, LiveValues from .fglair_client import FglairClient from .overrides import ConversionSet @@ -40,6 +41,7 @@ class FglairRuntime: client: FglairClient coordinator: "FglairCoordinator" conversions: ConversionSet + features: "FeatureSet" def key_issue_id(dsn: str) -> str: @@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): ) self.client = client self.conversions = conversions + self.features = FeatureSet( + explicit=None, values=LiveValues(client.cached) + ) self.entry_id = entry_id self.key_issue_id = key_issue_id(client.data[CONF_DSN]) self._synced = False @@ -118,8 +123,19 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): # После разрыва при следующем ONLINE нужна повторная синхронизация. self._synced = False - async def async_sync_now(self) -> None: - """Гарантирует первичный GET-батч (если уже online).""" + async def async_sync_now(self, timeout: float = 2.0) -> None: + """Гарантирует первичный GET-батч и ожидание ключевых свойств. + + Ждёт ONLINE (ограниченно), затем — завершения синхронизации, чтобы + сущности создавались с корректными feature-дефолтами. + """ + deadline = self.hass.loop.time() + timeout + while ( + self._sync_task is None + and self.client.state != State.ONLINE + and self.hass.loop.time() < deadline + ): + await asyncio.sleep(0.05) if self._sync_task is not None: await self._sync_task elif self.client.state == State.ONLINE and not self._synced: @@ -169,12 +185,35 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): async def _async_initial_sync(self) -> None: try: await self.hass.async_add_executor_job(self._initial_sync) + await self._async_wait_first_props() self._synced = True except Exception: # pragma: no cover - защитный путь _LOGGER.exception("первичная синхронизация свойств не удалась") finally: self._sync_task = None + async def _async_wait_first_props(self, timeout: float = 3.0) -> None: + """Ждёт ключевые свойства (caps/режим/num_dir), чтобы сущности + создавались с корректными feature-дефолтами.""" + template = self.client.template + wanted = [ + Prop.OPERATION_MODE, + Prop.DEVICE_CAPABILITIES, + Prop.AF_VERTICAL_NUM_DIR, + Prop.AF_HORIZONTAL_NUM_DIR, + Prop.DISPLAY_TEMPERATURE, + Prop.HUMAN_DET_AUTO_SAVE, + Prop.WIFI_LED_ENABLE, + ] + wanted = [ + prop for prop in wanted if prop_info(template, prop) is not None + ] + deadline = self.hass.loop.time() + timeout + while self.hass.loop.time() < deadline: + if all(self.client.cached(prop) is not None for prop in wanted): + return + await asyncio.sleep(0.05) + def _initial_sync(self) -> None: self.client.batch_begin() for prop in _writable_props(self.client.template): diff --git a/custom_components/fglair/entity.py b/custom_components/fglair/entity.py index 743c445..ada68b1 100644 --- a/custom_components/fglair/entity.py +++ b/custom_components/fglair/entity.py @@ -8,7 +8,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from pyfglair import Prop, Value -from .const import CONF_DSN, CONF_MODEL, CONF_NAME, DOMAIN +from .const import ( + CONF_DSN, + CONF_MODEL, + CONF_NAME, + DOMAIN, + entity_description, +) from .coordinator import FglairCoordinator @@ -22,6 +28,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): coordinator: FglairCoordinator, unique_suffix: str, translation_key: Optional[str], + description_key: Optional[str] = None, ) -> None: super().__init__(coordinator) data = coordinator.client.data @@ -29,6 +36,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): self._attr_unique_id = f"{dsn}_{unique_suffix}" if translation_key is not None: self._attr_translation_key = translation_key + self._description_key = description_key or translation_key self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, dsn)}, name=data.get(CONF_NAME) or dsn, @@ -40,6 +48,18 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): def available(self) -> bool: return self.coordinator.data.available and super().available + @property + def extra_state_attributes(self) -> Optional[dict]: + """HA не поддерживает тултипы сущностей: краткое описание видно в + атрибутах (More info → Атрибуты).""" + if self._description_key is None: + return None + text = entity_description(self.hass.config.language, self._description_key) + return {"description": text} if text else None + + def feature_enabled(self, key: str) -> bool: + return self.coordinator.features.enabled(key) + def value(self, prop: Prop) -> Optional[Value]: return self.coordinator.data.values.get(prop) @@ -54,19 +74,4 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): return None return self.coordinator.conversions.to_display(prop, raw) - def capabilities(self) -> Optional[int]: - return self.raw_value(Prop.DEVICE_CAPABILITIES) - def capability(self, bit: int) -> Optional[bool]: - caps = self.capabilities() - if not caps: - # 0/None: возможностей ещё не сообщили — не фильтруем. - return None - return bool(caps & (1 << bit)) - - def supports_capability(self, bit: Optional[int]) -> bool: - """None — считать поддержанным (capabilities ещё не пришли).""" - if bit is None: - return True - cap = self.capability(bit) - return True if cap is None else cap diff --git a/custom_components/fglair/features.py b/custom_components/fglair/features.py new file mode 100644 index 0000000..a4b83b1 --- /dev/null +++ b/custom_components/fglair/features.py @@ -0,0 +1,118 @@ +"""Feature-модель: какие возможности реально есть у прибора. + +Дефолты берутся из `device_capabilities` и фактических ответов устройства, +пользователь может переопределить их в мастере (и в reconfigure); результат +хранится в entry.data[CONF_FEATURES] и фильтрует создание сущностей. +""" +from __future__ import annotations + +from typing import Any, Mapping, Optional + +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, + ), +} + + +def _int_value(values: Mapping[Prop, Value], prop: Prop) -> Optional[int]: + value = values.get(prop) + return value.int_value if value is not None else None + + +def _caps(values: Mapping[Prop, Value]) -> Optional[int]: + raw = _int_value(values, Prop.DEVICE_CAPABILITIES) + return raw if raw else None + + +def device_default( + key: str, + values: Mapping[Prop, Value], + answered: Optional[set[Prop]] = None, +) -> bool: + """Дефолт фичи по данным устройства (без пользовательских override).""" + bit, prop = FEATURES[key] + if bit is not None: + caps = _caps(values) + if caps is None: + return True # возможностей не сообщили — не режем + return bool(caps & (1 << bit)) + if key in DIRECTION_KEYS: + num = _int_value(values, DIRECTION_KEYS[key][1]) + return bool(num and num > 1) + if prop is not None: + if answered is not None: + return prop in answered + return prop in values + return True + + +def resolve_features( + values: Mapping[Prop, Value], + answered: Optional[set[Prop]] = None, + explicit: Optional[Mapping[str, Any]] = None, +) -> dict[str, bool]: + """Полный набор фич: дефолт с устройства + пользовательские значения.""" + resolved = { + key: bool(value) + for key, value in ( + explicit or {} + ).items() + if key in FEATURES + } + for key in FEATURES: + resolved.setdefault(key, device_default(key, values, answered)) + return resolved + + +class LiveValues(Mapping): + """Mapping поверх client.cached(prop) — значения кэша ядра «на сейчас». + + Используется вместо снимка coordinator.data, чтобы фильтрация сущностей + не зависела от гонки с обработкой push-событий в loop. + """ + + def __init__(self, getter) -> None: + self._getter = getter + + def __getitem__(self, prop: Prop) -> Value: + value = self._getter(prop) + if value is None: + raise KeyError(prop) + return value + + def __iter__(self): + return iter(()) + + def __len__(self) -> int: + return 0 + + +class FeatureSet: + """Фильтр создания сущностей для runtime (entry + live-значения ядра).""" + + def __init__( + self, + explicit: Optional[Mapping[str, Any]], + values: Mapping[Prop, Value], + ) -> None: + self._explicit = { + key: bool(value) + for key, value in (explicit or {}).items() + if key in FEATURES + } + self._values = values + + def enabled(self, key: str) -> bool: + 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/select.py b/custom_components/fglair/select.py index 8ddfbf6..d6039ce 100644 --- a/custom_components/fglair/select.py +++ b/custom_components/fglair/select.py @@ -48,7 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: entities = [] if ( - prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None + coordinator.features.enabled("af_vertical_direction") + and prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None ): entities.append( @@ -60,7 +61,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: ) ) if ( - prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None + coordinator.features.enabled("af_horizontal_direction") + and prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None ): entities.append( diff --git a/custom_components/fglair/sensor.py b/custom_components/fglair/sensor.py index b70ef6c..b939e36 100644 --- a/custom_components/fglair/sensor.py +++ b/custom_components/fglair/sensor.py @@ -71,7 +71,9 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: props = {info.prop for info in template_info(template)} entities = [FglairConnectionState(coordinator)] - if Prop.DISPLAY_TEMPERATURE in props: + if Prop.DISPLAY_TEMPERATURE in props and runtime.features.enabled( + "display_temperature" + ): entities.append(FglairRoomTemperature(coordinator)) if Prop.ERROR_CODE in props: entities.append(FglairErrorCode(coordinator)) diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index ba3c5f3..33aaf07 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -52,11 +52,74 @@ "config_json": "Paste a JSON object produced by fglair-discover or fglctl" } }, - "preview": { - "title": "Template preview", - "description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", + "template": { + "title": "Module template", + "description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.", + "data": { + "template": "Template" + }, + "data_description": { + "template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)." + } + }, + "capabilities": { + "title": "Device capabilities", + "description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.", + "data": { + "mode_cool": "Cooling", + "mode_dry": "Dry", + "mode_fan": "Fan only", + "mode_heat": "Heating", + "mode_auto": "Automatic", + "fan_quiet": "Quiet fan", + "fan_low": "Low fan", + "fan_medium": "Medium fan", + "fan_high": "High fan", + "fan_auto": "Auto fan", + "swing_vertical": "Vertical swing", + "swing_horizontal": "Horizontal swing", + "economy_mode": "Economy", + "min_heat": "Minimum heat", + "indoor_fan_control": "Indoor fan control", + "powerful_mode": "Powerful", + "outdoor_low_noise": "Outdoor low noise", + "coil_dry_mode": "Coil dry", + "display_temperature": "Room temperature sensor", + "human_det_auto_save": "Human detection auto save", + "wifi_led_enable": "Wi-Fi LED", + "af_vertical_direction": "Vertical louver position", + "af_horizontal_direction": "Horizontal louver position" + }, + "data_description": { + "mode_cool": "Cooling", + "mode_dry": "Dry", + "mode_fan": "Fan only", + "mode_heat": "Heating", + "mode_auto": "Automatic mode", + "fan_auto": "Auto fan speed", + "fan_quiet": "Quiet fan speed", + "fan_low": "Low fan speed", + "fan_medium": "Medium fan speed", + "fan_high": "High fan speed", + "swing_vertical": "Vertical louver swing", + "swing_horizontal": "Horizontal louver swing", + "economy_mode": "Economy mode", + "min_heat": "Minimum heat (keep +10 °C)", + "indoor_fan_control": "Indoor fan control while outdoor unit runs", + "powerful_mode": "Powerful mode", + "outdoor_low_noise": "Outdoor unit low noise", + "coil_dry_mode": "Coil dry", + "display_temperature": "Room temperature sensor", + "human_det_auto_save": "Human detection auto save", + "wifi_led_enable": "Wi-Fi LED indicator", + "af_vertical_direction": "Vertical louver position", + "af_horizontal_direction": "Horizontal louver position" + } + }, + "limits": { + "title": "Setpoint (range and step)", + "description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.", "data": { - "template": "Template", "temp_min": "Minimum setpoint (°C)", "temp_max": "Maximum setpoint (°C)", "temp_step": "Setpoint step (°C)", @@ -66,8 +129,8 @@ }, "data_description": { "temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", - "temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", - "temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" + "temp_den": "display = raw × num / den + offset", + "temp_offset": "display = raw × num / den + offset" } } }, diff --git a/custom_components/fglair/switch.py b/custom_components/fglair/switch.py index 3a4e381..496acf1 100644 --- a/custom_components/fglair/switch.py +++ b/custom_components/fglair/switch.py @@ -40,21 +40,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: template = runtime.client.template entities = [] - for prop, (key, capability) in SWITCH_PROPS.items(): + for prop, (key, _capability) in SWITCH_PROPS.items(): if prop_info(template, prop) is None: continue - if not _capability_supported(coordinator, capability): + if not coordinator.features.enabled(key): continue entities.append(FglairSwitch(coordinator, prop, key)) async_add_entities(entities) - - -def _capability_supported( - coordinator: FglairCoordinator, bit: int | None -) -> bool: - if bit is None: - return True - caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES) - if caps is None or not caps.int_value: - return True - return bool(caps.int_value & (1 << bit)) diff --git a/custom_components/fglair/translations/en.json b/custom_components/fglair/translations/en.json index ba3c5f3..33aaf07 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -52,11 +52,74 @@ "config_json": "Paste a JSON object produced by fglair-discover or fglctl" } }, - "preview": { - "title": "Template preview", - "description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", + "template": { + "title": "Module template", + "description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.", + "data": { + "template": "Template" + }, + "data_description": { + "template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)." + } + }, + "capabilities": { + "title": "Device capabilities", + "description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.", + "data": { + "mode_cool": "Cooling", + "mode_dry": "Dry", + "mode_fan": "Fan only", + "mode_heat": "Heating", + "mode_auto": "Automatic", + "fan_quiet": "Quiet fan", + "fan_low": "Low fan", + "fan_medium": "Medium fan", + "fan_high": "High fan", + "fan_auto": "Auto fan", + "swing_vertical": "Vertical swing", + "swing_horizontal": "Horizontal swing", + "economy_mode": "Economy", + "min_heat": "Minimum heat", + "indoor_fan_control": "Indoor fan control", + "powerful_mode": "Powerful", + "outdoor_low_noise": "Outdoor low noise", + "coil_dry_mode": "Coil dry", + "display_temperature": "Room temperature sensor", + "human_det_auto_save": "Human detection auto save", + "wifi_led_enable": "Wi-Fi LED", + "af_vertical_direction": "Vertical louver position", + "af_horizontal_direction": "Horizontal louver position" + }, + "data_description": { + "mode_cool": "Cooling", + "mode_dry": "Dry", + "mode_fan": "Fan only", + "mode_heat": "Heating", + "mode_auto": "Automatic mode", + "fan_auto": "Auto fan speed", + "fan_quiet": "Quiet fan speed", + "fan_low": "Low fan speed", + "fan_medium": "Medium fan speed", + "fan_high": "High fan speed", + "swing_vertical": "Vertical louver swing", + "swing_horizontal": "Horizontal louver swing", + "economy_mode": "Economy mode", + "min_heat": "Minimum heat (keep +10 °C)", + "indoor_fan_control": "Indoor fan control while outdoor unit runs", + "powerful_mode": "Powerful mode", + "outdoor_low_noise": "Outdoor unit low noise", + "coil_dry_mode": "Coil dry", + "display_temperature": "Room temperature sensor", + "human_det_auto_save": "Human detection auto save", + "wifi_led_enable": "Wi-Fi LED indicator", + "af_vertical_direction": "Vertical louver position", + "af_horizontal_direction": "Horizontal louver position" + } + }, + "limits": { + "title": "Setpoint (range and step)", + "description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.", "data": { - "template": "Template", "temp_min": "Minimum setpoint (°C)", "temp_max": "Maximum setpoint (°C)", "temp_step": "Setpoint step (°C)", @@ -66,8 +129,8 @@ }, "data_description": { "temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", - "temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", - "temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" + "temp_den": "display = raw × num / den + offset", + "temp_offset": "display = raw × num / den + offset" } } }, diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index c440e83..0ad839c 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -52,11 +52,74 @@ "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" } }, - "preview": { - "title": "Превью шаблона", - "description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.", + "template": { + "title": "Шаблон модуля", + "description": "С устройства прочитано:\n\n{device_values}\n\n{probe_note} Шаблон задаёт таблицу свойств модуля (имена, типы, конверсии); фактические возможности прибора уточняются на следующем шаге.\n\nЕсли не уверены — оставьте значение по умолчанию.", + "data": { + "template": "Шаблон" + }, + "data_description": { + "template": "A — модули AP-WA/AP-WC/AP-WD: есть датчик температуры помещения, направления и swing заслонок, все флаги. B — модули AP-WB: без датчика температуры помещения и без направлений заслонок, шаг уставки 1,0 °C. F — как A, но для модулей AP-WF (monitor1, filter_sign_reset)." + } + }, + "capabilities": { + "title": "Возможности прибора", + "description": "Тумблеры выставлены по данным устройства:\n\n{device_values}\n\nОтключите то, что прибор не умеет или что не нужно показывать. Сущности создаются только для включённых возможностей.\n\nЕсли не уверены — оставьте как есть.", + "data": { + "mode_cool": "Охлаждение", + "mode_dry": "Осушение", + "mode_fan": "Вентиляция", + "mode_heat": "Обогрев", + "mode_auto": "Автоматический режим", + "fan_quiet": "Тихая скорость", + "fan_low": "Низкая скорость", + "fan_medium": "Средняя скорость", + "fan_high": "Высокая скорость", + "fan_auto": "Автоскорость", + "swing_vertical": "Swing вертикальный", + "swing_horizontal": "Swing горизонтальный", + "economy_mode": "Экономичный режим", + "min_heat": "Минимальный обогрев", + "indoor_fan_control": "Управление вентилятором", + "powerful_mode": "Мощный режим", + "outdoor_low_noise": "Тихий наружный блок", + "coil_dry_mode": "Сушка испарителя", + "display_temperature": "Датчик температуры помещения", + "human_det_auto_save": "Автосохранение (датчик присутствия)", + "wifi_led_enable": "Wi-Fi индикатор", + "af_vertical_direction": "Заслонка вертикальная", + "af_horizontal_direction": "Заслонка горизонтальная" + }, + "data_description": { + "mode_cool": "Охлаждение", + "mode_dry": "Осушение", + "mode_fan": "Вентиляция (без нагрева/охлаждения)", + "mode_heat": "Обогрев", + "mode_auto": "Автоматический режим", + "fan_auto": "Автоскорость вентилятора", + "fan_quiet": "Тихая скорость", + "fan_low": "Низкая скорость", + "fan_medium": "Средняя скорость", + "fan_high": "Высокая скорость", + "swing_vertical": "Качание заслонки по вертикали", + "swing_horizontal": "Качание заслонки по горизонтали", + "economy_mode": "Экономичный режим", + "min_heat": "Минимальный обогрев (поддержание +10 °C)", + "indoor_fan_control": "Управление вентилятором при работе наружного блока", + "powerful_mode": "Мощный режим (ускоренный выход на режим)", + "outdoor_low_noise": "Тихий наружный блок", + "coil_dry_mode": "Сушка испарителя", + "display_temperature": "Датчик температуры помещения", + "human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)", + "wifi_led_enable": "Индикатор Wi-Fi на модуле", + "af_vertical_direction": "Выбор положения вертикальной заслонки", + "af_horizontal_direction": "Выбор положения горизонтальной заслонки" + } + }, + "limits": { + "title": "Уставка (диапазон и шаг)", + "description": "Диапазон/шаг заданы автоматически (у A/F шаг 0,5 °C, у B — 1,0). При необходимости включите ручную конверсию коэффициентами (единицы API — 0,1 °C).\n\nЕсли не уверены — оставьте как есть.", "data": { - "template": "Шаблон", "temp_min": "Минимум уставки (°C)", "temp_max": "Максимум уставки (°C)", "temp_step": "Шаг уставки (°C)", @@ -66,8 +129,8 @@ }, "data_description": { "temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", - "temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", - "temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)" + "temp_den": "display = raw × num / den + offset", + "temp_offset": "display = raw × num / den + offset" } } }, diff --git a/custom_components/fglair/trial.py b/custom_components/fglair/trial.py index 89d7f3d..021a4cd 100644 --- a/custom_components/fglair/trial.py +++ b/custom_components/fglair/trial.py @@ -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 diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index ca39996..69fee76 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -99,8 +99,12 @@ Repair (теоретический): `key_error` → «Ключ устройст display_temperature; sensors: room temp, error_code, op_status-флаги; switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/ human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки, -demand_control; binary_sensor: connectivity). capabilities фильтруют -режимы/пресеты. Записи — один `batch_commit()` на действие пользователя. +demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере +feature-тумблеры фильтруют сущности (`features.py`, +`entry.data["features"]`): то, что прибор не умеет или отключено +пользователем, не создаётся. Записи — один `batch_commit()` на действие +пользователя. У сущностей есть атрибут `description` (HA не поддерживает +тултипы). Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована (§10) — сущность не создаётся до уточнения; в H3 вместо неё диагностический sensor состояния связи. diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index c213a8b..0013923 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -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() diff --git a/tests/components/fglair/test_entities.py b/tests/components/fglair/test_entities.py index 4a17e0c..3154aae 100644 --- a/tests/components/fglair/test_entities.py +++ b/tests/components/fglair/test_entities.py @@ -77,6 +77,7 @@ async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled): ) climate = hass.states.get(climate_id) + assert climate.attributes["description"] assert climate.attributes["current_temperature"] == 20.0 assert climate.attributes["temperature"] == 22.0 assert climate.attributes["fan_mode"] == "auto" diff --git a/tests/components/fglair/test_features.py b/tests/components/fglair/test_features.py new file mode 100644 index 0000000..5d16c58 --- /dev/null +++ b/tests/components/fglair/test_features.py @@ -0,0 +1,105 @@ +"""Дефолты feature-модели: device_capabilities, num_dir, presence.""" +from __future__ import annotations + +from custom_components.fglair.features import ( + FeatureSet, + device_default, + resolve_features, +) +from pyfglair import Prop, Template, Value, ValueKind + + +def _value(value: int) -> Value: + return Value(ValueKind.INT, value) + + +def test_caps_defaults_ap_wc1e(): + values = {Prop.DEVICE_CAPABILITIES: _value(5119)} # 0x13FF + assert device_default("mode_cool", values) is True + assert device_default("mode_heat", values) is True + assert device_default("fan_auto", values) is True + assert device_default("swing_vertical", values) is False + assert device_default("swing_horizontal", values) is False + assert device_default("economy_mode", values) is True + assert device_default("powerful_mode", values) is False + assert device_default("min_heat", values) is False + assert device_default("coil_dry_mode", values) is False + + +def test_caps_unknown_keeps_everything(): + assert device_default("swing_vertical", {}) is True + assert device_default( + "min_heat", {Prop.DEVICE_CAPABILITIES: _value(0)} + ) is True + + +def test_directions_need_num_dir(): + zero = {Prop.AF_VERTICAL_NUM_DIR: _value(0)} + five = {Prop.AF_VERTICAL_NUM_DIR: _value(5)} + assert device_default("af_vertical_direction", zero) is False + assert device_default("af_vertical_direction", five) is True + assert device_default("af_vertical_direction", {}) is False + assert device_default("af_horizontal_direction", zero) is False + + +def test_presence_defaults(): + values = {Prop.DISPLAY_TEMPERATURE: _value(200)} + assert device_default("display_temperature", values) is True + assert device_default("wifi_led_enable", values) is False + assert ( + device_default( + "display_temperature", {}, {Prop.DISPLAY_TEMPERATURE} + ) + is True + ) + assert device_default("wifi_led_enable", {}, set()) is False + + +def test_resolve_and_featureset_override(): + values = {Prop.DEVICE_CAPABILITIES: _value(5119)} + 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} + ) + feature_set = FeatureSet({"swing_vertical": True}, values) + assert feature_set.enabled("swing_vertical") is True + assert feature_set.enabled("powerful_mode") is False + default_set = FeatureSet(None, values) + assert default_set.enabled("economy_mode") is True + assert default_set.enabled("swing_vertical") is False + + +def test_classify_template(): + from custom_components.fglair.trial import classify_template + + assert classify_template( + {Prop.AF_VERTICAL_MOVE_STEP1} + ) is Template.B + assert classify_template( + {Prop.MONITOR1, Prop.AF_VERTICAL_DIRECTION} + ) is Template.F + assert classify_template( + {Prop.AF_VERTICAL_DIRECTION} + ) is Template.A + assert classify_template({Prop.DISPLAY_TEMPERATURE}) is Template.A + assert classify_template(set()) is None + + +def test_resolved_features_for_ap_wc1e_without_sensor(): + """Канальный блок: датчик комнаты есть, ламелей/swing нет.""" + values = { + Prop.DEVICE_CAPABILITIES: _value(5119), + Prop.AF_VERTICAL_NUM_DIR: _value(0), + Prop.AF_HORIZONTAL_NUM_DIR: _value(0), + Prop.DISPLAY_TEMPERATURE: _value(205), + } + resolved = resolve_features(values, set(values)) + assert resolved["display_temperature"] is True + assert resolved["swing_vertical"] is False + assert resolved["af_vertical_direction"] is False + assert resolved["af_horizontal_direction"] is False + assert resolved["mode_cool"] is True + assert resolved["economy_mode"] is True + assert resolved["powerful_mode"] is False diff --git a/tests/components/fglair/test_repairs.py b/tests/components/fglair/test_repairs.py index 09cb9c6..c455c1b 100644 --- a/tests/components/fglair/test_repairs.py +++ b/tests/components/fglair/test_repairs.py @@ -58,7 +58,7 @@ async def test_reconfigure_fixes_key_error( ): """Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят.""" from homeassistant.config_entries import SOURCE_RECONFIGURE - from test_config_flow import MANUAL_INPUT, PREVIEW_INPUT + from test_config_flow import LIMITS_INPUT, MANUAL_INPUT proc = mock_ac( ["--set", "operation_mode=6"], key_id=KEY_ID + 1 @@ -91,9 +91,16 @@ async def test_reconfigure_fixes_key_error( "lanip_key_id": KEY_ID + 1, }, ) - assert result["step_id"] == "preview", result.get("errors") + assert result["step_id"] == "template", result.get("errors") result = await hass.config_entries.flow.async_configure( - result["flow_id"], PREVIEW_INPUT + result["flow_id"], {"template": "A"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {} + ) + assert result["step_id"] == "limits" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], LIMITS_INPUT ) assert result["type"] == "abort" assert result["reason"] == "reconfigure_successful"