Files
fgl-aircon/custom_components/fglair/climate.py
T
petr.polezhaev dee5819743 ha(H3): ниты повторного ревью — пустой диапазон, merge значений, чистка
- R1: config flow отклоняет пустое пересечение override и таблицы
  (достижимый диапазон), + тест
- R2: async_sync_now перечитывает снимок без await (события не затираются)
- R3: translation_key климата убран (FglairEntity принимает None)
- R4: caps==0 «не сообщено» покрыт тестом
- R5: SWING_HORIZONTAL_MODES удалён (общий SWING_MODES)
2026-09-29 13:54:55 +03:00

263 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_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:
# translation_key не нужен: имя = имя устройства (has_entity_name).
super().__init__(coordinator, "climate", None)
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_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()