- M1: supported_features += TURN_ON/TURN_OFF (сервисы в HA 2026.9), async_turn_off; удалён мёртвый _enable_turn_on_off_backwards_compat; тест turn_on/turn_off → operation_mode 1/0 - M2: достижимый диапазон = override ∩ raw-таблица шаблона; raw клампится в from_display, clamp() по достижимому диапазону (молчаливый кламп ядра устранён); тесты scaled override - M3: mock_ac сбрасывает crypto/active на DELETE; тест reload → снова ONLINE - гонка: async_sync_now больше не перезаписывает данные устаревшим снимком (ONLINE мог потеряться при быстром старте) — стабильность 4×37 тестов - m1: превью-текст учитывает выбранный в форме шаблон; m2: убрана недостижимая проверка шага; m3: удалён мёртвый translation_key climate - m4: бит 21 op_status (шаблон B); m5: lru_cache template_info/prop_info; m6: operation_mode=1 без истории → hvac_mode unknown; m7: ошибка команды → HomeAssistantError; m8: unload возвращает False, не разбирая runtime - n2: уточнены описания data_description в переводах
267 lines
9.0 KiB
Python
267 lines
9.0 KiB
Python
"""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_name = None
|
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
|
|
|
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
|
|
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
|
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:
|
|
# Режим включения (1) не несёт информации о режиме: до первого
|
|
# явного режима состояние неизвестно.
|
|
return self._last_hvac_mode
|
|
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_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:
|
|
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()
|