diff --git a/custom_components/fglair/__init__.py b/custom_components/fglair/__init__.py index a967cdb..6d716a8 100644 --- a/custom_components/fglair/__init__.py +++ b/custom_components/fglair/__init__.py @@ -64,19 +64,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_PENDING] = hass.data.get(DATA_PENDING, 0) + 1 client = FglairClient(hass, entry.data) - try: - await client.async_start() - finally: - hass.data[DATA_PENDING] -= 1 - conversions = ConversionSet.from_entry(entry.data) + # Координатор создаётся (и подписывается) до старта сессии, чтобы не + # пропустить первое ONLINE. coordinator = FglairCoordinator(hass, client, conversions) try: + await client.async_start() await coordinator.async_config_entry_first_refresh() + await coordinator.async_sync_now() except Exception: await coordinator.async_shutdown() await client.async_stop() raise + finally: + hass.data[DATA_PENDING] -= 1 clients[entry.entry_id] = client entry.runtime_data = FglairRuntime(client, coordinator, conversions) @@ -93,6 +94,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unloaded = await hass.config_entries.async_unload_platforms( entry, PLATFORMS ) + if not unloaded: + return False await runtime.coordinator.async_shutdown() await runtime.client.async_stop() diff --git a/custom_components/fglair/climate.py b/custom_components/fglair/climate.py index a35452a..e4fb6d1 100644 --- a/custom_components/fglair/climate.py +++ b/custom_components/fglair/climate.py @@ -43,10 +43,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: 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") @@ -105,6 +103,7 @@ class FglairClimate(FglairEntity, ClimateEntity): features |= ClimateEntityFeature.SWING_MODE if self._horizontal_swing_supported(): features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF return features @property @@ -119,10 +118,9 @@ class FglairClimate(FglairEntity, ClimateEntity): 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 + # Режим включения (1) не несёт информации о режиме: до первого + # явного режима состояние неизвестно. + return self._last_hvac_mode return HVAC_VALUE_TO_MODE.get(value) @property @@ -212,6 +210,11 @@ class FglairClimate(FglairEntity, ClimateEntity): [(Prop.OPERATION_MODE, OPERATION_MODE_ON)] ) + async def async_turn_off(self) -> None: + await self.coordinator.async_write( + [(Prop.OPERATION_MODE, OPERATION_MODE_OFF)] + ) + async def async_set_temperature(self, **kwargs: Any) -> None: temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: diff --git a/custom_components/fglair/config_flow.py b/custom_components/fglair/config_flow.py index edd6844..e67ecfc 100644 --- a/custom_components/fglair/config_flow.py +++ b/custom_components/fglair/config_flow.py @@ -410,8 +410,6 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): 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 @@ -429,19 +427,20 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN): template.name, temp_min, temp_max, step, ) return self._create_entry(data) - schema = _preview_schema(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: data.get(CONF_TEMPLATE, Template.A.name), - } + user_input = {CONF_TEMPLATE: selected} return self.async_show_form( step_id="preview", - data_schema=self.add_suggested_values_to_schema(schema, user_input), + data_schema=self.add_suggested_values_to_schema( + _preview_schema(data), user_input + ), errors=errors, description_placeholders={ "preview": _preview_text( - Template[str(data.get(CONF_TEMPLATE, "A"))], - self._trial_properties, + Template[selected], self._trial_properties ) }, ) diff --git a/custom_components/fglair/const.py b/custom_components/fglair/const.py index 8b93763..b1beeff 100644 --- a/custom_components/fglair/const.py +++ b/custom_components/fglair/const.py @@ -121,6 +121,7 @@ OP_STATUS_BITS: dict[str, int] = { "pump_down": 29, "check_operation": 30, "simultaneous": 25, + "different_modes": 21, } # Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]); diff --git a/custom_components/fglair/coordinator.py b/custom_components/fglair/coordinator.py index 82883f4..6e95ed4 100644 --- a/custom_components/fglair/coordinator.py +++ b/custom_components/fglair/coordinator.py @@ -7,6 +7,7 @@ from dataclasses import dataclass, field, replace from typing import Optional from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind @@ -93,10 +94,34 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): 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: + if state == State.ONLINE: + if not self._synced and self._sync_task is None: + self._sync_task = self.hass.async_create_task( + self._async_initial_sync() + ) + else: + # После разрыва при следующем ONLINE нужна повторная синхронизация. + self._synced = False + + async def async_sync_now(self) -> None: + """Гарантирует первичный GET-батч (если уже online).""" + if self._sync_task is not None: + await self._sync_task + elif self.client.state == State.ONLINE and not self._synced: self._sync_task = self.hass.async_create_task( self._async_initial_sync() ) + await self._sync_task + # Снимок создавался до ONLINE: обновляем состояние/доступность из + # актуального состояния клиента (значения приходят событиями). + state = self.client.state + self.data = replace( + self.data, + state=state, + last_error=self.client.last_error, + available=state in AVAILABLE_STATES, + ) + self.async_set_updated_data(self.data) @callback def _on_property(self, event: PropertyEvent) -> None: @@ -127,7 +152,11 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): """Одно действие пользователя — один batch_commit().""" if not updates: return - await self.hass.async_add_executor_job(self._write, updates) + ok = await self.hass.async_add_executor_job(self._write, updates) + if not ok: + raise HomeAssistantError( + "FGLair: команда не отправлена (сессия недоступна)" + ) values = dict(self.data.values) for prop, _ in updates: value = self.client.cached(prop) @@ -136,11 +165,16 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]): 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() + def _write(self, updates: list[tuple[Prop, int]]) -> bool: + if not self.client.batch_begin(): + return False + ok = True for prop, value in updates: - self.client.set_int(prop, value) - self.client.batch_commit() + if not self.client.set_int(prop, value): + ok = False + if not self.client.batch_commit(): + ok = False + return ok async def async_shutdown(self) -> None: for unsub in self._unsubs: diff --git a/custom_components/fglair/entity.py b/custom_components/fglair/entity.py index a1c64db..d102545 100644 --- a/custom_components/fglair/entity.py +++ b/custom_components/fglair/entity.py @@ -58,7 +58,8 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]): def capability(self, bit: int) -> Optional[bool]: caps = self.capabilities() - if caps is None: + if not caps: + # 0/None: возможностей ещё не сообщили — не фильтруем. return None return bool(caps & (1 << bit)) diff --git a/custom_components/fglair/overrides.py b/custom_components/fglair/overrides.py index e57e53f..24c760c 100644 --- a/custom_components/fglair/overrides.py +++ b/custom_components/fglair/overrides.py @@ -12,7 +12,7 @@ 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 pyfglair.templates import convert_from_input, convert_to_display, prop_info from .const import ( CONF_OVERRIDES, @@ -112,22 +112,61 @@ class ConversionSet: 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().""" + def raw_range(self, prop: Prop) -> Optional[tuple[int, int]]: + """raw-диапазон протокольной таблицы шаблона (None — не задан).""" + info = prop_info(self.template, prop) + if info is None or info.raw_min is None or info.raw_max is None: + return None + return (min(info.raw_min, info.raw_max), max(info.raw_min, info.raw_max)) + + def _display_of_raw(self, prop: Prop, raw: int) -> int: override = self.override(prop) if override.identity: - return display - raw = override.from_display(display) + return convert_to_display(self.template, prop, raw) + return override.to_display(raw) + + def from_display(self, prop: Prop, display: int) -> int: + """Отображаемое значение → API-значение для set_int(). + + raw клампится по таблице шаблона (ядро тоже клампит — не отдаём + молча команду за пределами достижимого). + """ + override = self.override(prop) + raw = display if override.identity else override.from_display(display) + raw_range = self.raw_range(prop) + if raw_range is not None: + raw = max(raw_range[0], min(raw_range[1], raw)) + if override.identity: + return raw return convert_to_display(self.template, prop, raw) def clamp(self, prop: Prop, display: int) -> int: - return self.override(prop).clamp(display) + bounds = self.range(prop) + if bounds is None: + return display + return max(bounds[0], min(bounds[1], display)) def range(self, prop: Prop) -> Optional[tuple[int, int]]: + """Достижимый диапазон display: override ∩ таблица шаблона.""" override = self.override(prop) - if override.min_value is None or override.max_value is None: + low = override.min_value + high = override.max_value + if prop == Prop.ADJUST_TEMPERATURE: + # Фактические ограничения прибора (PROTOCOL §8.3), если + # пользователь не задал свои. + low = TEMP_MIN_TENTHS if low is None else low + high = TEMP_MAX_TENTHS if high is None else high + raw_range = self.raw_range(prop) + if raw_range is not None: + raw_low = self._display_of_raw(prop, raw_range[0]) + raw_high = self._display_of_raw(prop, raw_range[1]) + if raw_low > raw_high: + raw_low, raw_high = raw_high, raw_low + low = raw_low if low is None else max(low, raw_low) + high = raw_high if high is None else min(high, raw_high) + if low is None or high is None: return None - return (override.min_value, override.max_value) + return (low, high) def temperature_range(self) -> tuple[int, int]: return self.range(Prop.ADJUST_TEMPERATURE) or ( diff --git a/custom_components/fglair/strings.json b/custom_components/fglair/strings.json index 0c84d6d..7bfd348 100644 --- a/custom_components/fglair/strings.json +++ b/custom_components/fglair/strings.json @@ -65,9 +65,9 @@ "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)" + "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)" } } }, @@ -81,7 +81,7 @@ "no_devices": "No devices found in the account", "unknown": "Unexpected error", "properties_missing": "The device connected but did not report its base properties", - "invalid_range": "Minimum must be less than maximum, step must be positive" + "invalid_range": "Minimum must be less than maximum" }, "abort": { "already_configured": "This device is already configured", @@ -94,9 +94,6 @@ } }, "entity": { - "climate": { - "name": "Air conditioner" - }, "sensor": { "room_temperature": { "name": "Room temperature" @@ -137,6 +134,9 @@ }, "simultaneous": { "name": "Simultaneous operation" + }, + "different_modes": { + "name": "Freeze/oil/other modes (template B)" } }, "switch": { diff --git a/custom_components/fglair/switch.py b/custom_components/fglair/switch.py index 18d1cf9..3a4e381 100644 --- a/custom_components/fglair/switch.py +++ b/custom_components/fglair/switch.py @@ -55,6 +55,6 @@ def _capability_supported( if bit is None: return True caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES) - if caps is None: + 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 0c84d6d..7bfd348 100644 --- a/custom_components/fglair/translations/en.json +++ b/custom_components/fglair/translations/en.json @@ -65,9 +65,9 @@ "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)" + "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)" } } }, @@ -81,7 +81,7 @@ "no_devices": "No devices found in the account", "unknown": "Unexpected error", "properties_missing": "The device connected but did not report its base properties", - "invalid_range": "Minimum must be less than maximum, step must be positive" + "invalid_range": "Minimum must be less than maximum" }, "abort": { "already_configured": "This device is already configured", @@ -94,9 +94,6 @@ } }, "entity": { - "climate": { - "name": "Air conditioner" - }, "sensor": { "room_temperature": { "name": "Room temperature" @@ -137,6 +134,9 @@ }, "simultaneous": { "name": "Simultaneous operation" + }, + "different_modes": { + "name": "Freeze/oil/other modes (template B)" } }, "switch": { diff --git a/custom_components/fglair/translations/ru.json b/custom_components/fglair/translations/ru.json index ce067de..f319c7b 100644 --- a/custom_components/fglair/translations/ru.json +++ b/custom_components/fglair/translations/ru.json @@ -65,9 +65,9 @@ "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)" + "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)" } } }, @@ -81,7 +81,7 @@ "no_devices": "В аккаунте не найдено устройств", "unknown": "Неожиданная ошибка", "properties_missing": "Устройство подключилось, но не отдало базовые свойства", - "invalid_range": "Минимум должен быть меньше максимума, шаг — положительным" + "invalid_range": "Минимум должен быть меньше максимума" }, "abort": { "already_configured": "Устройство уже настроено", @@ -94,9 +94,6 @@ } }, "entity": { - "climate": { - "name": "Кондиционер" - }, "sensor": { "room_temperature": { "name": "Температура в помещении" @@ -137,6 +134,9 @@ }, "simultaneous": { "name": "Одновременные операции" + }, + "different_modes": { + "name": "Разморозка/масло/прочие режимы (шаблон B)" } }, "switch": { diff --git a/pyfglair/templates.py b/pyfglair/templates.py index ede28d5..910d360 100644 --- a/pyfglair/templates.py +++ b/pyfglair/templates.py @@ -3,6 +3,7 @@ src/aircon/tables.cpp; дублей в Python нет).""" from __future__ import annotations from dataclasses import dataclass +from functools import lru_cache from typing import Optional, Tuple from ._cffi import ffi, lib @@ -36,6 +37,7 @@ def detect(oem_model: Optional[str]) -> Optional[Template]: return Template(result) +@lru_cache(maxsize=None) def template_info(template: Template) -> Tuple[PropertyInfo, ...]: """Все свойства шаблона (порядок констант Prop).""" out = [] @@ -67,6 +69,7 @@ def template_info(template: Template) -> Tuple[PropertyInfo, ...]: return tuple(out) +@lru_cache(maxsize=None) def prop_info(template: Template, prop: Prop) -> Optional[PropertyInfo]: for info in template_info(template): if info.prop == prop: diff --git a/tests/ayla/mock_ac.py b/tests/ayla/mock_ac.py index 6aa2e11..6eda1c9 100644 --- a/tests/ayla/mock_ac.py +++ b/tests/ayla/mock_ac.py @@ -292,6 +292,9 @@ class Mock: cmd = c.get("cmd", {}) if cmd.get("method") == "DELETE": print("DELETE", flush=True) + # Слот освобождён: следующая сессия должна получить KE. + self.crypto = None + self.active = False return res = cmd.get("resource", "") name = res.split("name=")[-1] diff --git a/tests/components/fglair/test_config_flow.py b/tests/components/fglair/test_config_flow.py index b35f150..4ba7732 100644 --- a/tests/components/fglair/test_config_flow.py +++ b/tests/components/fglair/test_config_flow.py @@ -141,10 +141,18 @@ async def test_preview_invalid_range(hass: HomeAssistant): 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} + hass, + result["flow_id"], + { + **PREVIEW_INPUT, + "template": "B", + "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) assert result["type"] == "create_entry" diff --git a/tests/components/fglair/test_entities.py b/tests/components/fglair/test_entities.py index b17b628..8cc4e6a 100644 --- a/tests/components/fglair/test_entities.py +++ b/tests/components/fglair/test_entities.py @@ -185,9 +185,33 @@ async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled): ) assert proc.wait_line("CMD SET economy_mode=True", 10) + await hass.services.async_call( + "climate", "turn_off", {"entity_id": climate_id}, blocking=True + ) + assert proc.wait_line("CMD SET operation_mode=0", 10) + await hass.services.async_call( + "climate", "turn_on", {"entity_id": climate_id}, blocking=True + ) + assert proc.wait_line("CMD SET operation_mode=1", 10) + assert await hass.config_entries.async_unload(entry.entry_id) +async def test_reload_online(hass: HomeAssistant, mock_ac, socket_enabled): + """После unload тот же прибор снова поднимается до ONLINE.""" + 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) + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + 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) + + RESTRICTED_SETTINGS = [ "--set", "operation_mode=3", "--set", "fan_speed=4", @@ -258,6 +282,7 @@ async def test_manual_conversion_applied( 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["temperature"] == 22.5 ) assert hass.states.get(climate_id).attributes["temperature"] == 22.5 assert hass.states.get(climate_id).attributes["min_temp"] == 17.0 diff --git a/tests/components/fglair/test_overrides.py b/tests/components/fglair/test_overrides.py index e83fa47..8843b7c 100644 --- a/tests/components/fglair/test_overrides.py +++ b/tests/components/fglair/test_overrides.py @@ -36,6 +36,20 @@ def test_linear_override_scaled(): assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200 +def test_scaled_override_clamped_to_table(): + """display = raw/2: таблица A -100..450 → достижимо -50..225.""" + override = LinearOverride( + num=1, den=2, offset=0, min_value=160, max_value=300 + ) + conversions = ConversionSet( + Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5 + ) + assert conversions.temperature_range() == (160, 225) + assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 250) == 225 + assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 250) == 450 + assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 450 + + def test_from_entry_parses_spec(): conversions = ConversionSet.from_entry( { @@ -54,7 +68,9 @@ def test_from_entry_parses_spec(): ) assert conversions.template == Template.B assert conversions.step_tenths == 10 - assert conversions.temperature_range() == (170, 280) + # display = raw/2 + 5: таблица -100..450 → -45..230, override 170..280 + # → достижимо 170..230. + assert conversions.temperature_range() == (170, 230) override = conversions.override(Prop.ADJUST_TEMPERATURE) assert (override.num, override.den, override.offset) == (2, 4, 5) assert conversions.override(Prop.FAN_SPEED).identity diff --git a/tests/pyfglair/test_templates.py b/tests/pyfglair/test_templates.py index 108a4c5..2322e2a 100644 --- a/tests/pyfglair/test_templates.py +++ b/tests/pyfglair/test_templates.py @@ -9,6 +9,7 @@ from pyfglair import ( convert_from_input, convert_to_display, detect, + prop_info, template_info, ) from pyfglair._cffi import ffi, lib @@ -61,6 +62,13 @@ def test_conversions(): assert convert_from_input(Template.A, Prop.ADJUST_TEMPERATURE, 220) == 220 +def test_template_info_is_cached(): + assert template_info(Template.A) is template_info(Template.A) + assert prop_info(Template.A, Prop.FAN_SPEED) is prop_info( + Template.A, Prop.FAN_SPEED + ) + + def test_api_range(): assert api_range(Template.A, Prop.DISPLAY_TEMPERATURE) == (-100, 450) assert api_range(Template.A, Prop.OPERATION_MODE) == (0, 6)