diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index 0fa4813..a967cdb 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -5,16 +5,25 @@ import logging from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant import pyfglair from .const import DOMAIN +from .coordinator import FglairCoordinator, FglairRuntime from .fglair_client import FglairClient +from .overrides import ConversionSet _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[str] = [] +PLATFORMS = [ + Platform.CLIMATE, + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.SWITCH, + Platform.SELECT, +] _LOG_LEVELS = { 0: logging.DEBUG, @@ -60,23 +69,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: hass.data[DATA_PENDING] -= 1 + conversions = ConversionSet.from_entry(entry.data) + coordinator = FglairCoordinator(hass, client, conversions) + try: + await coordinator.async_config_entry_first_refresh() + except Exception: + await coordinator.async_shutdown() + await client.async_stop() + raise + clients[entry.entry_id] = client - entry.runtime_data = client + entry.runtime_data = FglairRuntime(client, coordinator, conversions) if len(clients) == 1: pyfglair.set_log_handler(_log_handler) pyfglair.set_log_level(1) - if PLATFORMS: - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - client: FglairClient = entry.runtime_data - await client.async_stop() + runtime: FglairRuntime = entry.runtime_data + unloaded = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS + ) + await runtime.coordinator.async_shutdown() + await runtime.client.async_stop() clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) clients.pop(entry.entry_id, None) if not clients and not hass.data.get(DATA_PENDING, 0): await hass.async_add_executor_job(pyfglair.set_log_handler, None) - return True + return unloaded diff --git a/custom_components/fglair/binary_sensor.py b/custom_components/fglair/binary_sensor.py new file mode 100644 index 0000000..2ec4423 --- /dev/null +++ b/custom_components/fglair/binary_sensor.py @@ -0,0 +1,64 @@ +"""Binary sensors: connectivity и флаги op_status (PROTOCOL §8.4).""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory + +from pyfglair import Prop, State +from pyfglair.templates import template_info + +from .const import OP_STATUS_BITS +from .coordinator import FglairCoordinator, FglairRuntime +from .entity import FglairEntity + + +class FglairConnectivity(FglairEntity, BinarySensorEntity): + _attr_translation_key = "connectivity" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + + def __init__(self, coordinator: FglairCoordinator) -> None: + super().__init__(coordinator, "connectivity", "connectivity") + + @property + def available(self) -> bool: + return True + + @property + def is_on(self) -> bool: + return self.coordinator.data.state == State.ONLINE + + +class FglairStatusFlag(FglairEntity, BinarySensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, coordinator: FglairCoordinator, key: str, bit: int + ) -> None: + super().__init__(coordinator, f"op_status_{key}", key) + self._bit = bit + + @property + def is_on(self) -> bool: + status = self.raw_value(Prop.OP_STATUS) + if status is None: + return False + return bool(status & (1 << self._bit)) + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + runtime: FglairRuntime = entry.runtime_data + coordinator = runtime.coordinator + props = { + info.prop for info in template_info(runtime.client.template) + } + + entities = [FglairConnectivity(coordinator)] + if Prop.OP_STATUS in props: + entities.extend( + FglairStatusFlag(coordinator, key, bit) + for key, bit in OP_STATUS_BITS.items() + ) + async_add_entities(entities) diff --git a/custom_components/fglair/climate.py b/custom_components/fglair/climate.py new file mode 100644 index 0000000..a35452a --- /dev/null +++ b/custom_components/fglair/climate.py @@ -0,0 +1,263 @@ +"""Climate-сущность FGLair: режим, уставка, fan, swing, preset ECO/BOOST.""" +from __future__ import annotations + +from typing import Any, Optional + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import callback + +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, + HVAC_VALUE_TO_MODE, + OPERATION_MODE_OFF, + OPERATION_MODE_ON, + PRESET_BOOST, + PRESET_CAP, + PRESET_ECO, + PRESET_PROP, + SWING_HORIZONTAL_MODES, + SWING_MODES, +) +from .coordinator import FglairCoordinator, FglairRuntime +from .entity import FglairEntity + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + runtime: FglairRuntime = entry.runtime_data + async_add_entities([FglairClimate(runtime.coordinator)]) + + +class FglairClimate(FglairEntity, ClimateEntity): + _attr_translation_key = "climate" + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compat = False + + def __init__(self, coordinator: FglairCoordinator) -> None: + super().__init__(coordinator, "climate", "climate") + self._last_hvac_mode: Optional[HVACMode] = None + + # -- свойства шаблона -------------------------------------------------- + def _in_template(self, prop: Prop) -> bool: + return prop_info(self.coordinator.client.template, prop) is not None + + def _hvac_modes(self) -> list[HVACMode]: + modes: list[HVACMode] = [HVACMode.OFF] + for mode, bit in CAP_HVAC.items(): + if self.supports_capability(bit): + 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) + ] + + def _preset_modes(self) -> Optional[list[str]]: + presets = [ + preset + for preset, bit in PRESET_CAP.items() + if self._in_template(PRESET_PROP[preset]) + and self.supports_capability(bit) + ] + 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) + ) + + def _horizontal_swing_supported(self) -> bool: + return self._in_template(Prop.AF_HORIZONTAL_SWING) and ( + self.supports_capability(CAP_SWING_HORIZONTAL) + ) + + # -- ClimateEntity ---------------------------------------------------- + @property + def supported_features(self) -> ClimateEntityFeature: + features = ClimateEntityFeature(0) + if self._in_template(Prop.ADJUST_TEMPERATURE): + features |= ClimateEntityFeature.TARGET_TEMPERATURE + if self._fan_modes(): + features |= ClimateEntityFeature.FAN_MODE + if self._preset_modes(): + features |= ClimateEntityFeature.PRESET_MODE + if self._vertical_swing_supported(): + features |= ClimateEntityFeature.SWING_MODE + if self._horizontal_swing_supported(): + features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + return features + + @property + def hvac_modes(self) -> list[HVACMode]: + return self._hvac_modes() + + @property + def hvac_mode(self) -> Optional[HVACMode]: + value = self.raw_value(Prop.OPERATION_MODE) + if value is None: + return None + if value == OPERATION_MODE_OFF: + return HVACMode.OFF + if value == OPERATION_MODE_ON: + if self._last_hvac_mode is not None: + return self._last_hvac_mode + available = [mode for mode in self._hvac_modes() if mode != HVACMode.OFF] + return available[0] if available else HVACMode.AUTO + return HVAC_VALUE_TO_MODE.get(value) + + @property + def target_temperature(self) -> Optional[float]: + display = self.display_value(Prop.ADJUST_TEMPERATURE) + return display / 10 if display is not None else None + + @property + def target_temperature_step(self) -> Optional[float]: + return self.coordinator.conversions.step_tenths / 10 + + @property + def min_temp(self) -> float: + return self.coordinator.conversions.temperature_range()[0] / 10 + + @property + def max_temp(self) -> float: + return self.coordinator.conversions.temperature_range()[1] / 10 + + @property + def current_temperature(self) -> Optional[float]: + display = self.display_value(Prop.DISPLAY_TEMPERATURE) + return display / 10 if display is not None else None + + @property + def fan_mode(self) -> Optional[str]: + value = self.raw_value(Prop.FAN_SPEED) + return FAN_VALUE_TO_MODE.get(value) if value is not None else None + + @property + def fan_modes(self) -> Optional[list[str]]: + return self._fan_modes() + + @property + def preset_mode(self) -> Optional[str]: + if self.raw_value(PRESET_PROP[PRESET_ECO]) == 1: + return PRESET_ECO + if self.raw_value(PRESET_PROP[PRESET_BOOST]) == 1: + return PRESET_BOOST + return None + + @property + def preset_modes(self) -> Optional[list[str]]: + return self._preset_modes() + + @property + def swing_mode(self) -> Optional[str]: + if not self._vertical_swing_supported(): + return None + value = self.raw_value(Prop.AF_VERTICAL_SWING) + return None if value is None else ("on" if value else "off") + + @property + def swing_modes(self) -> Optional[list[str]]: + return SWING_MODES if self._vertical_swing_supported() else None + + @property + def swing_horizontal_mode(self) -> Optional[str]: + if not self._horizontal_swing_supported(): + return None + value = self.raw_value(Prop.AF_HORIZONTAL_SWING) + return None if value is None else ("on" if value else "off") + + @property + def swing_horizontal_modes(self) -> Optional[list[str]]: + return ( + SWING_HORIZONTAL_MODES + if self._horizontal_swing_supported() + else None + ) + + # -- команды ---------------------------------------------------------- + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await self.coordinator.async_write( + [(Prop.OPERATION_MODE, OPERATION_MODE_OFF)] + ) + return + value = HVAC_MODE_TO_VALUE.get(hvac_mode) + if value is None: + return + self._last_hvac_mode = hvac_mode + await self.coordinator.async_write([(Prop.OPERATION_MODE, value)]) + + async def async_turn_on(self) -> None: + await self.coordinator.async_write( + [(Prop.OPERATION_MODE, OPERATION_MODE_ON)] + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + display = int(round(temperature * 10)) + display = self.coordinator.conversions.clamp( + Prop.ADJUST_TEMPERATURE, display + ) + api_value = self.coordinator.conversions.from_display( + Prop.ADJUST_TEMPERATURE, display + ) + await self.coordinator.async_write( + [(Prop.ADJUST_TEMPERATURE, api_value)] + ) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + value = FAN_MODE_TO_VALUE.get(fan_mode) + if value is None: + return + await self.coordinator.async_write([(Prop.FAN_SPEED, value)]) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + eco = 1 if preset_mode == PRESET_ECO else 0 + boost = 1 if preset_mode == PRESET_BOOST else 0 + updates = [(prop, value) for prop, value in ( + (PRESET_PROP[PRESET_ECO], eco), + (PRESET_PROP[PRESET_BOOST], boost), + ) if self._in_template(prop)] + await self.coordinator.async_write(updates) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + await self.coordinator.async_write( + [(Prop.AF_VERTICAL_SWING, 1 if swing_mode == "on" else 0)] + ) + + async def async_set_swing_horizontal_mode( + self, swing_mode: str + ) -> None: + await self.coordinator.async_write( + [(Prop.AF_HORIZONTAL_SWING, 1 if swing_mode == "on" else 0)] + ) + + @callback + def _handle_coordinator_update(self) -> None: + value = self.raw_value(Prop.OPERATION_MODE) + mode = HVAC_VALUE_TO_MODE.get(value) if value is not None else None + if mode is not None: + self._last_hvac_mode = mode + super()._handle_coordinator_update() diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index 7f5006d..edd6844 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -12,11 +12,15 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers import selector import pyfglair -from pyfglair import Template +from pyfglair import Prop, Template 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, @@ -27,13 +31,23 @@ from .const import ( CONF_LISTEN_PORT, CONF_MODEL, CONF_NAME, + CONF_OVERRIDES, CONF_PASSWORD, CONF_REGION, + CONF_TEMP_DEN, + CONF_TEMP_MAX, + CONF_TEMP_MIN, + CONF_TEMP_NUM, + CONF_TEMP_OFFSET, + CONF_TEMP_STEP, CONF_TEMPLATE, DEFAULT_DEVICE_PORT, DEFAULT_LISTEN_PORT, DOMAIN, REGIONS, + TEMP_MAX_TENTHS, + TEMP_MIN_TENTHS, + TEMP_STEP_TENTHS, TRIAL_ERRORS, TRIAL_TIMEOUT, ) @@ -86,6 +100,75 @@ def _import_schema() -> vol.Schema: ) +def _preview_schema(data: dict[str, Any]) -> vol.Schema: + template = Template[str(data.get(CONF_TEMPLATE, "A"))] + return vol.Schema( + { + vol.Required(CONF_TEMPLATE, default=template.name): vol.In( + ["A", "B", "F"] + ), + vol.Required(CONF_TEMP_MIN, default=16.0): vol.All( + vol.Coerce(float), vol.Range(min=-10.0, max=45.0) + ), + vol.Required(CONF_TEMP_MAX, default=30.0): vol.All( + vol.Coerce(float), vol.Range(min=-10.0, max=45.0) + ), + vol.Required( + CONF_TEMP_STEP, default=TEMP_STEP_TENTHS[template] / 10 + ): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)), + vol.Required(CONF_TEMP_NUM, default=1): vol.All( + int, vol.Range(min=1, max=1000) + ), + vol.Required(CONF_TEMP_DEN, default=1): vol.All( + int, vol.Range(min=1, max=1000) + ), + vol.Required(CONF_TEMP_OFFSET, default=0): vol.All( + int, vol.Range(min=-1000, max=1000) + ), + } + ) + + +def _preview_text( + template: Template, 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", + ] + 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)}") + if caps is not None: + lines.append(f"- Capabilities: 0x{caps:08X}") + lines.append(f"- Template: {template.name}") + return "\n".join(lines) + + def _template_name(model: str | None) -> str: if model: found = detect(model) @@ -173,6 +256,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: self._devices: list[Device] = [] + self._pending_data: dict[str, Any] = {} + self._trial_properties: dict[Any, Any] = {} def _show_form( self, @@ -256,7 +341,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): } error = await self._async_trial(data) if error is None: - return self._create_entry(data) + return await self.async_step_preview() errors["base"] = error schema = vol.Schema( { @@ -278,7 +363,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): data = _manual_data(user_input) error = await self._async_trial(data) if error is None: - return self._create_entry(data) + return await self.async_step_preview() errors["base"] = error return self._show_form( "manual", _manual_schema(), errors, user_input, @@ -296,7 +381,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): else: error = await self._async_trial(data) if error is None: - return self._create_entry(data) + return await self.async_step_preview() errors["base"] = error return self._show_form("import_json", _import_schema(), errors, user_input) @@ -312,6 +397,55 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=error) return self._create_entry(data) + async def async_step_preview( + 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]] + 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)) + if temp_min >= temp_max: + errors["base"] = "invalid_range" + elif step <= 0: + errors["base"] = "invalid_range" + else: + data[CONF_TEMPLATE] = template.name + data[CONF_TEMP_STEP] = step + data[CONF_OVERRIDES] = { + Prop.ADJUST_TEMPERATURE.name.lower(): { + "num": int(user_input[CONF_TEMP_NUM]), + "den": int(user_input[CONF_TEMP_DEN]), + "offset": int(user_input[CONF_TEMP_OFFSET]), + "min": temp_min, + "max": temp_max, + } + } + _LOGGER.debug( + "превью: template=%s range=%d..%d step=%d", + template.name, temp_min, temp_max, step, + ) + return self._create_entry(data) + schema = _preview_schema(data) + if user_input is None: + user_input = { + CONF_TEMPLATE: data.get(CONF_TEMPLATE, Template.A.name), + } + return self.async_show_form( + step_id="preview", + data_schema=self.add_suggested_values_to_schema(schema, user_input), + errors=errors, + description_placeholders={ + "preview": _preview_text( + Template[str(data.get(CONF_TEMPLATE, "A"))], + self._trial_properties, + ) + }, + ) + async def _async_trial(self, data: dict[str, Any]) -> str | None: """None — сессия поднялась; иначе ключ ошибки формы.""" await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) @@ -320,6 +454,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): partial(_run_trial, data) ) if result.ok: + self._pending_data = data + self._trial_properties = result.properties 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 77f0c3d..8b93763 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -1,6 +1,9 @@ -"""Константы интеграции fglair.""" +"""Константы и словари значений интеграции fglair (PROTOCOL §8).""" from __future__ import annotations +from homeassistant.components.climate import HVACMode + +from pyfglair import Prop, State, Template from pyfglair.provision import REGIONS as _PROVISION_REGIONS DOMAIN = "fglair" @@ -19,6 +22,13 @@ CONF_REGION = "region" CONF_EMAIL = "email" CONF_PASSWORD = "password" CONF_CONFIG_JSON = "config_json" +CONF_OVERRIDES = "overrides" +CONF_TEMP_STEP = "temp_step" +CONF_TEMP_MIN = "temp_min" +CONF_TEMP_MAX = "temp_max" +CONF_TEMP_NUM = "temp_num" +CONF_TEMP_DEN = "temp_den" +CONF_TEMP_OFFSET = "temp_offset" DEFAULT_DEVICE_PORT = 80 # 0 => свободный эфемерный порт: несколько ConfigEntry (несколько @@ -38,3 +48,90 @@ TRIAL_ERRORS = { "start_failed": "cannot_connect", "properties_missing": "properties_missing", } + +# --------------------------------------------------------------------------- +# operation_mode (PROTOCOL §8.3): 0=OFF, 1=ON, 2=AUTO, 3=COOL, 4=DRY, +# 5=FAN, 6=HEAT +# --------------------------------------------------------------------------- +OPERATION_MODE_OFF = 0 +OPERATION_MODE_ON = 1 +HVAC_MODE_TO_VALUE = { + HVACMode.AUTO: 2, + HVACMode.COOL: 3, + HVACMode.DRY: 4, + HVACMode.FAN_ONLY: 5, + HVACMode.HEAT: 6, +} +HVAC_VALUE_TO_MODE = {value: mode for mode, value in HVAC_MODE_TO_VALUE.items()} + +# device_capabilities, биты (PROTOCOL §8.5) +CAP_HVAC = { + HVACMode.AUTO: 4, + HVACMode.COOL: 0, + HVACMode.DRY: 1, + HVACMode.FAN_ONLY: 2, + HVACMode.HEAT: 3, +} +CAP_FAN = {"auto": 5, "high": 6, "medium": 7, "low": 8, "quiet": 9} +CAP_SWING_VERTICAL = 10 +CAP_SWING_HORIZONTAL = 11 +CAP_ECONOMY = 12 +CAP_MIN_HEAT = 13 +CAP_INDOOR_FAN_CONTROL = 14 +CAP_POWERFUL = 16 +CAP_OUTDOOR_LOW_NOISE = 17 +CAP_COIL_DRY = 18 + +# fan_speed (PROTOCOL §8.3) +FAN_VALUE_TO_MODE = {0: "quiet", 1: "low", 2: "medium", 3: "high", 4: "auto"} +FAN_MODE_TO_VALUE = {mode: value for value, mode in FAN_VALUE_TO_MODE.items()} + +# preset: ECO = economy_mode, BOOST = powerful_mode +PRESET_ECO = "eco" +PRESET_BOOST = "boost" +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"] +SWING_HORIZONTAL_MODES = ["off", "on"] + +# switch-свойства: prop -> (translation_key, capability bit | None) +SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = { + Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY), + Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL), + Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY), + Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT), + Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE), + Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None), + Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None), + Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL), +} + +# op_status, биты (PROTOCOL §8.4): translation_key -> bit +OP_STATUS_BITS: dict[str, int] = { + "defrost": 24, + "maintenance": 22, + "oil_recovery": 28, + "pump_down": 29, + "check_operation": 30, + "simultaneous": 25, +} + +# Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]); +# единицы API = 0.1 °C. +TEMP_MIN_TENTHS = 160 +TEMP_MAX_TENTHS = 300 +TEMP_STEP_TENTHS = { + Template.A: 5, + Template.B: 10, + Template.F: 5, +} + +# Состояния, в которых сущности доступны (PROTOCOL/план §5). +AVAILABLE_STATES = (State.ONLINE, State.RECOVERING) diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py new file mode 100644 index 0000000..82883f4 --- /dev/null +++ b/custom_components/fglair/coordinator.py @@ -0,0 +1,152 @@ +"""Push-driven координатор: кэш свойств + доступность по состоянию сессии.""" +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field, replace +from typing import Optional + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind +from pyfglair.templates import template_info + +from .const import AVAILABLE_STATES, CONF_DSN, DOMAIN +from .fglair_client import FglairClient +from .overrides import ConversionSet + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class FglairData: + values: dict[Prop, Value] = field(default_factory=dict) + state: State = State.IDLE + last_error: Error = Error.NONE + available: bool = False + + +@dataclass +class FglairRuntime: + client: FglairClient + coordinator: "FglairCoordinator" + conversions: ConversionSet + + +def _writable_props(template: Template): + for info in template_info(template): + if info.kind != ValueKind.STRING: + yield info.prop + + +class FglairCoordinator(DataUpdateCoordinator[FglairData]): + """Держит актуальный снимок свойств и рассылает его сущностям.""" + + def __init__( + self, + hass: HomeAssistant, + client: FglairClient, + conversions: ConversionSet, + ) -> None: + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{client.data[CONF_DSN]}", + update_interval=None, + ) + self.client = client + self.conversions = conversions + self._synced = False + self._sync_task: Optional[asyncio.Task] = None + self._unsubs = [ + client.add_state_listener(self._on_state), + client.add_property_listener(self._on_property), + ] + self.data = self._snapshot() + + # -- снимок ----------------------------------------------------------- + def _snapshot(self) -> FglairData: + values: dict[Prop, Value] = {} + for prop in _writable_props(self.client.template): + value = self.client.cached(prop) + if value is not None: + values[prop] = value + state = self.client.state + return FglairData( + values=values, + state=state, + last_error=self.client.last_error, + available=state in AVAILABLE_STATES, + ) + + async def _async_update_data(self) -> FglairData: + return await self.hass.async_add_executor_job(self._snapshot) + + # -- события ---------------------------------------------------------- + @callback + def _on_state(self, state: State, error: Error) -> None: + self.data = replace( + self.data, + state=state, + last_error=error, + available=state in AVAILABLE_STATES, + ) + self.async_set_updated_data(self.data) + if state == State.ONLINE and not self._synced and self._sync_task is None: + self._sync_task = self.hass.async_create_task( + self._async_initial_sync() + ) + + @callback + def _on_property(self, event: PropertyEvent) -> None: + values = dict(self.data.values) + values[event.prop] = event.value + self.data = replace(self.data, values=values) + self.async_set_updated_data(self.data) + + async def _async_initial_sync(self) -> None: + try: + await self.hass.async_add_executor_job(self._initial_sync) + self._synced = True + except Exception: # pragma: no cover - защитный путь + _LOGGER.exception("первичная синхронизация свойств не удалась") + finally: + self._sync_task = None + + def _initial_sync(self) -> None: + self.client.batch_begin() + for prop in _writable_props(self.client.template): + self.client.get_prop(prop) + self.client.batch_commit() + + # -- записи ----------------------------------------------------------- + async def async_write( + self, updates: list[tuple[Prop, int]] + ) -> None: + """Одно действие пользователя — один batch_commit().""" + if not updates: + return + await self.hass.async_add_executor_job(self._write, updates) + values = dict(self.data.values) + for prop, _ in updates: + value = self.client.cached(prop) + if value is not None: + values[prop] = value + self.data = replace(self.data, values=values) + self.async_set_updated_data(self.data) + + def _write(self, updates: list[tuple[Prop, int]]) -> None: + self.client.batch_begin() + for prop, value in updates: + self.client.set_int(prop, value) + self.client.batch_commit() + + async def async_shutdown(self) -> None: + for unsub in self._unsubs: + unsub() + self._unsubs.clear() + if self._sync_task is not None: + self._sync_task.cancel() + self._sync_task = None + await super().async_shutdown() diff --git a/custom_components/fglair/entity.py b/custom_components/fglair/entity.py new file mode 100644 index 0000000..a1c64db --- /dev/null +++ b/custom_components/fglair/entity.py @@ -0,0 +1,70 @@ +"""Базовая сущность fglair: DeviceInfo, значения из координатора.""" +from __future__ import annotations + +from typing import Optional + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from pyfglair import Prop, Value + +from .const import CONF_DSN, CONF_MODEL, CONF_NAME, DOMAIN +from .coordinator import FglairCoordinator + + +class FglairEntity(CoordinatorEntity[FglairCoordinator]): + """Общий базис: unique_id = _, device_info — один прибор.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FglairCoordinator, + unique_suffix: str, + translation_key: str, + ) -> None: + super().__init__(coordinator) + data = coordinator.client.data + dsn = data[CONF_DSN] + self._attr_unique_id = f"{dsn}_{unique_suffix}" + self._attr_translation_key = translation_key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, dsn)}, + name=data.get(CONF_NAME) or dsn, + manufacturer="Fujitsu General", + model=data.get(CONF_MODEL) or None, + ) + + @property + def available(self) -> bool: + return self.coordinator.data.available and super().available + + def value(self, prop: Prop) -> Optional[Value]: + return self.coordinator.data.values.get(prop) + + def raw_value(self, prop: Prop) -> Optional[int]: + value = self.value(prop) + return value.int_value if value is not None else None + + def display_value(self, prop: Prop) -> Optional[int]: + """Значение свойства в единицах отображения (с ручной конверсией).""" + raw = self.raw_value(prop) + if raw is None: + 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 caps is 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/overrides.py b/custom_components/fglair/overrides.py new file mode 100644 index 0000000..e57e53f --- /dev/null +++ b/custom_components/fglair/overrides.py @@ -0,0 +1,136 @@ +"""Ручные конверсии/диапазоны свойств (план §3 шаг 3, §4). + +Ядро конвертирует raw→API по таблице шаблона; ручная конверсия задаётся +коэффициентами ``display = raw*num/den + offset`` (в единицах API, для +температуры это 0.1 °C) и диапазоном. Применение — на стороне HA: + display = linear(raw), raw = convert_from_input(template, api) + api = convert_to_display(template, linear_inverse(display)) +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional + +from pyfglair import Prop, Template +from pyfglair.templates import convert_from_input, convert_to_display + +from .const import ( + CONF_OVERRIDES, + CONF_TEMP_STEP, + TEMP_MAX_TENTHS, + TEMP_MIN_TENTHS, + TEMP_STEP_TENTHS, +) + + +def _div_trunc(numerator: int, denominator: int) -> int: + quotient = abs(numerator) // abs(denominator) + return quotient if (numerator < 0) == (denominator < 0) else -quotient + + +@dataclass(frozen=True) +class LinearOverride: + num: int = 1 + den: int = 1 + offset: int = 0 + min_value: Optional[int] = None + max_value: Optional[int] = None + + @property + def identity(self) -> bool: + return self.num == 1 and self.den == 1 and self.offset == 0 + + def to_display(self, raw: int) -> int: + return _div_trunc(raw * self.num, self.den) + self.offset + + def from_display(self, display: int) -> int: + return _div_trunc((display - self.offset) * self.den, self.num) + + def clamp(self, display: int) -> int: + if self.min_value is not None and display < self.min_value: + return self.min_value + if self.max_value is not None and display > self.max_value: + return self.max_value + return display + + +class ConversionSet: + """Конверсии записи: ручные override поверх шаблонных конверсий ядра.""" + + def __init__( + self, + template: Template, + overrides: Mapping[Prop, LinearOverride], + step_tenths: int, + ) -> None: + self.template = template + self.overrides = dict(overrides) + self.step_tenths = step_tenths + + @classmethod + def from_entry(cls, data: Mapping[str, Any]) -> "ConversionSet": + template = Template[str(data.get("template", "A"))] + raw_overrides = data.get(CONF_OVERRIDES) or {} + overrides: dict[Prop, LinearOverride] = {} + if isinstance(raw_overrides, dict): + for name, spec in raw_overrides.items(): + try: + prop = Prop[str(name).upper()] + except KeyError: + continue + if not isinstance(spec, dict): + continue + num = int(spec.get("num", 1)) or 1 + den = int(spec.get("den", 1)) or 1 + overrides[prop] = LinearOverride( + num=num, + den=den, + offset=int(spec.get("offset", 0)), + min_value=( + int(spec["min"]) if spec.get("min") is not None else None + ), + max_value=( + int(spec["max"]) if spec.get("max") is not None else None + ), + ) + step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template]) + return cls(template, overrides, step) + + def override(self, prop: Prop) -> LinearOverride: + return self.overrides.get(prop, LinearOverride()) + + def to_display(self, prop: Prop, api_value: int) -> int: + """API-значение из ядра → отображаемое (при override — единицы API). + + Без override отображаемое значение равно API-значению ядра + (например, display_temperature API 200 = 20.0 °C). С линейным + override: display = raw*num/den + offset. + """ + override = self.override(prop) + if override.identity: + return api_value + raw = convert_from_input(self.template, prop, api_value) + return override.to_display(raw) + + def from_display(self, prop: Prop, display: int) -> int: + """Отображаемое значение → API-значение для set_int().""" + override = self.override(prop) + if override.identity: + return display + raw = override.from_display(display) + return convert_to_display(self.template, prop, raw) + + def clamp(self, prop: Prop, display: int) -> int: + return self.override(prop).clamp(display) + + def range(self, prop: Prop) -> Optional[tuple[int, int]]: + override = self.override(prop) + if override.min_value is None or override.max_value is None: + return None + return (override.min_value, override.max_value) + + def temperature_range(self) -> tuple[int, int]: + return self.range(Prop.ADJUST_TEMPERATURE) or ( + TEMP_MIN_TENTHS, + TEMP_MAX_TENTHS, + ) diff --git a/custom_components/fglair/select.py b/custom_components/fglair/select.py new file mode 100644 index 0000000..8ddfbf6 --- /dev/null +++ b/custom_components/fglair/select.py @@ -0,0 +1,74 @@ +"""Selects: положения заслонок (af_*_direction, число позиций из num_dir).""" +from __future__ import annotations + +from homeassistant.components.select import SelectEntity + +from pyfglair import Prop +from pyfglair.templates import prop_info + +from .coordinator import FglairCoordinator, FglairRuntime +from .entity import FglairEntity + + +class FglairDirectionSelect(FglairEntity, SelectEntity): + def __init__( + self, + coordinator: FglairCoordinator, + prop: Prop, + num_dir_prop: Prop, + key: str, + ) -> None: + super().__init__(coordinator, prop.name.lower(), key) + self._prop = prop + self._num_dir_prop = num_dir_prop + + @property + def options(self) -> list[str]: + num = self.raw_value(self._num_dir_prop) + if num is None or num <= 0 or num > 15: + return ["0"] + return [str(value) for value in range(num)] + + @property + def current_option(self) -> str | None: + value = self.raw_value(self._prop) + if value is None: + return None + text = str(value) + return text if text in self.options else self.options[0] + + async def async_select_option(self, option: str) -> None: + await self.coordinator.async_write([(self._prop, int(option))]) + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + runtime: FglairRuntime = entry.runtime_data + coordinator = runtime.coordinator + template = runtime.client.template + + entities = [] + if ( + prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None + and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None + ): + entities.append( + FglairDirectionSelect( + coordinator, + Prop.AF_VERTICAL_DIRECTION, + Prop.AF_VERTICAL_NUM_DIR, + "af_vertical_direction", + ) + ) + if ( + prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None + and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None + ): + entities.append( + FglairDirectionSelect( + coordinator, + Prop.AF_HORIZONTAL_DIRECTION, + Prop.AF_HORIZONTAL_NUM_DIR, + "af_horizontal_direction", + ) + ) + async_add_entities(entities) diff --git a/custom_components/fglair/sensor.py b/custom_components/fglair/sensor.py new file mode 100644 index 0000000..b70ef6c --- /dev/null +++ b/custom_components/fglair/sensor.py @@ -0,0 +1,78 @@ +"""Sensors: температура помещения, код ошибки, состояние связи.""" +from __future__ import annotations + +from typing import Optional + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfTemperature + +from pyfglair import Prop, State +from pyfglair.templates import template_info + +from .coordinator import FglairCoordinator, FglairRuntime +from .entity import FglairEntity + + +class FglairRoomTemperature(FglairEntity, SensorEntity): + _attr_translation_key = "room_temperature" + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_suggested_display_precision = 1 + + def __init__(self, coordinator: FglairCoordinator) -> None: + super().__init__(coordinator, "display_temperature", "room_temperature") + + @property + def native_value(self) -> Optional[float]: + display = self.display_value(Prop.DISPLAY_TEMPERATURE) + return display / 10 if display is not None else None + + +class FglairErrorCode(FglairEntity, SensorEntity): + _attr_translation_key = "error_code" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: FglairCoordinator) -> None: + super().__init__(coordinator, "error_code", "error_code") + + @property + def native_value(self) -> Optional[int]: + return self.raw_value(Prop.ERROR_CODE) + + +class FglairConnectionState(FglairEntity, SensorEntity): + _attr_translation_key = "connection_state" + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_options = [state.name.lower() for state in State] + + def __init__(self, coordinator: FglairCoordinator) -> None: + super().__init__(coordinator, "connection_state", "connection_state") + + @property + def available(self) -> bool: + # Диагностика связи должна быть видна и при offline. + return True + + @property + def native_value(self) -> str: + return self.coordinator.data.state.name.lower() + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + runtime: FglairRuntime = entry.runtime_data + coordinator = runtime.coordinator + template = runtime.client.template + props = {info.prop for info in template_info(template)} + + entities = [FglairConnectionState(coordinator)] + if Prop.DISPLAY_TEMPERATURE in props: + entities.append(FglairRoomTemperature(coordinator)) + if Prop.ERROR_CODE in props: + entities.append(FglairErrorCode(coordinator)) + async_add_entities(entities) diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 0c03876..0c84d6d 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -51,6 +51,24 @@ "data_description": { "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.", + "data": { + "template": "Template", + "temp_min": "Minimum setpoint (°C)", + "temp_max": "Maximum setpoint (°C)", + "temp_step": "Setpoint step (°C)", + "temp_num": "Conversion numerator", + "temp_den": "Conversion denominator", + "temp_offset": "Conversion offset (0.1 °C)" + }, + "data_description": { + "temp_num": "display = raw × num / den + offset (raw in API units, 0.1 °C)", + "temp_den": "display = raw × num / den + offset (raw in API units, 0.1 °C)", + "temp_offset": "display = raw × num / den + offset (raw in API units, 0.1 °C)" + } } }, "error": { @@ -62,7 +80,8 @@ "invalid_import": "Invalid config JSON", "no_devices": "No devices found in the account", "unknown": "Unexpected error", - "properties_missing": "The device connected but did not report its base properties" + "properties_missing": "The device connected but did not report its base properties", + "invalid_range": "Minimum must be less than maximum, step must be positive" }, "abort": { "already_configured": "This device is already configured", @@ -73,5 +92,86 @@ "timeout": "Timed out waiting for the device", "properties_missing": "The device connected but did not report its base properties" } + }, + "entity": { + "climate": { + "name": "Air conditioner" + }, + "sensor": { + "room_temperature": { + "name": "Room temperature" + }, + "error_code": { + "name": "Error code" + }, + "connection_state": { + "name": "Connection state", + "state": { + "idle": "Idle", + "registering": "Registering", + "online": "Online", + "recovering": "Recovering", + "offline": "Offline", + "key_error": "Key error" + } + } + }, + "binary_sensor": { + "connectivity": { + "name": "Connectivity" + }, + "defrost": { + "name": "Defrost" + }, + "maintenance": { + "name": "Maintenance" + }, + "oil_recovery": { + "name": "Oil recovery" + }, + "pump_down": { + "name": "Pump down" + }, + "check_operation": { + "name": "Check operation" + }, + "simultaneous": { + "name": "Simultaneous operation" + } + }, + "switch": { + "economy_mode": { + "name": "Economy" + }, + "powerful_mode": { + "name": "Powerful" + }, + "coil_dry_mode": { + "name": "Coil dry" + }, + "min_heat": { + "name": "Minimum heat" + }, + "outdoor_low_noise": { + "name": "Outdoor low noise" + }, + "human_det_auto_save": { + "name": "Human detection auto save" + }, + "wifi_led_enable": { + "name": "Wi-Fi LED" + }, + "indoor_fan_control": { + "name": "Indoor fan control" + } + }, + "select": { + "af_vertical_direction": { + "name": "Vertical airflow direction" + }, + "af_horizontal_direction": { + "name": "Horizontal airflow direction" + } + } } } diff --git a/custom_components/fglair/switch.py b/custom_components/fglair/switch.py new file mode 100644 index 0000000..18d1cf9 --- /dev/null +++ b/custom_components/fglair/switch.py @@ -0,0 +1,60 @@ +"""Switches: флаги-свойства (economy/powerful/min_heat/...).""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity + +from pyfglair import Prop +from pyfglair.templates import prop_info + +from .const import SWITCH_PROPS +from .coordinator import FglairCoordinator, FglairRuntime +from .entity import FglairEntity + + +class FglairSwitch(FglairEntity, SwitchEntity): + def __init__( + self, + coordinator: FglairCoordinator, + prop: Prop, + key: str, + ) -> None: + super().__init__(coordinator, prop.name.lower(), key) + self._prop = prop + + @property + def is_on(self) -> bool: + return self.raw_value(self._prop) == 1 + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.coordinator.async_write([(self._prop, 1)]) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.async_write([(self._prop, 0)]) + + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + runtime: FglairRuntime = entry.runtime_data + coordinator = runtime.coordinator + template = runtime.client.template + + entities = [] + for prop, (key, capability) in SWITCH_PROPS.items(): + if prop_info(template, prop) is None: + continue + if not _capability_supported(coordinator, capability): + 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: + 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 0c03876..0c84d6d 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -51,6 +51,24 @@ "data_description": { "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.", + "data": { + "template": "Template", + "temp_min": "Minimum setpoint (°C)", + "temp_max": "Maximum setpoint (°C)", + "temp_step": "Setpoint step (°C)", + "temp_num": "Conversion numerator", + "temp_den": "Conversion denominator", + "temp_offset": "Conversion offset (0.1 °C)" + }, + "data_description": { + "temp_num": "display = raw × num / den + offset (raw in API units, 0.1 °C)", + "temp_den": "display = raw × num / den + offset (raw in API units, 0.1 °C)", + "temp_offset": "display = raw × num / den + offset (raw in API units, 0.1 °C)" + } } }, "error": { @@ -62,7 +80,8 @@ "invalid_import": "Invalid config JSON", "no_devices": "No devices found in the account", "unknown": "Unexpected error", - "properties_missing": "The device connected but did not report its base properties" + "properties_missing": "The device connected but did not report its base properties", + "invalid_range": "Minimum must be less than maximum, step must be positive" }, "abort": { "already_configured": "This device is already configured", @@ -73,5 +92,86 @@ "timeout": "Timed out waiting for the device", "properties_missing": "The device connected but did not report its base properties" } + }, + "entity": { + "climate": { + "name": "Air conditioner" + }, + "sensor": { + "room_temperature": { + "name": "Room temperature" + }, + "error_code": { + "name": "Error code" + }, + "connection_state": { + "name": "Connection state", + "state": { + "idle": "Idle", + "registering": "Registering", + "online": "Online", + "recovering": "Recovering", + "offline": "Offline", + "key_error": "Key error" + } + } + }, + "binary_sensor": { + "connectivity": { + "name": "Connectivity" + }, + "defrost": { + "name": "Defrost" + }, + "maintenance": { + "name": "Maintenance" + }, + "oil_recovery": { + "name": "Oil recovery" + }, + "pump_down": { + "name": "Pump down" + }, + "check_operation": { + "name": "Check operation" + }, + "simultaneous": { + "name": "Simultaneous operation" + } + }, + "switch": { + "economy_mode": { + "name": "Economy" + }, + "powerful_mode": { + "name": "Powerful" + }, + "coil_dry_mode": { + "name": "Coil dry" + }, + "min_heat": { + "name": "Minimum heat" + }, + "outdoor_low_noise": { + "name": "Outdoor low noise" + }, + "human_det_auto_save": { + "name": "Human detection auto save" + }, + "wifi_led_enable": { + "name": "Wi-Fi LED" + }, + "indoor_fan_control": { + "name": "Indoor fan control" + } + }, + "select": { + "af_vertical_direction": { + "name": "Vertical airflow direction" + }, + "af_horizontal_direction": { + "name": "Horizontal airflow direction" + } + } } } diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index e154cb2..ce067de 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -51,6 +51,24 @@ "data_description": { "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" } + }, + "preview": { + "title": "Превью шаблона", + "description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.", + "data": { + "template": "Шаблон", + "temp_min": "Минимум уставки (°C)", + "temp_max": "Максимум уставки (°C)", + "temp_step": "Шаг уставки (°C)", + "temp_num": "Числитель конверсии", + "temp_den": "Знаменатель конверсии", + "temp_offset": "Смещение конверсии (0,1 °C)" + }, + "data_description": { + "temp_num": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)", + "temp_den": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)", + "temp_offset": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)" + } } }, "error": { @@ -62,7 +80,8 @@ "invalid_import": "Некорректный config JSON", "no_devices": "В аккаунте не найдено устройств", "unknown": "Неожиданная ошибка", - "properties_missing": "Устройство подключилось, но не отдало базовые свойства" + "properties_missing": "Устройство подключилось, но не отдало базовые свойства", + "invalid_range": "Минимум должен быть меньше максимума, шаг — положительным" }, "abort": { "already_configured": "Устройство уже настроено", @@ -73,5 +92,86 @@ "timeout": "Устройство не вышло на связь за отведённое время", "properties_missing": "Устройство подключилось, но не отдало базовые свойства" } + }, + "entity": { + "climate": { + "name": "Кондиционер" + }, + "sensor": { + "room_temperature": { + "name": "Температура в помещении" + }, + "error_code": { + "name": "Код ошибки" + }, + "connection_state": { + "name": "Состояние связи", + "state": { + "idle": "Покой", + "registering": "Регистрация", + "online": "На связи", + "recovering": "Восстановление", + "offline": "Не в сети", + "key_error": "Ошибка ключа" + } + } + }, + "binary_sensor": { + "connectivity": { + "name": "Связь" + }, + "defrost": { + "name": "Разморозка" + }, + "maintenance": { + "name": "Обслуживание" + }, + "oil_recovery": { + "name": "Возврат масла" + }, + "pump_down": { + "name": "Pump down" + }, + "check_operation": { + "name": "Проверка работы" + }, + "simultaneous": { + "name": "Одновременные операции" + } + }, + "switch": { + "economy_mode": { + "name": "Экономичный режим" + }, + "powerful_mode": { + "name": "Мощный режим" + }, + "coil_dry_mode": { + "name": "Сушка испарителя" + }, + "min_heat": { + "name": "Минимальный обогрев" + }, + "outdoor_low_noise": { + "name": "Тихий наружный блок" + }, + "human_det_auto_save": { + "name": "Автосохранение при отсутствии людей" + }, + "wifi_led_enable": { + "name": "Wi-Fi индикатор" + }, + "indoor_fan_control": { + "name": "Управление вентилятором" + } + }, + "select": { + "af_vertical_direction": { + "name": "Положение заслонки (верт.)" + }, + "af_horizontal_direction": { + "name": "Положение заслонки (гориз.)" + } + } } } diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md index 5c5715c..0d03d48 100644 --- a/docs/PLAN_HOME_ASSISTANT.md +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -99,6 +99,9 @@ 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: семантика значений в PROTOCOL §8.3 не зафиксирована +(§10) — сущность не создаётся до уточнения; в H3 вместо неё +диагностический sensor состояния связи. ## 5. Runtime @@ -156,6 +159,6 @@ Security → Long-lived access tokens) и REST API: вызов сервисов |---|-----------| | H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | -| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | +| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H4 | repair, диагностика (ключ для ESPHome), translations | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда | diff --git a/pyfglair/session.py b/pyfglair/session.py index a01bc2c..e147e75 100644 --- a/pyfglair/session.py +++ b/pyfglair/session.py @@ -276,13 +276,15 @@ class Session: if handler is None: return loop = self._loop - if loop is not None and loop.is_running(): + if loop is not None: + if not loop.is_running(): + _LOGGER.debug("event loop остановлен, колбэк отброшен") + return try: loop.call_soon_threadsafe(handler, *args) - return except RuntimeError: _LOGGER.debug("event loop закрыт, колбэк отброшен") - return + return try: handler(*args) except Exception: diff --git a/tests/ayla/mock_ac.py b/tests/ayla/mock_ac.py index 27c41fe..6aa2e11 100644 --- a/tests/ayla/mock_ac.py +++ b/tests/ayla/mock_ac.py @@ -382,9 +382,14 @@ def main(): ap.add_argument("--stale-gap", type=float, default=44.0) ap.add_argument("--push-every", type=float, default=0) ap.add_argument("--fail-first-ke", action="store_true") + ap.add_argument("--set", action="append", default=[], metavar="NAME=VALUE", + help="предустановить свойство mock-модуля (повторяемо)") args = ap.parse_args() mock = Mock(args) + for item in args.set: + name, _, value = item.partition("=") + mock.props[name] = int(value, 0) # принимает 0b/0x/десятичное lock = mock.lock class H(http.server.BaseHTTPRequestHandler): diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index 36b6eb5..b35f150 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -13,6 +13,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.provision import Device MANUAL_INPUT = { @@ -25,6 +26,16 @@ MANUAL_INPUT = { "model": "AP-WC1E", } +PREVIEW_INPUT = { + "template": "A", + "temp_min": 16.0, + "temp_max": 30.0, + "temp_step": 0.5, + "temp_num": 1, + "temp_den": 1, + "temp_offset": 0, +} + TRIAL_OK = TrialResult(ok=True) TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable") @@ -41,6 +52,10 @@ async def _select(hass: HomeAssistant, flow_id: str, option: str): ) +async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict): + return await hass.config_entries.flow.async_configure(flow_id, user_input) + + def _suggested(schema, field: str): for key in schema.schema: if getattr(key, "schema", None) == field: @@ -64,19 +79,94 @@ async def test_manual_success(hass: HomeAssistant): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") assert result["type"] == "form" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MANUAL_INPUT - ) + 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["type"] == "create_entry" assert result["title"] == "Bedroom" assert result["result"].unique_id == DSN data = result["data"] assert data["lanip_key_id"] == KEY_ID assert data["template"] == "A" + assert data["temp_step"] == 5 + assert data["overrides"]["adjust_temperature"] == { + "num": 1, + "den": 1, + "offset": 0, + "min": 160, + "max": 300, + } 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): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + 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) + assert result["type"] == "create_entry" + assert result["data"]["template"] == "B" + assert result["data"]["temp_step"] == 10 + assert result["data"]["overrides"]["adjust_temperature"] == { + "num": 2, + "den": 4, + "offset": 5, + "min": 170, + "max": 280, + } + + +async def test_preview_invalid_range(hass: HomeAssistant): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_OK, + ): + 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"], {**PREVIEW_INPUT, "temp_min": 30.0, "temp_max": 16.0} + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_range"} + result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) + assert result["type"] == "create_entry" + + +async def test_preview_contains_trial_values(hass: HomeAssistant): + props = { + Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200), + Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011), + } + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TrialResult(ok=True, properties=props), + ): + 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 + + async def test_manual_connect_error(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", @@ -84,9 +174,7 @@ async def test_manual_connect_error(hass: HomeAssistant): ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MANUAL_INPUT - ) + result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "form" assert result["errors"] == {"base": "cannot_connect"} @@ -98,9 +186,7 @@ async def test_manual_key_mismatch_error(hass: HomeAssistant): ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MANUAL_INPUT - ) + result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "form" assert result["errors"] == {"base": "key_mismatch"} @@ -114,13 +200,25 @@ async def test_manual_already_configured(hass: HomeAssistant): ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MANUAL_INPUT - ) + result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "abort" assert result["reason"] == "already_configured" +async def test_manual_error_keeps_input(hass: HomeAssistant): + with patch( + "custom_components.fglair.config_flow._run_trial", + return_value=TRIAL_NETWORK_FAIL, + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "manual") + result = await _configure(hass, result["flow_id"], MANUAL_INPUT) + assert result["errors"] == {"base": "cannot_connect"} + assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"] + assert _suggested(result["data_schema"], "dsn") == DSN + assert _suggested(result["data_schema"], "lanip_key") is None + + async def test_import_json_success(hass: HomeAssistant): payload = json.dumps( { @@ -139,8 +237,13 @@ async def test_import_json_success(hass: HomeAssistant): result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") assert result["type"] == "form" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"config_json": payload} + result = await _configure( + hass, result["flow_id"], {"config_json": payload} + ) + assert result["step_id"] == "preview" + assert _suggested(result["data_schema"], "template") == "B" + result = await _configure( + hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"} ) assert result["type"] == "create_entry" assert result["data"]["host"] == "192.168.1.60" @@ -150,8 +253,8 @@ async def test_import_json_success(hass: HomeAssistant): async def test_import_json_invalid(hass: HomeAssistant): result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"config_json": "not json"} + result = await _configure( + hass, result["flow_id"], {"config_json": "not json"} ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_import"} @@ -176,57 +279,20 @@ async def test_cloud_flow_success(hass: HomeAssistant): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") assert result["type"] == "form" - result = await hass.config_entries.flow.async_configure( + result = await _configure( + hass, result["flow_id"], {"email": "user@example.com", "password": "secret", "region": "eu"}, ) - assert result["type"] == "form" - assert result["step_id"] == "cloud_device" - with patch( - "custom_components.fglair.config_flow._run_trial", - return_value=TRIAL_OK, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"dsn": DSN} - ) + 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["type"] == "create_entry" assert result["data"]["host"] == "192.168.1.50" assert result["data"]["lanip_key_id"] == KEY_ID -async def test_cloud_auth_error(hass: HomeAssistant): - with patch( - "pyfglair.discover", - AsyncMock( - side_effect=pyfglair.ProvisionError("401 unauthorized", "auth") - ), - ): - result = await _menu(hass) - result = await _select(hass, result["flow_id"], "cloud") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"email": "user@example.com", "password": "bad", "region": "eu"}, - ) - assert result["type"] == "form" - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_manual_error_keeps_input(hass: HomeAssistant): - with patch( - "custom_components.fglair.config_flow._run_trial", - return_value=TRIAL_NETWORK_FAIL, - ): - result = await _menu(hass) - result = await _select(hass, result["flow_id"], "manual") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MANUAL_INPUT - ) - assert result["errors"] == {"base": "cannot_connect"} - assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"] - assert _suggested(result["data_schema"], "dsn") == DSN - assert _suggested(result["data_schema"], "lanip_key") is None - - async def test_cloud_no_devices(hass: HomeAssistant): with patch( "pyfglair.discover", @@ -238,7 +304,8 @@ async def test_cloud_no_devices(hass: HomeAssistant): ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") - result = await hass.config_entries.flow.async_configure( + result = await _configure( + hass, result["flow_id"], {"email": "user@example.com", "password": "secret", "region": "eu"}, ) @@ -246,6 +313,24 @@ async def test_cloud_no_devices(hass: HomeAssistant): assert result["errors"] == {"base": "no_devices"} +async def test_cloud_auth_error(hass: HomeAssistant): + with patch( + "pyfglair.discover", + AsyncMock( + side_effect=pyfglair.ProvisionError("401 unauthorized", "auth") + ), + ): + result = await _menu(hass) + result = await _select(hass, result["flow_id"], "cloud") + result = await _configure( + hass, + result["flow_id"], + {"email": "user@example.com", "password": "bad", "region": "eu"}, + ) + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + async def test_yaml_import(hass: HomeAssistant): payload = { "ip_address": "192.168.1.70", @@ -289,9 +374,10 @@ async def test_import_json_null_device_port(hass: HomeAssistant): ) as trial: result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"config_json": payload} + result = await _configure( + hass, result["flow_id"], {"config_json": payload} ) + result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) assert result["type"] == "create_entry" assert result["data"]["device_port"] == 80 assert trial.call_args.args[0]["device_port"] == 80 diff --git a/tests/components/fglair/test_entities.py b/tests/components/fglair/test_entities.py new file mode 100644 index 0000000..b17b628 --- /dev/null +++ b/tests/components/fglair/test_entities.py @@ -0,0 +1,273 @@ +"""Сущности fglair против mock_ac.py: climate/switch/select/sensor/binary.""" +from __future__ import annotations + +import asyncio +import time + +from helpers import DSN, KEY_ID, LANIP_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.fglair.const import DOMAIN +from pyfglair import State + +MOCK_SETTINGS = [ + "--set", "operation_mode=6", + "--set", "fan_speed=4", + "--set", "adjust_temperature=220", + "--set", "display_temperature=7000", + "--set", "device_capabilities=0b1111111111111111111", + "--set", "af_vertical_num_dir=5", + "--set", "af_horizontal_num_dir=7", + "--set", "af_vertical_direction=2", + "--set", "af_horizontal_direction=3", + "--set", "op_status=0b1000000010000000000000000", + "--set", "economy_mode=0", + "--set", "powerful_mode=0", +] + + +async def _wait_for(predicate, timeout: float = 20.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + await asyncio.sleep(0.1) + raise AssertionError("условие не выполнено за отведённое время") + + +def _entity_id(hass: HomeAssistant, platform: str, unique_id: str): + return er.async_get(hass).async_get_entity_id(platform, DOMAIN, unique_id) + + +async def _setup_entry(hass: HomeAssistant, port: int) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "127.0.0.1", + "device_port": port, + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "template": "A", + "name": "Mock AC", + "listen_port": 0, + }, + unique_id=DSN, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled): + proc = mock_ac(MOCK_SETTINGS) + entry = await _setup_entry(hass, proc.port) + runtime = entry.runtime_data + + await _wait_for(lambda: runtime.client.state == State.ONLINE) + climate_id = _entity_id(hass, "climate", f"{DSN}_climate") + await _wait_for( + lambda: climate_id and hass.states.get(climate_id) is not None + and hass.states.get(climate_id).state == "heat" + and hass.states.get(climate_id).attributes["swing_mode"] == "off" + ) + + climate = hass.states.get(climate_id) + assert climate.attributes["current_temperature"] == 20.0 + assert climate.attributes["temperature"] == 22.0 + assert climate.attributes["fan_mode"] == "auto" + assert climate.attributes["preset_mode"] is None + assert "heat" in climate.attributes["hvac_modes"] + assert "cool" in climate.attributes["hvac_modes"] + assert climate.attributes["swing_mode"] == "off" + + room = _entity_id(hass, "sensor", f"{DSN}_display_temperature") + await _wait_for( + lambda: room and hass.states.get(room) is not None + and hass.states.get(room).state == "20.0" + ) + + connectivity = _entity_id(hass, "binary_sensor", f"{DSN}_connectivity") + assert hass.states.get(connectivity).state == "on" + + defrost = _entity_id(hass, "binary_sensor", f"{DSN}_op_status_defrost") + await _wait_for(lambda: hass.states.get(defrost).state == "on") + maintenance = _entity_id( + hass, "binary_sensor", f"{DSN}_op_status_maintenance" + ) + assert hass.states.get(maintenance).state == "off" + + vertical = _entity_id( + hass, "select", f"{DSN}_af_vertical_direction" + ) + await _wait_for( + lambda: hass.states.get(vertical).attributes["options"] + == ["0", "1", "2", "3", "4"] + ) + vertical_state = hass.states.get(vertical) + assert vertical_state.state == "2" + + economy = _entity_id(hass, "switch", f"{DSN}_economy_mode") + assert hass.states.get(economy).state == "off" + + connection = _entity_id(hass, "sensor", f"{DSN}_connection_state") + assert hass.states.get(connection).state == "online" + + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled): + proc = mock_ac(MOCK_SETTINGS) + entry = await _setup_entry(hass, proc.port) + runtime = entry.runtime_data + await _wait_for(lambda: runtime.client.state == State.ONLINE) + climate_id = _entity_id(hass, "climate", f"{DSN}_climate") + select_id = _entity_id(hass, "select", f"{DSN}_af_vertical_direction") + await _wait_for( + lambda: climate_id and hass.states.get(climate_id) is not None + and hass.states.get(climate_id).state == "heat" + and hass.states.get(select_id).attributes["options"] + == ["0", "1", "2", "3", "4"] + ) + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": climate_id, "hvac_mode": "cool"}, + blocking=True, + ) + assert proc.wait_line("CMD SET operation_mode=3", 10) + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": climate_id, "temperature": 25.5}, + blocking=True, + ) + assert proc.wait_line("CMD SET adjust_temperature=255", 10) + + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": climate_id, "fan_mode": "low"}, + blocking=True, + ) + assert proc.wait_line("CMD SET fan_speed=1", 10) + + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": climate_id, "preset_mode": "eco"}, + blocking=True, + ) + assert proc.wait_line("CMD SET economy_mode=True", 10) + assert proc.wait_line("CMD SET powerful_mode=False", 10) + + await _wait_for( + lambda: hass.states.get(climate_id).state == "cool" + ) + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": select_id, "option": "4"}, + blocking=True, + ) + assert proc.wait_line("CMD SET af_vertical_direction=4", 10) + + switch_id = _entity_id(hass, "switch", f"{DSN}_economy_mode") + await hass.services.async_call( + "switch", "turn_on", {"entity_id": switch_id}, blocking=True + ) + assert proc.wait_line("CMD SET economy_mode=True", 10) + + assert await hass.config_entries.async_unload(entry.entry_id) + + +RESTRICTED_SETTINGS = [ + "--set", "operation_mode=3", + "--set", "fan_speed=4", + "--set", "device_capabilities=0b100001", # cool (0) + fan auto (5) +] + + +async def test_capabilities_filter( + hass: HomeAssistant, mock_ac, socket_enabled +): + proc = mock_ac(RESTRICTED_SETTINGS) + entry = await _setup_entry(hass, proc.port) + runtime = entry.runtime_data + await _wait_for(lambda: runtime.client.state == State.ONLINE) + climate_id = _entity_id(hass, "climate", f"{DSN}_climate") + await _wait_for( + lambda: climate_id and hass.states.get(climate_id) is not None + and hass.states.get(climate_id).state == "cool" + and "heat" not in hass.states.get(climate_id).attributes["hvac_modes"] + ) + climate = hass.states.get(climate_id) + assert "heat" not in climate.attributes["hvac_modes"] + assert "cool" in climate.attributes["hvac_modes"] + assert climate.attributes["fan_modes"] == ["auto"] + assert climate.attributes.get("preset_modes") is None + assert await hass.config_entries.async_unload(entry.entry_id) + + +async def test_manual_conversion_applied( + hass: HomeAssistant, mock_ac, socket_enabled +): + settings = [ + "--set", "operation_mode=6", + "--set", "adjust_temperature=220", + "--set", "display_temperature=7000", + ] + proc = mock_ac(settings) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "127.0.0.1", + "device_port": proc.port, + "dsn": DSN, + "lanip_key": LANIP_KEY, + "lanip_key_id": KEY_ID, + "template": "A", + "name": "Mock AC", + "listen_port": 0, + "temp_step": 5, + "overrides": { + "adjust_temperature": { + "num": 1, + "den": 1, + "offset": 5, + "min": 170, + "max": 280, + } + }, + }, + unique_id=DSN, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + runtime = entry.runtime_data + await _wait_for(lambda: runtime.client.state == State.ONLINE) + climate_id = _entity_id(hass, "climate", f"{DSN}_climate") + await _wait_for( + lambda: climate_id and hass.states.get(climate_id) is not None + and hass.states.get(climate_id).state == "heat" + ) + assert hass.states.get(climate_id).attributes["temperature"] == 22.5 + assert hass.states.get(climate_id).attributes["min_temp"] == 17.0 + assert hass.states.get(climate_id).attributes["max_temp"] == 28.0 + + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": climate_id, "temperature": 25.0}, + blocking=True, + ) + assert proc.wait_line("CMD SET adjust_temperature=245", 10) + assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/fglair/test_init.py b/tests/components/fglair/test_init.py index ba9b627..d550d1e 100644 --- a/tests/components/fglair/test_init.py +++ b/tests/components/fglair/test_init.py @@ -17,13 +17,14 @@ async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - client = config_entry.runtime_data - assert client is not None - assert isinstance(client.state, State) + runtime = config_entry.runtime_data + assert runtime is not None + assert isinstance(runtime.client.state, State) + assert runtime.coordinator.data is not None assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert client.cached(Prop.OPERATION_MODE) is None + assert runtime.client.cached(Prop.OPERATION_MODE) is None async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled): @@ -44,9 +45,9 @@ async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_ assert first.state.name == "LOADED" assert second.state.name == "LOADED" - first_client = first.runtime_data + first_runtime = first.runtime_data assert await hass.config_entries.async_unload(first.entry_id) - assert first_client.cached(Prop.OPERATION_MODE) is None + assert first_runtime.client.cached(Prop.OPERATION_MODE) is None assert second.state.name == "LOADED" assert await hass.config_entries.async_unload(second.entry_id) diff --git a/tests/components/fglair/test_overrides.py b/tests/components/fglair/test_overrides.py new file mode 100644 index 0000000..e83fa47 --- /dev/null +++ b/tests/components/fglair/test_overrides.py @@ -0,0 +1,66 @@ +"""Ручные конверсии/диапазоны (ConversionSet) без HA-рантайма.""" +from __future__ import annotations + +from custom_components.fglair.overrides import ConversionSet, LinearOverride +from pyfglair import Prop, Template + + +def test_identity_keeps_api_units(): + conversions = ConversionSet(Template.A, {}, 5) + assert conversions.to_display(Prop.DISPLAY_TEMPERATURE, 200) == 200 + assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 220 + assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 220) == 220 + assert conversions.temperature_range() == (160, 300) + + +def test_linear_override_roundtrip_and_clamp(): + override = LinearOverride( + num=1, den=1, offset=5, min_value=170, max_value=280 + ) + conversions = ConversionSet( + Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5 + ) + assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 225 + assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 220 + assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 100) == 170 + assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 999) == 280 + assert conversions.temperature_range() == (170, 280) + + +def test_linear_override_scaled(): + override = LinearOverride(num=2, den=4, offset=0) + conversions = ConversionSet( + Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5 + ) + assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 200) == 100 + assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200 + + +def test_from_entry_parses_spec(): + conversions = ConversionSet.from_entry( + { + "template": "B", + "temp_step": 10, + "overrides": { + "adjust_temperature": { + "num": 2, + "den": 4, + "offset": 5, + "min": 170, + "max": 280, + } + }, + } + ) + assert conversions.template == Template.B + assert conversions.step_tenths == 10 + assert conversions.temperature_range() == (170, 280) + override = conversions.override(Prop.ADJUST_TEMPERATURE) + assert (override.num, override.den, override.offset) == (2, 4, 5) + assert conversions.override(Prop.FAN_SPEED).identity + + +def test_from_entry_defaults(): + conversions = ConversionSet.from_entry({"template": "F"}) + assert conversions.step_tenths == 5 + assert conversions.temperature_range() == (160, 300)