diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 9d8bf4b6e3a3d..e2f04ad05f3d3 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -36,7 +36,7 @@ DOMAIN = "growatt_server" -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] LOGIN_INVALID_AUTH_CODE = "502" diff --git a/homeassistant/components/growatt_server/number.py b/homeassistant/components/growatt_server/number.py new file mode 100644 index 0000000000000..7016c25cadb22 --- /dev/null +++ b/homeassistant/components/growatt_server/number.py @@ -0,0 +1,162 @@ +"""Number platform for Growatt.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from growattServer import GrowattV1ApiError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import GrowattConfigEntry, GrowattCoordinator +from .sensor.sensor_entity_description import GrowattRequiredKeysMixin + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = ( + 1 # Serialize updates as inverter does not handle concurrent requests +) + + +@dataclass(frozen=True, kw_only=True) +class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin): + """Describes Growatt number entity.""" + + write_key: str | None = None # Parameter ID for writing (if different from api_key) + + +# Note that the Growatt V1 API uses different keys for reading and writing parameters. +# Reading values returns camelCase keys, while writing requires snake_case keys. + +MIN_NUMBER_TYPES: tuple[GrowattNumberEntityDescription, ...] = ( + GrowattNumberEntityDescription( + key="battery_charge_power_limit", + translation_key="battery_charge_power_limit", + api_key="chargePowerCommand", # Key returned by V1 API + write_key="charge_power", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="battery_charge_soc_limit", + translation_key="battery_charge_soc_limit", + api_key="wchargeSOCLowLimit", # Key returned by V1 API + write_key="charge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="battery_discharge_power_limit", + translation_key="battery_discharge_power_limit", + api_key="disChargePowerCommand", # Key returned by V1 API + write_key="discharge_power", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), + GrowattNumberEntityDescription( + key="battery_discharge_soc_limit", + translation_key="battery_discharge_soc_limit", + api_key="wdisChargeSOCLowLimit", # Key returned by V1 API + write_key="discharge_stop_soc", # Key used to write parameter + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: GrowattConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Growatt number entities.""" + runtime_data = entry.runtime_data + + # Add number entities for each MIN device (only supported with V1 API) + async_add_entities( + GrowattNumber(device_coordinator, description) + for device_coordinator in runtime_data.devices.values() + if ( + device_coordinator.device_type == "min" + and device_coordinator.api_version == "v1" + ) + for description in MIN_NUMBER_TYPES + ) + + +class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity): + """Representation of a Growatt number.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.CONFIG + entity_description: GrowattNumberEntityDescription + + def __init__( + self, + coordinator: GrowattCoordinator, + description: GrowattNumberEntityDescription, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + manufacturer="Growatt", + name=coordinator.device_id, + ) + + @property + def native_value(self) -> int | None: + """Return the current value of the number.""" + value = self.coordinator.data.get(self.entity_description.api_key) + if value is None: + return None + return int(value) + + async def async_set_native_value(self, value: float) -> None: + """Set the value of the number.""" + # Use write_key if specified, otherwise fall back to api_key + parameter_id = ( + self.entity_description.write_key or self.entity_description.api_key + ) + int_value = int(value) + + try: + # Use V1 API to write parameter + await self.hass.async_add_executor_job( + self.coordinator.api.min_write_parameter, + self.coordinator.device_id, + parameter_id, + int_value, + ) + except GrowattV1ApiError as e: + raise HomeAssistantError(f"Error while setting parameter: {e}") from e + + # If no exception was raised, the write was successful + _LOGGER.debug( + "Set parameter %s to %s", + parameter_id, + value, + ) + + # Update the value in coordinator data to avoid triggering an immediate + # refresh that would hit the API rate limit (5-minute polling interval) + self.coordinator.data[self.entity_description.api_key] = int_value + self.async_write_ha_state() diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index d353350c968a6..93a1ded5c3dc0 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -504,6 +504,20 @@ "name": "Maximum power" } }, + "number": { + "battery_charge_power_limit": { + "name": "Battery charge power limit" + }, + "battery_charge_soc_limit": { + "name": "Battery charge SOC limit" + }, + "battery_discharge_power_limit": { + "name": "Battery discharge power limit" + }, + "battery_discharge_soc_limit": { + "name": "Battery discharge SOC limit" + } + }, "switch": { "ac_charge": { "name": "Charge from grid" diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index f59d48de62992..70d78ce8fe71e 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -12,6 +12,7 @@ ) from xknx.devices.fan import FanSpeedMode from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode +from xknx.remote_value.remote_value_setpoint_shift import SetpointShiftMode from homeassistant import config_entries from homeassistant.components.climate import ( @@ -34,13 +35,53 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import ConfigType -from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY -from .entity import KnxYamlEntity +from .const import ( + CONF_SYNC_STATE, + CONTROLLER_MODES, + CURRENT_HVAC_ACTIONS, + DOMAIN, + KNX_MODULE_KEY, + ClimateConf, +) +from .entity import ( + KnxUiEntity, + KnxUiEntityPlatformController, + KnxYamlEntity, + _KnxEntityBase, +) from .knx_module import KNXModule from .schema import ClimateSchema +from .storage.const import ( + CONF_ENTITY, + CONF_GA_ACTIVE, + CONF_GA_CONTROLLER_MODE, + CONF_GA_CONTROLLER_STATUS, + CONF_GA_FAN_SPEED, + CONF_GA_FAN_SWING, + CONF_GA_FAN_SWING_HORIZONTAL, + CONF_GA_HEAT_COOL, + CONF_GA_HUMIDITY_CURRENT, + CONF_GA_ON_OFF, + CONF_GA_OP_MODE_COMFORT, + CONF_GA_OP_MODE_ECO, + CONF_GA_OP_MODE_PROTECTION, + CONF_GA_OP_MODE_STANDBY, + CONF_GA_OPERATION_MODE, + CONF_GA_SETPOINT_SHIFT, + CONF_GA_TEMPERATURE_CURRENT, + CONF_GA_TEMPERATURE_TARGET, + CONF_GA_VALVE, + CONF_IGNORE_AUTO_MODE, + CONF_TARGET_TEMPERATURE, +) +from .storage.entity_store_schema import ConfClimateFanSpeedMode, ConfSetpointShiftMode +from .storage.util import ConfigExtractor ATTR_COMMAND_VALUE = "command_value" CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()} @@ -53,12 +94,30 @@ async def async_setup_entry( ) -> None: """Set up climate(s) for KNX platform.""" knx_module = hass.data[KNX_MODULE_KEY] - config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE] - - async_add_entities( - KNXClimate(knx_module, entity_config) for entity_config in config + platform = async_get_current_platform() + knx_module.config_store.add_platform( + platform=Platform.CLIMATE, + controller=KnxUiEntityPlatformController( + knx_module=knx_module, + entity_platform=platform, + entity_class=KnxUiClimate, + ), ) + entities: list[KnxYamlEntity | KnxUiEntity] = [] + if yaml_platform_config := knx_module.config_yaml.get(Platform.CLIMATE): + entities.extend( + KnxYamlClimate(knx_module, entity_config) + for entity_config in yaml_platform_config + ) + if ui_config := knx_module.config_store.data["entities"].get(Platform.CLIMATE): + entities.extend( + KnxUiClimate(knx_module, unique_id, config) + for unique_id, config in ui_config.items() + ) + if entities: + async_add_entities(entities) + def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: """Return a KNX Climate device to be used within XKNX.""" @@ -99,8 +158,8 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: group_address_heat_cool_state=config.get( ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS ), - operation_modes=config.get(ClimateSchema.CONF_OPERATION_MODES), - controller_modes=config.get(ClimateSchema.CONF_CONTROLLER_MODES), + operation_modes=config.get(ClimateConf.OPERATION_MODES), + controller_modes=config.get(ClimateConf.CONTROLLER_MODES), ) return XknxClimate( @@ -120,24 +179,24 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: ClimateSchema.CONF_SETPOINT_SHIFT_STATE_ADDRESS ), setpoint_shift_mode=config.get(ClimateSchema.CONF_SETPOINT_SHIFT_MODE), - setpoint_shift_max=config[ClimateSchema.CONF_SETPOINT_SHIFT_MAX], - setpoint_shift_min=config[ClimateSchema.CONF_SETPOINT_SHIFT_MIN], - temperature_step=config[ClimateSchema.CONF_TEMPERATURE_STEP], + setpoint_shift_max=config[ClimateConf.SETPOINT_SHIFT_MAX], + setpoint_shift_min=config[ClimateConf.SETPOINT_SHIFT_MIN], + temperature_step=config[ClimateConf.TEMPERATURE_STEP], group_address_on_off=config.get(ClimateSchema.CONF_ON_OFF_ADDRESS), group_address_on_off_state=config.get(ClimateSchema.CONF_ON_OFF_STATE_ADDRESS), - on_off_invert=config[ClimateSchema.CONF_ON_OFF_INVERT], + on_off_invert=config[ClimateConf.ON_OFF_INVERT], group_address_active_state=config.get(ClimateSchema.CONF_ACTIVE_STATE_ADDRESS), group_address_command_value_state=config.get( ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS ), - min_temp=config.get(ClimateSchema.CONF_MIN_TEMP), - max_temp=config.get(ClimateSchema.CONF_MAX_TEMP), + min_temp=config.get(ClimateConf.MIN_TEMP), + max_temp=config.get(ClimateConf.MAX_TEMP), mode=climate_mode, group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS), group_address_fan_speed_state=config.get( ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS ), - fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE], + fan_speed_mode=config[ClimateConf.FAN_SPEED_MODE], group_address_swing=config.get(ClimateSchema.CONF_SWING_ADDRESS), group_address_swing_state=config.get(ClimateSchema.CONF_SWING_STATE_ADDRESS), group_address_horizontal_swing=config.get( @@ -152,91 +211,195 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: ) -class KNXClimate(KnxYamlEntity, ClimateEntity): +def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClimate: + """Return a KNX Climate device to be used within XKNX from UI config.""" + sync_state = conf.get(CONF_SYNC_STATE) + op_modes: list[str | HVACOperationMode] = list(HVACOperationMode) + if conf.get(CONF_IGNORE_AUTO_MODE): + op_modes.remove(HVACOperationMode.AUTO) + + climate_mode = XknxClimateMode( + xknx, + name=f"{name} Mode", + group_address_operation_mode=conf.get_write(CONF_GA_OPERATION_MODE), + group_address_operation_mode_state=conf.get_state_and_passive( + CONF_GA_OPERATION_MODE + ), + group_address_operation_mode_comfort=conf.get_write_and_passive( + CONF_GA_OP_MODE_COMFORT + ), + group_address_operation_mode_economy=conf.get_write_and_passive( + CONF_GA_OP_MODE_ECO + ), + group_address_operation_mode_protection=conf.get_write_and_passive( + CONF_GA_OP_MODE_PROTECTION + ), + group_address_operation_mode_standby=conf.get_write_and_passive( + CONF_GA_OP_MODE_STANDBY + ), + group_address_controller_status=conf.get_write(CONF_GA_CONTROLLER_STATUS), + group_address_controller_status_state=conf.get_state_and_passive( + CONF_GA_CONTROLLER_STATUS + ), + group_address_controller_mode=conf.get_write(CONF_GA_CONTROLLER_MODE), + group_address_controller_mode_state=conf.get_state_and_passive( + CONF_GA_CONTROLLER_MODE + ), + group_address_heat_cool=conf.get_write(CONF_GA_HEAT_COOL), + group_address_heat_cool_state=conf.get_state_and_passive(CONF_GA_HEAT_COOL), + sync_state=sync_state, + operation_modes=op_modes, + ) + + sps_mode = None + if _sps_dpt := conf.get_dpt(CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT): + sps_mode = ( + SetpointShiftMode.DPT6010 + if _sps_dpt == ConfSetpointShiftMode.COUNT + else SetpointShiftMode.DPT9002 + ) + _fan_speed_dpt = conf.get_dpt(CONF_GA_FAN_SPEED) + fan_speed_mode = ( + FanSpeedMode.STEP + if _fan_speed_dpt == ConfClimateFanSpeedMode.STEPS + else FanSpeedMode.PERCENT + ) + + return XknxClimate( + xknx, + name=name, + group_address_temperature=conf.get_state_and_passive( + CONF_GA_TEMPERATURE_CURRENT + ), + group_address_target_temperature=conf.get_write( + CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET + ), + group_address_target_temperature_state=conf.get_state_and_passive( + CONF_TARGET_TEMPERATURE, CONF_GA_TEMPERATURE_TARGET + ), + group_address_setpoint_shift=conf.get_write( + CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT + ), + group_address_setpoint_shift_state=conf.get_state_and_passive( + CONF_TARGET_TEMPERATURE, CONF_GA_SETPOINT_SHIFT + ), + setpoint_shift_mode=sps_mode, + setpoint_shift_max=conf.get( + CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MAX, default=6 + ), + setpoint_shift_min=conf.get( + CONF_TARGET_TEMPERATURE, ClimateConf.SETPOINT_SHIFT_MIN, default=-6 + ), + temperature_step=conf.get( + CONF_TARGET_TEMPERATURE, ClimateConf.TEMPERATURE_STEP, default=0.1 + ), + group_address_on_off=conf.get_write(CONF_GA_ON_OFF), + group_address_on_off_state=conf.get_state_and_passive(CONF_GA_ON_OFF), + on_off_invert=conf.get(ClimateConf.ON_OFF_INVERT, default=False), + group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE), + group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE), + sync_state=sync_state, + min_temp=conf.get(ClimateConf.MIN_TEMP), + max_temp=conf.get(ClimateConf.MAX_TEMP), + mode=climate_mode, + group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED), + group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED), + fan_speed_mode=fan_speed_mode, + group_address_humidity_state=conf.get_state_and_passive( + CONF_GA_HUMIDITY_CURRENT + ), + group_address_swing=conf.get_write(CONF_GA_FAN_SWING), + group_address_swing_state=conf.get_state_and_passive(CONF_GA_FAN_SWING), + group_address_horizontal_swing=conf.get_write(CONF_GA_FAN_SWING_HORIZONTAL), + group_address_horizontal_swing_state=conf.get_state_and_passive( + CONF_GA_FAN_SWING_HORIZONTAL + ), + ) + + +class _KnxClimate(ClimateEntity, _KnxEntityBase): """Representation of a KNX climate device.""" _device: XknxClimate _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "knx_climate" - def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: - """Initialize of a KNX climate device.""" - super().__init__( - knx_module=knx_module, - device=_create_climate(knx_module.xknx, config), - ) - self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + default_hvac_mode: HVACMode + _last_hvac_mode: HVACMode + fan_zero_mode: str + _fan_modes_percentages: list[int] + + def _init_from_device_config( + self, + device: XknxClimate, + default_hvac_mode: HVACMode, + fan_max_step: int, + fan_zero_mode: str, + ) -> None: + """Set attributes that depend on device config.""" + self.default_hvac_mode = default_hvac_mode + # non-OFF HVAC mode to be used when turning on the device without on_off address + self._last_hvac_mode = self.default_hvac_mode + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - if self._device.supports_on_off: + if device.supports_on_off: self._attr_supported_features |= ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) if ( - self._device.mode is not None - and len(self._device.mode.controller_modes) >= 2 - and HVACControllerMode.OFF in self._device.mode.controller_modes + device.mode is not None + and len(device.mode.controller_modes) >= 2 + and HVACControllerMode.OFF in device.mode.controller_modes ): self._attr_supported_features |= ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) if ( - self._device.mode is not None - and self._device.mode.operation_modes # empty list when not writable + device.mode is not None + and device.mode.operation_modes # empty list when not writable ): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = [ - mode.name.lower() for mode in self._device.mode.operation_modes + mode.name.lower() for mode in device.mode.operation_modes ] - fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP] + self.fan_zero_mode = fan_zero_mode self._fan_modes_percentages = [ int(100 * i / fan_max_step) for i in range(fan_max_step + 1) ] - self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE] - - if self._device.fan_speed is not None and self._device.fan_speed.initialized: + if device.fan_speed is not None and device.fan_speed.initialized: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if fan_max_step == 3: self._attr_fan_modes = [ - self.fan_zero_mode, + fan_zero_mode, FAN_LOW, FAN_MEDIUM, FAN_HIGH, ] elif fan_max_step == 2: - self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH] + self._attr_fan_modes = [fan_zero_mode, FAN_LOW, FAN_HIGH] elif fan_max_step == 1: - self._attr_fan_modes = [self.fan_zero_mode, FAN_ON] - elif self._device.fan_speed_mode == FanSpeedMode.STEP: - self._attr_fan_modes = [self.fan_zero_mode] + [ + self._attr_fan_modes = [fan_zero_mode, FAN_ON] + elif device.fan_speed_mode == FanSpeedMode.STEP: + self._attr_fan_modes = [fan_zero_mode] + [ str(i) for i in range(1, fan_max_step + 1) ] else: - self._attr_fan_modes = [self.fan_zero_mode] + [ + self._attr_fan_modes = [fan_zero_mode] + [ f"{percentage}%" for percentage in self._fan_modes_percentages[1:] ] - if self._device.swing.initialized: + + if device.swing.initialized: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = [SWING_ON, SWING_OFF] - if self._device.horizontal_swing.initialized: + if device.horizontal_swing.initialized: self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF] - self._attr_target_temperature_step = self._device.temperature_step - self._attr_unique_id = ( - f"{self._device.temperature.group_address_state}_" - f"{self._device.target_temperature.group_address_state}_" - f"{self._device.target_temperature.group_address}_" - f"{self._device._setpoint_shift.group_address}" # noqa: SLF001 - ) - self.default_hvac_mode: HVACMode = config[ - ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE - ] - # non-OFF HVAC mode to be used when turning on the device without on_off address - self._last_hvac_mode: HVACMode = self.default_hvac_mode + self._attr_target_temperature_step = device.temperature_step @property def current_temperature(self) -> float | None: @@ -475,3 +638,63 @@ def after_update_callback(self, device: XknxDevice) -> None: if hvac_mode is not HVACMode.OFF: self._last_hvac_mode = hvac_mode super().after_update_callback(device) + + +class KnxYamlClimate(_KnxClimate, KnxYamlEntity): + """Representation of a KNX climate device configured from YAML.""" + + _device: XknxClimate + + def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: + """Initialize of a KNX climate device.""" + super().__init__( + knx_module=knx_module, + device=_create_climate(knx_module.xknx, config), + ) + default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE] + fan_max_step = config[ClimateConf.FAN_MAX_STEP] + fan_zero_mode: str = config[ClimateConf.FAN_ZERO_MODE] + self._init_from_device_config( + device=self._device, + default_hvac_mode=default_hvac_mode, + fan_max_step=fan_max_step, + fan_zero_mode=fan_zero_mode, + ) + + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = ( + f"{self._device.temperature.group_address_state}_" + f"{self._device.target_temperature.group_address_state}_" + f"{self._device.target_temperature.group_address}_" + f"{self._device._setpoint_shift.group_address}" # noqa: SLF001 + ) + + +class KnxUiClimate(_KnxClimate, KnxUiEntity): + """Representation of a KNX climate device configured from the UI.""" + + _device: XknxClimate + + def __init__( + self, knx_module: KNXModule, unique_id: str, config: ConfigType + ) -> None: + """Initialize of a KNX climate device.""" + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) + knx_conf = ConfigExtractor(config[DOMAIN]) + self._device = _create_climate_ui( + knx_module.xknx, knx_conf, config[CONF_ENTITY][CONF_NAME] + ) + + default_hvac_mode = HVACMode(knx_conf.get(ClimateConf.DEFAULT_CONTROLLER_MODE)) + fan_max_step = knx_conf.get(ClimateConf.FAN_MAX_STEP) + fan_zero_mode = knx_conf.get(ClimateConf.FAN_ZERO_MODE) + self._init_from_device_config( + device=self._device, + default_hvac_mode=default_hvac_mode, + fan_max_step=fan_max_step, + fan_zero_mode=fan_zero_mode, + ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index dbc02f08245c4..41dc9e10b1487 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -160,6 +160,7 @@ class FanZeroMode(StrEnum): SUPPORTED_PLATFORMS_UI: Final = { Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.SWITCH, @@ -193,3 +194,23 @@ class CoverConf: INVERT_UPDOWN: Final = "invert_updown" INVERT_POSITION: Final = "invert_position" INVERT_ANGLE: Final = "invert_angle" + + +class ClimateConf: + """Common config keys for climate.""" + + MIN_TEMP: Final = "min_temp" + MAX_TEMP: Final = "max_temp" + TEMPERATURE_STEP: Final = "temperature_step" + SETPOINT_SHIFT_MAX: Final = "setpoint_shift_max" + SETPOINT_SHIFT_MIN: Final = "setpoint_shift_min" + + ON_OFF_INVERT: Final = "on_off_invert" + + OPERATION_MODES: Final = "operation_modes" + CONTROLLER_MODES: Final = "controller_modes" + DEFAULT_CONTROLLER_MODE: Final = "default_controller_mode" + + FAN_MAX_STEP: Final = "fan_max_step" + FAN_SPEED_MODE: Final = "fan_speed_mode" + FAN_ZERO_MODE: Final = "fan_zero_mode" diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 794d875132723..faf53162dfe42 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -56,6 +56,7 @@ CONF_STATE_ADDRESS, CONF_SYNC_STATE, KNX_ADDRESS, + ClimateConf, ColorTempModes, CoverConf, FanZeroMode, @@ -306,10 +307,7 @@ class ClimateSchema(KNXPlatformSchema): CONF_SETPOINT_SHIFT_ADDRESS = "setpoint_shift_address" CONF_SETPOINT_SHIFT_STATE_ADDRESS = "setpoint_shift_state_address" CONF_SETPOINT_SHIFT_MODE = "setpoint_shift_mode" - CONF_SETPOINT_SHIFT_MAX = "setpoint_shift_max" - CONF_SETPOINT_SHIFT_MIN = "setpoint_shift_min" CONF_TEMPERATURE_ADDRESS = "temperature_address" - CONF_TEMPERATURE_STEP = "temperature_step" CONF_TARGET_TEMPERATURE_ADDRESS = "target_temperature_address" CONF_TARGET_TEMPERATURE_STATE_ADDRESS = "target_temperature_state_address" CONF_OPERATION_MODE_ADDRESS = "operation_mode_address" @@ -327,19 +325,10 @@ class ClimateSchema(KNXPlatformSchema): CONF_OPERATION_MODE_NIGHT_ADDRESS = "operation_mode_night_address" CONF_OPERATION_MODE_COMFORT_ADDRESS = "operation_mode_comfort_address" CONF_OPERATION_MODE_STANDBY_ADDRESS = "operation_mode_standby_address" - CONF_OPERATION_MODES = "operation_modes" - CONF_CONTROLLER_MODES = "controller_modes" - CONF_DEFAULT_CONTROLLER_MODE = "default_controller_mode" CONF_ON_OFF_ADDRESS = "on_off_address" CONF_ON_OFF_STATE_ADDRESS = "on_off_state_address" - CONF_ON_OFF_INVERT = "on_off_invert" - CONF_MIN_TEMP = "min_temp" - CONF_MAX_TEMP = "max_temp" CONF_FAN_SPEED_ADDRESS = "fan_speed_address" CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address" - CONF_FAN_MAX_STEP = "fan_max_step" - CONF_FAN_SPEED_MODE = "fan_speed_mode" - CONF_FAN_ZERO_MODE = "fan_zero_mode" CONF_HUMIDITY_STATE_ADDRESS = "humidity_state_address" CONF_SWING_ADDRESS = "swing_address" CONF_SWING_STATE_ADDRESS = "swing_state_address" @@ -359,13 +348,13 @@ class ClimateSchema(KNXPlatformSchema): { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional( - CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX + ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX ): vol.All(int, vol.Range(min=0, max=32)), vol.Optional( - CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN + ClimateConf.SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN ): vol.All(int, vol.Range(min=-32, max=0)), vol.Optional( - CONF_TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP + ClimateConf.TEMPERATURE_STEP, default=DEFAULT_TEMPERATURE_STEP ): vol.All(float, vol.Range(min=0, max=2)), vol.Required(CONF_TEMPERATURE_ADDRESS): ga_list_validator, vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): ga_list_validator, @@ -408,29 +397,29 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_ON_OFF_ADDRESS): ga_list_validator, vol.Optional(CONF_ON_OFF_STATE_ADDRESS): ga_list_validator, vol.Optional( - CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT + ClimateConf.ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT ): cv.boolean, - vol.Optional(CONF_OPERATION_MODES): vol.All( + vol.Optional(ClimateConf.OPERATION_MODES): vol.All( cv.ensure_list, [backwards_compatible_xknx_climate_enum_member(HVACOperationMode)], ), - vol.Optional(CONF_CONTROLLER_MODES): vol.All( + vol.Optional(ClimateConf.CONTROLLER_MODES): vol.All( cv.ensure_list, [backwards_compatible_xknx_climate_enum_member(HVACControllerMode)], ), vol.Optional( - CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT + ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT ): vol.Coerce(HVACMode), - vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), - vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), + vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float), + vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator, vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator, - vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte, + vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte, vol.Optional( - CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE + ClimateConf.FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE ): vol.All(vol.Upper, cv.enum(FanSpeedMode)), - vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( + vol.Optional(ClimateConf.FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( FanZeroMode ), vol.Optional(CONF_SWING_ADDRESS): ga_list_validator, diff --git a/homeassistant/components/knx/storage/const.py b/homeassistant/components/knx/storage/const.py index 78cd38c9d0050..5b092e00d2e62 100644 --- a/homeassistant/components/knx/storage/const.py +++ b/homeassistant/components/knx/storage/const.py @@ -14,6 +14,28 @@ CONF_GA_SENSOR: Final = "ga_sensor" CONF_GA_SWITCH: Final = "ga_switch" +# Climate +CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" +CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" +CONF_TARGET_TEMPERATURE: Final = "target_temperature" +CONF_GA_TEMPERATURE_TARGET: Final = "ga_temperature_target" +CONF_GA_SETPOINT_SHIFT: Final = "ga_setpoint_shift" +CONF_GA_ACTIVE: Final = "ga_active" +CONF_GA_VALVE: Final = "ga_valve" +CONF_GA_OPERATION_MODE: Final = "ga_operation_mode" +CONF_IGNORE_AUTO_MODE: Final = "ignore_auto_mode" +CONF_GA_OP_MODE_COMFORT: Final = "ga_operation_mode_comfort" +CONF_GA_OP_MODE_ECO: Final = "ga_operation_mode_economy" +CONF_GA_OP_MODE_STANDBY: Final = "ga_operation_mode_standby" +CONF_GA_OP_MODE_PROTECTION: Final = "ga_operation_mode_protection" +CONF_GA_HEAT_COOL: Final = "ga_heat_cool" +CONF_GA_ON_OFF: Final = "ga_on_off" +CONF_GA_CONTROLLER_MODE: Final = "ga_controller_mode" +CONF_GA_CONTROLLER_STATUS: Final = "ga_controller_status" +CONF_GA_FAN_SPEED: Final = "ga_fan_speed" +CONF_GA_FAN_SWING: Final = "ga_fan_swing" +CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal" + # Cover CONF_GA_UP_DOWN: Final = "ga_up_down" CONF_GA_STOP: Final = "ga_stop" diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index a764ea92e446b..250ab25275b23 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -4,6 +4,7 @@ import voluptuous as vol +from homeassistant.components.climate import HVACMode from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_ENTITY_ID, @@ -24,8 +25,10 @@ CONF_SYNC_STATE, DOMAIN, SUPPORTED_PLATFORMS_UI, + ClimateConf, ColorTempModes, CoverConf, + FanZeroMode, ) from .const import ( CONF_COLOR, @@ -34,27 +37,47 @@ CONF_DATA, CONF_DEVICE_INFO, CONF_ENTITY, + CONF_GA_ACTIVE, CONF_GA_ANGLE, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, CONF_GA_BRIGHTNESS, CONF_GA_COLOR, CONF_GA_COLOR_TEMP, + CONF_GA_CONTROLLER_MODE, + CONF_GA_CONTROLLER_STATUS, + CONF_GA_FAN_SPEED, + CONF_GA_FAN_SWING, + CONF_GA_FAN_SWING_HORIZONTAL, CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, + CONF_GA_HEAT_COOL, CONF_GA_HUE, + CONF_GA_HUMIDITY_CURRENT, + CONF_GA_ON_OFF, + CONF_GA_OP_MODE_COMFORT, + CONF_GA_OP_MODE_ECO, + CONF_GA_OP_MODE_PROTECTION, + CONF_GA_OP_MODE_STANDBY, + CONF_GA_OPERATION_MODE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, CONF_GA_SENSOR, + CONF_GA_SETPOINT_SHIFT, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_SWITCH, + CONF_GA_TEMPERATURE_CURRENT, + CONF_GA_TEMPERATURE_TARGET, CONF_GA_UP_DOWN, + CONF_GA_VALVE, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, + CONF_IGNORE_AUTO_MODE, + CONF_TARGET_TEMPERATURE, ) from .knx_selector import ( AllSerializeFirst, @@ -109,7 +132,9 @@ min=0, max=600, step=0.1, unit_of_measurement="s" ) ), - vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(), + vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector( + allow_false=True + ), }, ) @@ -311,8 +336,151 @@ class LightColorMode(StrEnum): }, ) + +@unique +class ConfSetpointShiftMode(StrEnum): + """Enum for setpoint shift mode.""" + + COUNT = "6.010" + FLOAT = "9.002" + + +@unique +class ConfClimateFanSpeedMode(StrEnum): + """Enum for climate fan speed mode.""" + + PERCENTAGE = "5.001" + STEPS = "5.010" + + +CLIMATE_KNX_SCHEMA = vol.Schema( + { + vol.Required(CONF_GA_TEMPERATURE_CURRENT): GASelector( + write=False, state_required=True, valid_dpt="9.001" + ), + vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector( + write=False, valid_dpt="9.002" + ), + vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect( + GroupSelectOption( + translation_key="group_direct_temp", + schema={ + vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector( + write_required=True, valid_dpt="9.001" + ), + vol.Required( + ClimateConf.MIN_TEMP, default=7 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=-20, max=80, step=1, unit_of_measurement="°C" + ) + ), + vol.Required( + ClimateConf.MAX_TEMP, default=28 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=100, step=1, unit_of_measurement="°C" + ) + ), + vol.Required( + ClimateConf.TEMPERATURE_STEP, default=0.1 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, max=2, step=0.1, unit_of_measurement="K" + ), + ), + }, + ), + GroupSelectOption( + translation_key="group_setpoint_shift", + schema={ + vol.Required(CONF_GA_TEMPERATURE_TARGET): GASelector( + write=False, state_required=True, valid_dpt="9.001" + ), + vol.Required(CONF_GA_SETPOINT_SHIFT): GASelector( + write_required=True, + state_required=True, + dpt=ConfSetpointShiftMode, + ), + vol.Required( + ClimateConf.SETPOINT_SHIFT_MIN, default=-6 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=-32, max=0, step=1, unit_of_measurement="K" + ) + ), + vol.Required( + ClimateConf.SETPOINT_SHIFT_MAX, default=6 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=32, step=1, unit_of_measurement="K" + ) + ), + vol.Required( + ClimateConf.TEMPERATURE_STEP, default=0.1 + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0.1, max=2, step=0.1, unit_of_measurement="K" + ), + ), + }, + ), + collapsible=False, + ), + "section_activity": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_ACTIVE): GASelector(write=False, valid_dpt="1"), + vol.Optional(CONF_GA_VALVE): GASelector(write=False, valid_dpt="5.001"), + "section_operation_mode": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_OPERATION_MODE): GASelector(valid_dpt="20.102"), + vol.Optional(CONF_IGNORE_AUTO_MODE): selector.BooleanSelector(), + "section_operation_mode_individual": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_OP_MODE_COMFORT): GASelector(state=False, valid_dpt="1"), + vol.Optional(CONF_GA_OP_MODE_ECO): GASelector(state=False, valid_dpt="1"), + vol.Optional(CONF_GA_OP_MODE_STANDBY): GASelector(state=False, valid_dpt="1"), + vol.Optional(CONF_GA_OP_MODE_PROTECTION): GASelector( + state=False, valid_dpt="1" + ), + "section_heat_cool": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_HEAT_COOL): GASelector(valid_dpt="1.100"), + "section_on_off": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_ON_OFF): GASelector(valid_dpt="1"), + vol.Optional(ClimateConf.ON_OFF_INVERT): selector.BooleanSelector(), + "section_controller_mode": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_CONTROLLER_MODE): GASelector(valid_dpt="20.105"), + vol.Optional(CONF_GA_CONTROLLER_STATUS): GASelector(write=False), + vol.Required( + ClimateConf.DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(HVACMode), + translation_key="component.climate.selector.hvac_mode", + ) + ), + "section_fan": KNXSectionFlat(collapsible=True), + vol.Optional(CONF_GA_FAN_SPEED): GASelector(dpt=ConfClimateFanSpeedMode), + vol.Required(ClimateConf.FAN_MAX_STEP, default=3): AllSerializeFirst( + selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ), + vol.Coerce(int), + ), + vol.Required( + ClimateConf.FAN_ZERO_MODE, default=FanZeroMode.OFF + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(FanZeroMode), + translation_key="component.knx.config_panel.entities.create.climate.knx.fan_zero_mode", + ) + ), + vol.Optional(CONF_GA_FAN_SWING): GASelector(valid_dpt="1"), + vol.Optional(CONF_GA_FAN_SWING_HORIZONTAL): GASelector(valid_dpt="1"), + vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), + }, +) + KNX_SCHEMA_FOR_PLATFORM = { Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA, + Platform.CLIMATE: CLIMATE_KNX_SCHEMA, Platform.COVER: COVER_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA, diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 48c3ce12027ac..964570e6ba0ff 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -412,6 +412,164 @@ } } }, + "climate": { + "description": "The KNX climate platform is used as an interface to heating actuators, HVAC gateways, etc.", + "knx": { + "ga_temperature_current": { + "label": "Current temperature" + }, + "ga_humidity_current": { + "label": "Current humidity" + }, + "target_temperature": { + "title": "Target temperature", + "description": "Set the target temperature.", + "options": { + "group_direct_temp": { + "label": "Absolute setpoint", + "description": "Set the target temperature by an absolute value." + }, + "group_setpoint_shift": { + "label": "Setpoint shift", + "description": "Shift the target temperature from a base setpoint." + } + }, + "ga_temperature_target": { + "label": "Target temperature", + "description": "Current absolute target temperature." + }, + "min_temp": { + "label": "Minimum temperature", + "description": "Minimum temperature that can be set." + }, + "max_temp": { + "label": "Maximum temperature", + "description": "Maximum temperature that can be set." + }, + "temperature_step": { + "label": "Temperature step", + "description": "Smallest step size to change the temperature. For setpoint shift configureations this sets the scale factor of the shift value." + }, + "ga_setpoint_shift": { + "label": "Setpoint shift", + "description": "Target temperature deviation from a base setpoint." + }, + "setpoint_shift_min": { + "label": "Minimum setpoint shift", + "description": "Lowest allowed deviation from the base setpoint." + }, + "setpoint_shift_max": { + "label": "Maximum setpoint shift", + "description": "Highest allowed deviation from the base setpoint." + } + }, + "section_activity": { + "title": "Activity", + "description": "Determine if the device is active or idle." + }, + "ga_active": { + "label": "Active", + "description": "Binary value indicating if the device is active or idle. If configured, this takes precedence over valve position." + }, + "ga_valve": { + "label": "Valve position", + "description": "Current control value / valve position in percent. `0` sets the climate entity to idle." + }, + "section_operation_mode": { + "title": "Operation mode", + "description": "Set the preset mode of the device." + }, + "ga_operation_mode": { + "label": "Operation mode", + "description": "Current operation mode." + }, + "ignore_auto_mode": { + "label": "Ignore auto mode", + "description": "Enable when your controller doesn't support `auto` mode. It will be ignored by the integration then." + }, + "section_operation_mode_individual": { + "title": "Individual operation modes", + "description": "Set the preset mode of the device using individual group addresses." + }, + "ga_operation_mode_comfort": { + "label": "Comfort mode" + }, + "ga_operation_mode_economy": { + "label": "Economy mode" + }, + "ga_operation_mode_standby": { + "label": "Standby mode" + }, + "ga_operation_mode_protection": { + "label": "Building protection mode" + }, + "section_heat_cool": { + "title": "Heating/Cooling", + "description": "Set whether the device is in heating or cooling mode." + }, + "ga_heat_cool": { + "label": "Heating/Cooling" + }, + "section_on_off": { + "title": "On/Off", + "description": "Turn the device on or off." + }, + "ga_on_off": { + "label": "On/Off" + }, + "on_off_invert": { + "label": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::label%]", + "description": "[%key:component::knx::config_panel::entities::create::binary_sensor::knx::invert::description%]" + }, + "section_controller_mode": { + "title": "Controller mode", + "description": "Set the mode of the climate device." + }, + "ga_controller_mode": { + "label": "Controller mode" + }, + "ga_controller_status": { + "label": "Controller status", + "description": "HVAC controller mode and preset status. Eberle Status octet (KNX AN 097/07 rev 3) non-standardized DPT." + }, + "default_controller_mode": { + "label": "Default mode", + "description": "Climate mode to be set on initialization." + }, + "section_fan": { + "title": "Fan", + "description": "Configuration for fan control (AC units)." + }, + "ga_fan_speed": { + "label": "Fan speed", + "description": "Set the current fan speed.", + "options": { + "5_001": "Percent", + "5_010": "Steps" + } + }, + "fan_max_step": { + "label": "Fan steps", + "description": "The maximum amount of steps for the fan." + }, + "fan_zero_mode": { + "label": "Zero fan speed mode", + "description": "Set the mode that represents fan speed `0`.", + "options": { + "off": "[%key:common::state::off%]", + "auto": "[%key:common::state::auto%]" + } + }, + "ga_fan_swing": { + "label": "Fan swing", + "description": "Toggle (vertical) fan swing mode. Use this if only one direction is supported." + }, + "ga_fan_swing_horizontal": { + "label": "Fan horizontal swing", + "description": "Toggle horizontal fan swing mode." + } + } + }, "cover": { "description": "The KNX cover platform is used as an interface to shutter actuators.", "knx": { diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 35977da9924aa..7c678c28464ec 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.09.26"], + "requirements": ["yt-dlp[default]==2025.10.22"], "single_config_entry": true } diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index d2856b50c8b68..15387a58d3308 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.8"] + "requirements": ["pyportainer==1.0.9"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e7b08f165b280..8b8fcd0ed10ad 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ atomicwrites-homeassistant==1.4.1 attrs==25.4.0 audioop-lts==0.2.1 av==13.1.0 -awesomeversion==25.5.0 +awesomeversion==25.8.0 bcrypt==5.0.0 bleak-retry-connector==4.4.3 bleak==1.0.1 diff --git a/pyproject.toml b/pyproject.toml index 5be848614e571..6385c62faf197 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "attrs==25.4.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1", - "awesomeversion==25.5.0", + "awesomeversion==25.8.0", "bcrypt==5.0.0", "certifi>=2021.5.30", "ciso8601==2.3.3", diff --git a/requirements.txt b/requirements.txt index 1f36766b5f215..f027fa954cd02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ async-interrupt==1.2.2 attrs==25.4.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1 -awesomeversion==25.5.0 +awesomeversion==25.8.0 bcrypt==5.0.0 certifi>=2021.5.30 ciso8601==2.3.3 diff --git a/requirements_all.txt b/requirements_all.txt index a365a8727dd87..0d4e797f58e46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2308,7 +2308,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.8 +pyportainer==1.0.9 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -3244,7 +3244,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.26 +yt-dlp[default]==2025.10.22 # homeassistant.components.zabbix zabbix-utils==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c87033b2883b..b95dda9e37c96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.8 +pyportainer==1.0.9 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2694,7 +2694,7 @@ youless-api==2.2.0 youtubeaio==2.0.0 # homeassistant.components.media_extractor -yt-dlp[default]==2025.09.26 +yt-dlp[default]==2025.10.22 # homeassistant.components.zamg zamg==0.3.6 diff --git a/tests/components/growatt_server/conftest.py b/tests/components/growatt_server/conftest.py index 66e0dd8103672..ddee63e873d62 100644 --- a/tests/components/growatt_server/conftest.py +++ b/tests/components/growatt_server/conftest.py @@ -30,12 +30,12 @@ def mock_growatt_v1_api(): - plant_energy_overview: Called by total coordinator during first refresh Methods mocked for MIN device coordinator refresh: - - min_detail: Provides device state (e.g., acChargeEnable for switches) + - min_detail: Provides device state (e.g., acChargeEnable, chargePowerCommand) - min_settings: Provides settings (e.g. TOU periods) - - min_energy: Provides energy data (empty for switch tests, sensors need real data) + - min_energy: Provides energy data (empty for switch/number tests, sensors need real data) - Methods mocked for switch operations: - - min_write_parameter: Called by switch entities to change settings + Methods mocked for switch and number operations: + - min_write_parameter: Called by switch/number entities to change settings """ with patch("growattServer.OpenApiV1", autospec=True) as mock_v1_api_class: mock_v1_api = mock_v1_api_class.return_value @@ -54,11 +54,15 @@ def mock_growatt_v1_api(): mock_v1_api.min_detail.return_value = { "deviceSn": "MIN123456", "acChargeEnable": 1, # AC charge enabled - read by switch entity + "chargePowerCommand": 50, # 50% charge power - read by number entity + "wchargeSOCLowLimit": 10, # 10% charge stop SOC - read by number entity + "disChargePowerCommand": 80, # 80% discharge power - read by number entity + "wdisChargeSOCLowLimit": 20, # 20% discharge stop SOC - read by number entity } # Called by MIN device coordinator during refresh mock_v1_api.min_settings.return_value = { - # Forced charge time segments (not used by switch, but coordinator fetches it) + # Forced charge time segments (not used by switch/number, but coordinator fetches it) "forcedTimeStart1": "06:00", "forcedTimeStop1": "08:00", "forcedChargeBatMode1": 1, @@ -91,7 +95,7 @@ def mock_growatt_v1_api(): "current_power": 2500, } - # Called by switch entities during turn_on/turn_off + # Called by switch/number entities during turn_on/turn_off/set_value mock_v1_api.min_write_parameter.return_value = None yield mock_v1_api diff --git a/tests/components/growatt_server/snapshots/test_number.ambr b/tests/components/growatt_server/snapshots/test_number.ambr new file mode 100644 index 0000000000000..428756074b4a7 --- /dev/null +++ b/tests/components/growatt_server/snapshots/test_number.ambr @@ -0,0 +1,264 @@ +# serializer version: 1 +# name: test_number_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'growatt_server', + 'MIN123456', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Growatt', + 'model': None, + 'model_id': None, + 'name': 'MIN123456', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_number_entities[number.min123456_battery_charge_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_charge_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery charge power limit', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_power_limit', + 'unique_id': 'MIN123456_battery_charge_power_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_charge_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery charge power limit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_charge_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_number_entities[number.min123456_battery_charge_soc_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_charge_soc_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery charge SOC limit', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_charge_soc_limit', + 'unique_id': 'MIN123456_battery_charge_soc_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_charge_soc_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery charge SOC limit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_charge_soc_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_power_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_discharge_power_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery discharge power limit', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_discharge_power_limit', + 'unique_id': 'MIN123456_battery_discharge_power_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_power_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery discharge power limit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_discharge_power_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery discharge SOC limit', + 'platform': 'growatt_server', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_discharge_soc_limit', + 'unique_id': 'MIN123456_battery_discharge_soc_limit', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities[number.min123456_battery_discharge_soc_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MIN123456 Battery discharge SOC limit', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.min123456_battery_discharge_soc_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/growatt_server/test_number.py b/tests/components/growatt_server/test_number.py new file mode 100644 index 0000000000000..ffc4bc45561bb --- /dev/null +++ b/tests/components/growatt_server/test_number.py @@ -0,0 +1,339 @@ +"""Tests for the Growatt Server number platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from growattServer import GrowattV1ApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.growatt_server.coordinator import SCAN_INTERVAL +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +DOMAIN = "growatt_server" + + +@pytest.fixture(autouse=True) +async def number_only() -> AsyncGenerator[None]: + """Enable only the number platform.""" + with patch( + "homeassistant.components.growatt_server.PLATFORMS", + [Platform.NUMBER], + ): + yield + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that number entities are created for MIN devices.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_number_value_success( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test setting a number entity value successfully.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": "number.min123456_battery_charge_power_limit", + ATTR_VALUE: 75, + }, + blocking=True, + ) + + # Verify API was called with correct parameters + mock_growatt_v1_api.min_write_parameter.assert_called_once_with( + "MIN123456", "charge_power", 75 + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_set_number_value_api_error( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test handling API error when setting number value.""" + # Mock API to raise error + mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error") + + with pytest.raises(HomeAssistantError, match="Error while setting parameter"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + "entity_id": "number.min123456_battery_charge_power_limit", + ATTR_VALUE: 75, + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_entity_attributes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test number entity attributes.""" + # Check entity registry attributes + entity_entry = entity_registry.async_get( + "number.min123456_battery_charge_power_limit" + ) + assert entity_entry is not None + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.unique_id == "MIN123456_battery_charge_power_limit" + + # Check state attributes + state = hass.states.get("number.min123456_battery_charge_power_limit") + assert state is not None + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 100 + assert state.attributes["step"] == 1 + assert state.attributes["unit_of_measurement"] == "%" + assert state.attributes["friendly_name"] == "MIN123456 Battery charge power limit" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_device_registry( + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that number entities are associated with the correct device.""" + # Get the device from device registry + device = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")}) + assert device is not None + assert device == snapshot + + # Verify number entity is associated with the device + entity_entry = entity_registry.async_get( + "number.min123456_battery_charge_power_limit" + ) + assert entity_entry is not None + assert entity_entry.device_id == device.id + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_all_number_entities_service_calls( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test service calls work for all number entities.""" + # Test all four number entities + test_cases = [ + ("number.min123456_battery_charge_power_limit", "charge_power", 75), + ("number.min123456_battery_charge_soc_limit", "charge_stop_soc", 85), + ("number.min123456_battery_discharge_power_limit", "discharge_power", 90), + ("number.min123456_battery_discharge_soc_limit", "discharge_stop_soc", 25), + ] + + for entity_id, expected_write_key, test_value in test_cases: + mock_growatt_v1_api.reset_mock() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": entity_id, ATTR_VALUE: test_value}, + blocking=True, + ) + + # Verify API was called with correct parameters + mock_growatt_v1_api.min_write_parameter.assert_called_once_with( + "MIN123456", expected_write_key, test_value + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_boundary_values( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test setting boundary values for number entities.""" + # Test minimum value + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "number.min123456_battery_charge_power_limit", ATTR_VALUE: 0}, + blocking=True, + ) + + mock_growatt_v1_api.min_write_parameter.assert_called_with( + "MIN123456", "charge_power", 0 + ) + + # Test maximum value + mock_growatt_v1_api.reset_mock() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "number.min123456_battery_charge_power_limit", ATTR_VALUE: 100}, + blocking=True, + ) + + mock_growatt_v1_api.min_write_parameter.assert_called_with( + "MIN123456", "charge_power", 100 + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_missing_data( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, +) -> None: + """Test number entity when coordinator data is missing.""" + # Set up API with missing data for one entity + mock_growatt_v1_api.min_detail.return_value = { + "deviceSn": "MIN123456", + # Missing 'chargePowerCommand' key to test None case + "wchargeSOCLowLimit": 10, + "disChargePowerCommand": 80, + "wdisChargeSOCLowLimit": 20, + } + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Entity should exist but have unknown state due to missing data + state = hass.states.get("number.min123456_battery_charge_power_limit") + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_number_entities_for_non_min_devices( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that number entities are not created for non-MIN devices.""" + # Mock a different device type (not MIN) - type 7 is MIN, type 8 is non-MIN + mock_growatt_v1_api.device_list.return_value = { + "devices": [ + { + "device_sn": "TLX123456", + "type": 8, # Non-MIN device type (MIN is type 7) + } + ] + } + + # Mock TLX API response to prevent coordinator errors + mock_growatt_v1_api.tlx_detail.return_value = {"data": {}} + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have no number entities for TLX devices + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + number_entities = [entry for entry in entity_entries if entry.domain == "number"] + assert len(number_entities) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_number_entities_for_classic_api( + hass: HomeAssistant, + mock_growatt_classic_api, + mock_config_entry_classic: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that number entities are not created for Classic API.""" + # Mock device list to return no devices + mock_growatt_classic_api.device_list.return_value = [] + + mock_config_entry_classic.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry_classic.entry_id) + await hass.async_block_till_done() + + # Should have no number entities for classic API (no devices) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry_classic.entry_id + ) + number_entities = [entry for entry in entity_entries if entry.domain == "number"] + assert len(number_entities) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_float_to_int_conversion( + hass: HomeAssistant, + mock_growatt_v1_api, +) -> None: + """Test that float values are converted to integers when setting.""" + # Test setting a float value gets converted to int + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "number.min123456_battery_charge_power_limit", ATTR_VALUE: 75.7}, + blocking=True, + ) + + # Verify API was called with integer value + mock_growatt_v1_api.min_write_parameter.assert_called_once_with( + "MIN123456", + "charge_power", + 75, # Should be converted to int + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_coordinator_data_update( + hass: HomeAssistant, + mock_growatt_v1_api, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that number state updates when coordinator data changes.""" + # Set up integration + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Initial state should be 50 (based on mock data) + state = hass.states.get("number.min123456_battery_charge_power_limit") + assert state is not None + assert float(state.state) == 50.0 + + # Change mock data and trigger coordinator update + mock_growatt_v1_api.min_detail.return_value = { + "deviceSn": "MIN123456", + "chargePowerCommand": 75, # Changed value + "wchargeSOCLowLimit": 10, + "disChargePowerCommand": 80, + "wdisChargeSOCLowLimit": 20, + } + + # Advance time to trigger coordinator refresh + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # State should now be 75 + state = hass.states.get("number.min123456_battery_charge_power_limit") + assert state is not None + assert float(state.state) == 75.0 diff --git a/tests/components/knx/fixtures/config_store_climate.json b/tests/components/knx/fixtures/config_store_climate.json new file mode 100644 index 0000000000000..3ba3f5e6a7aaf --- /dev/null +++ b/tests/components/knx/fixtures/config_store_climate.json @@ -0,0 +1,136 @@ +{ + "version": 2, + "minor_version": 2, + "key": "knx/config_store.json", + "data": { + "entities": { + "climate": { + "knx_es_01K76NGZRMJA74CBRQF9KXNPE8": { + "entity": { + "name": "direct_indi-op_heat-cool", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_temperature_current": { + "state": "0/0/1", + "passive": [] + }, + "default_controller_mode": "cool", + "fan_max_step": 4, + "fan_zero_mode": "auto", + "sync_state": true, + "target_temperature": { + "ga_temperature_target": { + "write": "0/1/1", + "state": "0/1/2", + "passive": [] + }, + "min_temp": 10.0, + "max_temp": 24.0, + "temperature_step": 0.1 + }, + "ga_operation_mode_comfort": { + "write": "0/2/1", + "passive": [] + }, + "ga_operation_mode_protection": { + "write": "0/2/4", + "passive": [] + }, + "ga_operation_mode_economy": { + "write": "0/2/2", + "passive": [] + }, + "ga_operation_mode_standby": { + "write": "0/2/3", + "passive": [] + }, + "ga_heat_cool": { + "write": "0/3/1", + "state": "0/3/2", + "passive": [] + }, + "ga_on_off": { + "write": "0/4/1", + "state": "0/4/2", + "passive": [] + }, + "on_off_invert": true + } + }, + "knx_es_01K76NGZRMJA74CBRQF9KXNPE9": { + "entity": { + "name": "sps_op-mode_contr-mode", + "device_info": null, + "entity_category": null + }, + "knx": { + "ga_temperature_current": { + "state": "1/0/1", + "passive": [] + }, + "ga_humidity_current": { + "state": "1/0/2", + "passive": [] + }, + "target_temperature": { + "ga_temperature_target": { + "state": "1/1/0", + "passive": [] + }, + "ga_setpoint_shift": { + "write": "1/1/1", + "dpt": "9.002", + "state": "1/1/2", + "passive": [] + }, + "setpoint_shift_min": -8.0, + "setpoint_shift_max": 8.0, + "temperature_step": 0.5 + }, + "ga_active": { + "state": "1/1/3", + "passive": [] + }, + "ga_valve": { + "state": "1/1/4", + "passive": [] + }, + "ignore_auto_mode": true, + "ga_operation_mode": { + "write": "1/2/1", + "state": "1/2/2", + "passive": [] + }, + "ga_controller_mode": { + "write": "1/3/1", + "state": "1/3/2", + "passive": [] + }, + "default_controller_mode": "heat", + "ga_fan_speed": { + "write": "1/4/1", + "dpt": "5.001", + "state": "1/4/2", + "passive": [] + }, + "fan_max_step": 4, + "fan_zero_mode": "auto", + "ga_fan_swing": { + "write": "1/4/3", + "state": "1/4/4", + "passive": [] + }, + "ga_fan_swing_horizontal": { + "write": "1/4/5", + "state": "1/4/6", + "passive": [] + }, + "sync_state": "init" + } + } + } + } + } +} diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 69a329151b91b..fef67034ccc06 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -78,7 +78,7 @@ 'type': 'ha_selector', }), dict({ - 'allow_false': False, + 'allow_false': True, 'default': True, 'name': 'sync_state', 'required': True, @@ -89,6 +89,643 @@ 'type': 'result', }) # --- +# name: test_knx_get_schema[climate] + dict({ + 'id': 1, + 'result': list([ + dict({ + 'name': 'ga_temperature_current', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': True, + }), + 'validDPTs': list([ + dict({ + 'main': 9, + 'sub': 1, + }), + ]), + 'write': False, + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_humidity_current', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 9, + 'sub': 2, + }), + ]), + 'write': False, + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': False, + 'name': 'target_temperature', + 'required': True, + 'schema': list([ + dict({ + 'schema': list([ + dict({ + 'name': 'ga_temperature_target', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 9, + 'sub': 1, + }), + ]), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': 7, + 'name': 'min_temp', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 80.0, + 'min': -20.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '°C', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 28, + 'name': 'max_temp', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '°C', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 0.1, + 'name': 'temperature_step', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 2.0, + 'min': 0.1, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + ]), + 'translation_key': 'group_direct_temp', + 'type': 'knx_group_select_option', + }), + dict({ + 'schema': list([ + dict({ + 'name': 'ga_temperature_target', + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': True, + }), + 'validDPTs': list([ + dict({ + 'main': 9, + 'sub': 1, + }), + ]), + 'write': False, + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_setpoint_shift', + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 6, + 'sub': 10, + }), + 'translation_key': '6_010', + 'value': '6.010', + }), + dict({ + 'dpt': dict({ + 'main': 9, + 'sub': 2, + }), + 'translation_key': '9_002', + 'value': '9.002', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': True, + }), + 'write': dict({ + 'required': True, + }), + }), + 'required': True, + 'type': 'knx_group_address', + }), + dict({ + 'default': -6, + 'name': 'setpoint_shift_min', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 0.0, + 'min': -32.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 6, + 'name': 'setpoint_shift_max', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 32.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 0.1, + 'name': 'temperature_step', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 2.0, + 'min': 0.1, + 'mode': 'slider', + 'step': 0.1, + 'unit_of_measurement': 'K', + }), + }), + 'type': 'ha_selector', + }), + ]), + 'translation_key': 'group_setpoint_shift', + 'type': 'knx_group_select_option', + }), + ]), + 'type': 'knx_group_select', + }), + dict({ + 'collapsible': True, + 'name': 'section_activity', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_active', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': False, + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_valve', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), + 'write': False, + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_operation_mode', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_operation_mode', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 20, + 'sub': 102, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ignore_auto_mode', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_operation_mode_individual', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_operation_mode_comfort', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_operation_mode_economy', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_operation_mode_standby', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_operation_mode_protection', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_heat_cool', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_heat_cool', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': 100, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'collapsible': True, + 'name': 'section_on_off', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_on_off', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'on_off_invert', + 'optional': True, + 'required': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_controller_mode', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_controller_mode', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 20, + 'sub': 105, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_controller_status', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': False, + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'default': 'heat', + 'name': 'default_controller_mode', + 'required': True, + 'selector': dict({ + 'select': dict({ + 'custom_value': False, + 'multiple': False, + 'options': list([ + 'off', + 'heat', + 'cool', + 'heat_cool', + 'auto', + 'dry', + 'fan_only', + ]), + 'sort': False, + 'translation_key': 'component.climate.selector.hvac_mode', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'collapsible': True, + 'name': 'section_fan', + 'required': False, + 'type': 'knx_section_flat', + }), + dict({ + 'name': 'ga_fan_speed', + 'optional': True, + 'options': dict({ + 'dptSelect': list([ + dict({ + 'dpt': dict({ + 'main': 5, + 'sub': 1, + }), + 'translation_key': '5_001', + 'value': '5.001', + }), + dict({ + 'dpt': dict({ + 'main': 5, + 'sub': 10, + }), + 'translation_key': '5_010', + 'value': '5.010', + }), + ]), + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'default': 3, + 'name': 'fan_max_step', + 'required': True, + 'selector': dict({ + 'number': dict({ + 'max': 100.0, + 'min': 1.0, + 'mode': 'slider', + 'step': 1.0, + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'default': 'off', + 'name': 'fan_zero_mode', + 'required': True, + 'selector': dict({ + 'select': dict({ + 'custom_value': False, + 'multiple': False, + 'options': list([ + 'off', + 'auto', + ]), + 'sort': False, + 'translation_key': 'component.knx.config_panel.entities.create.climate.knx.fan_zero_mode', + }), + }), + 'type': 'ha_selector', + }), + dict({ + 'name': 'ga_fan_swing', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'name': 'ga_fan_swing_horizontal', + 'optional': True, + 'options': dict({ + 'passive': True, + 'state': dict({ + 'required': False, + }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), + 'write': dict({ + 'required': False, + }), + }), + 'required': False, + 'type': 'knx_group_address', + }), + dict({ + 'allow_false': False, + 'default': True, + 'name': 'sync_state', + 'optional': True, + 'required': False, + 'type': 'knx_sync_state', + }), + ]), + 'success': True, + 'type': 'result', + }) +# --- # name: test_knx_get_schema[cover] dict({ 'id': 1, diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index b5a90428ef2fd..a825f8c402a4d 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -3,16 +3,19 @@ import pytest from homeassistant.components.climate import HVACMode +from homeassistant.components.knx.const import ClimateConf from homeassistant.components.knx.schema import ClimateSchema -from homeassistant.const import CONF_NAME, STATE_IDLE +from homeassistant.const import CONF_NAME, STATE_IDLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from . import KnxEntityGenerator from .conftest import KNXTestKit from tests.common import async_capture_events +RAW_FLOAT_MINUS_1_0 = (0x87, 0x9C) RAW_FLOAT_20_0 = (0x07, 0xD0) RAW_FLOAT_21_0 = (0x0C, 0x1A) RAW_FLOAT_22_0 = (0x0C, 0x4C) @@ -158,7 +161,7 @@ async def test_climate_hvac_mode( ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: controller_mode_ga, ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_OPERATION_MODES: ["Auto"], + ClimateConf.OPERATION_MODES: ["Auto"], } | ( { @@ -452,8 +455,8 @@ async def test_fan_speed_3_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "step", - ClimateSchema.CONF_FAN_MAX_STEP: 3, + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_MAX_STEP: 3, } } ) @@ -508,8 +511,8 @@ async def test_fan_speed_2_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "step", - ClimateSchema.CONF_FAN_MAX_STEP: 2, + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_MAX_STEP: 2, } } ) @@ -561,8 +564,8 @@ async def test_fan_speed_1_step(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "step", - ClimateSchema.CONF_FAN_MAX_STEP: 1, + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_MAX_STEP: 1, } } ) @@ -604,8 +607,8 @@ async def test_fan_speed_5_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "step", - ClimateSchema.CONF_FAN_MAX_STEP: 5, + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_MAX_STEP: 5, } } ) @@ -660,7 +663,7 @@ async def test_fan_speed_percentage(hass: HomeAssistant, knx: KNXTestKit) -> Non ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "percent", + ClimateConf.FAN_SPEED_MODE: "percent", } } ) @@ -725,8 +728,8 @@ async def test_fan_speed_percentage_4_steps( ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_SPEED_MODE: "percent", - ClimateSchema.CONF_FAN_MAX_STEP: 4, + ClimateConf.FAN_SPEED_MODE: "percent", + ClimateConf.FAN_MAX_STEP: 4, } } ) @@ -785,9 +788,9 @@ async def test_fan_speed_zero_mode_auto(hass: HomeAssistant, knx: KNXTestKit) -> ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_FAN_MAX_STEP: 3, - ClimateSchema.CONF_FAN_SPEED_MODE: "step", - ClimateSchema.CONF_FAN_ZERO_MODE: "auto", + ClimateConf.FAN_MAX_STEP: 3, + ClimateConf.FAN_SPEED_MODE: "step", + ClimateConf.FAN_ZERO_MODE: "auto", } } ) @@ -938,3 +941,83 @@ async def test_horizontal_swing(hass: HomeAssistant, knx: KNXTestKit) -> None: ) await knx.assert_write("1/2/6", False) knx.assert_state("climate.test", HVACMode.HEAT, swing_horizontal_mode="off") + + +async def test_climate_ui_create( + hass: HomeAssistant, + knx: KNXTestKit, + create_ui_entity: KnxEntityGenerator, +) -> None: + """Test creating a climate entity.""" + await knx.setup_integration() + await create_ui_entity( + platform=Platform.CLIMATE, + entity_data={"name": "test"}, + knx_data={ + "ga_temperature_current": {"state": "0/0/1"}, + "target_temperature": { + "ga_temperature_target": {"write": "1/1/1", "state": "1/1/2"}, + }, + "sync_state": True, + }, + ) + # created entity sends read-request to KNX bus + await knx.assert_read("0/0/1", response=RAW_FLOAT_20_0) + await knx.assert_read("1/1/2", response=RAW_FLOAT_20_0) + knx.assert_state("climate.test", HVACMode.HEAT) + + +async def test_climate_ui_load(knx: KNXTestKit) -> None: + """Test loading climate entities from storage.""" + await knx.setup_integration(config_store_fixture="config_store_climate.json") + # direct_indi-op_heat-cool + await knx.assert_read( + "0/0/1", response=RAW_FLOAT_20_0, ignore_order=True + ) # current + await knx.assert_read("0/1/2", response=RAW_FLOAT_20_0, ignore_order=True) # target + await knx.assert_read( + "0/4/2", response=False, ignore_order=True + ) # on_off - inverted + await knx.assert_read("0/3/2", response=True, ignore_order=True) # heat-cool + await knx.assert_read("0/2/1", response=True, ignore_order=True) # comfort + await knx.assert_read("0/2/2", response=False, ignore_order=True) # eco + await knx.assert_read("0/2/3", response=False, ignore_order=True) # standby + await knx.assert_read("0/2/4", response=False, ignore_order=True) # protection + + # sps_op-mode_contr-mode + await knx.assert_read( + "1/0/1", response=RAW_FLOAT_20_0, ignore_order=True + ) # current + await knx.assert_read("1/1/0", response=RAW_FLOAT_21_0, ignore_order=True) # target + await knx.assert_read( + "1/1/2", response=RAW_FLOAT_MINUS_1_0, ignore_order=True + ) # shift + await knx.assert_read("1/1/3", response=True, ignore_order=True) # active + await knx.assert_read("1/1/4", response=(0x22,), ignore_order=True) # valve + await knx.assert_read("1/4/2", response=(0x22,), ignore_order=True) # fan speed + await knx.assert_read("1/4/4", response=False, ignore_order=True) # swing vertical + await knx.assert_read( + "1/4/6", response=False, ignore_order=True + ) # swing horizontal + await knx.assert_read( + "1/0/2", response=RAW_FLOAT_20_0, ignore_order=True + ) # humidity + await knx.assert_read( + "1/2/2", # operation mode + response=(0x01,), # comfort + ignore_order=True, + ) + await knx.assert_read( + "1/3/2", # controller mode + response=(0x03,), # cool + ignore_order=True, + ) + + knx.assert_state( + "climate.direct_indi_op_heat_cool", + HVACMode.HEAT, + ) + knx.assert_state( + "climate.sps_op_mode_contr_mode", + HVACMode.COOL, + )