diff --git a/.coveragerc b/.coveragerc index 8582f2daed8..01788ff14da 100644 --- a/.coveragerc +++ b/.coveragerc @@ -841,6 +841,7 @@ omit = homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py + homeassistant/components/nibe_heatpump/climate.py homeassistant/components/nibe_heatpump/binary_sensor.py homeassistant/components/nibe_heatpump/number.py homeassistant/components/nibe_heatpump/select.py diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 68e16871549..ed70579ecb7 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -48,6 +48,7 @@ from .const import ( PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -156,6 +157,8 @@ class ContextCoordinator( self, update_callback: CALLBACK_TYPE, context: Any = None ) -> Callable[[], None]: """Wrap standard function to prune cached callback database.""" + assert isinstance(context, set) + context -= {None} release = super().async_add_listener(update_callback, context) self.__dict__.pop("context_callbacks", None) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py new file mode 100644 index 00000000000..637cd51e0a5 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -0,0 +1,220 @@ +"""The Nibe Heat Pump climate.""" +from __future__ import annotations + +from typing import Any + +from nibe.coil import Coil +from nibe.coil_groups import ( + CLIMATE_COILGROUPS, + UNIT_COILGROUPS, + ClimateCoilGroup, + UnitCoilGroup, +) +from nibe.exceptions import CoilNotFoundException + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import Coordinator +from .const import ( + DOMAIN, + LOGGER, + VALUES_MIXING_VALVE_CLOSED_STATE, + VALUES_PRIORITY_COOLING, + VALUES_PRIORITY_HEATING, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up platform.""" + + coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + main_unit = UNIT_COILGROUPS.get(coordinator.series, {}).get("main") + if not main_unit: + LOGGER.debug("Skipping climates - no main unit found") + return + + def climate_systems(): + for key, group in CLIMATE_COILGROUPS.get(coordinator.series, ()).items(): + try: + yield NibeClimateEntity(coordinator, key, main_unit, group) + except CoilNotFoundException as exception: + LOGGER.debug("Skipping climate: %s due to %s", key, exception) + + async_add_entities(climate_systems()) + + +class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): + """Climate entity.""" + + _attr_entity_category = None + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.HEAT] + _attr_target_temperature_step = 0.5 + _attr_max_temp = 35.0 + _attr_min_temp = 5.0 + + def __init__( + self, + coordinator: Coordinator, + key: str, + unit: UnitCoilGroup, + climate: ClimateCoilGroup, + ) -> None: + """Initialize entity.""" + super().__init__( + coordinator, + { + unit.prio, + unit.cooling_with_room_sensor, + climate.current, + climate.setpoint_heat, + climate.setpoint_cool, + climate.mixing_valve_state, + climate.active_accessory, + climate.use_room_sensor, + }, + ) + self._attr_available = False + self._attr_name = climate.name + self._attr_unique_id = f"{coordinator.unique_id}-{key}" + self._attr_device_info = coordinator.device_info + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.OFF + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + self._attr_target_temperature = None + self._attr_entity_registry_enabled_default = climate.active_accessory is None + + def _get(address: int) -> Coil: + return coordinator.heatpump.get_coil_by_address(address) + + self._coil_current = _get(climate.current) + self._coil_setpoint_heat = _get(climate.setpoint_heat) + self._coil_setpoint_cool = _get(climate.setpoint_cool) + self._coil_prio = _get(unit.prio) + self._coil_mixing_valve_state = _get(climate.mixing_valve_state) + if climate.active_accessory is None: + self._coil_active_accessory = None + else: + self._coil_active_accessory = _get(climate.active_accessory) + self._coil_use_room_sensor = _get(climate.use_room_sensor) + self._coil_cooling_with_room_sensor = _get(unit.cooling_with_room_sensor) + + if self._coil_current: + self._attr_temperature_unit = self._coil_current.unit + + def _handle_coordinator_update(self) -> None: + if not self.coordinator.data: + return + + def _get_value(coil: Coil) -> int | str | float | None: + return self.coordinator.get_coil_value(coil) + + def _get_float(coil: Coil) -> float | None: + return self.coordinator.get_coil_float(coil) + + self._attr_current_temperature = _get_float(self._coil_current) + + mode = HVACMode.OFF + if _get_value(self._coil_use_room_sensor) == "ON": + if _get_value(self._coil_cooling_with_room_sensor) == "ON": + mode = HVACMode.HEAT_COOL + else: + mode = HVACMode.HEAT + self._attr_hvac_mode = mode + + setpoint_heat = _get_float(self._coil_setpoint_heat) + setpoint_cool = _get_float(self._coil_setpoint_cool) + + if mode == HVACMode.HEAT_COOL: + self._attr_target_temperature = None + self._attr_target_temperature_low = setpoint_heat + self._attr_target_temperature_high = setpoint_cool + elif mode == HVACMode.HEAT: + self._attr_target_temperature = setpoint_heat + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + else: + self._attr_target_temperature = None + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + if prio := _get_value(self._coil_prio): + if ( + _get_value(self._coil_mixing_valve_state) + in VALUES_MIXING_VALVE_CLOSED_STATE + ): + self._attr_hvac_action = HVACAction.IDLE + elif prio in VALUES_PRIORITY_HEATING: + self._attr_hvac_action = HVACAction.HEATING + elif prio in VALUES_PRIORITY_COOLING: + self._attr_hvac_action = HVACAction.COOLING + else: + self._attr_hvac_action = HVACAction.IDLE + else: + self._attr_hvac_action = None + + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return if entity is available.""" + coordinator = self.coordinator + active = self._coil_active_accessory + + if not coordinator.last_update_success: + return False + + if not active: + return True + + if active_accessory := coordinator.get_coil_value(active): + return active_accessory == "ON" + + return False + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperatures.""" + coordinator = self.coordinator + hvac_mode = kwargs.get(ATTR_HVAC_MODE, self._attr_hvac_mode) + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + if hvac_mode == HVACMode.HEAT: + await coordinator.async_write_coil( + self._coil_setpoint_heat, temperature + ) + elif hvac_mode == HVACMode.COOL: + await coordinator.async_write_coil( + self._coil_setpoint_cool, temperature + ) + else: + raise ValueError( + f"Don't known which temperature to control for hvac mode: {self._attr_hvac_mode}" + ) + + if (temperature := kwargs.get(ATTR_TARGET_TEMP_LOW)) is not None: + await coordinator.async_write_coil(self._coil_setpoint_heat, temperature) + + if (temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH)) is not None: + await coordinator.async_write_coil(self._coil_setpoint_cool, temperature) diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 381ad7ba0c2..7d9bf58709c 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -13,3 +13,7 @@ CONF_CONNECTION_TYPE_NIBEGW = "nibegw" CONF_CONNECTION_TYPE_MODBUS = "modbus" CONF_MODBUS_URL = "modbus_url" CONF_MODBUS_UNIT = "modbus_unit" + +VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") +VALUES_PRIORITY_HEATING = (30, "HEAT") +VALUES_PRIORITY_COOLING = (60, "COOLING") diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index f9276570885..8760cbe1b55 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -3,7 +3,7 @@ "name": "Nibe Heat Pump", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", - "requirements": ["nibe==1.3.0"], + "requirements": ["nibe==1.4.0"], "codeowners": ["@elupus"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index cc0bb13bafe..92830abbe46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1169,7 +1169,7 @@ nextcord==2.0.0a8 nextdns==1.2.2 # homeassistant.components.nibe_heatpump -nibe==1.3.0 +nibe==1.4.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 790c067a3b1..f68fd267952 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -856,7 +856,7 @@ nextcord==2.0.0a8 nextdns==1.2.2 # homeassistant.components.nibe_heatpump -nibe==1.3.0 +nibe==1.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5