diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index 0b20377a9ed9..c5fc792dbc72 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -30,6 +30,24 @@ "compressor_rpm": { "default": "mdi:fan" }, + "cooling_blocked_by": { + "default": "mdi:snowflake-alert" + }, + "cooling_conditions_met": { + "default": "mdi:checkbox-multiple-marked-outline" + }, + "cooling_pause_reason": { + "default": "mdi:snowflake-off" + }, + "cooling_state": { + "default": "mdi:snowflake-thermometer" + }, + "cooling_stop_reason": { + "default": "mdi:snowflake-off" + }, + "cooling_wait_until": { + "default": "mdi:timer-sand" + }, "cop": { "default": "mdi:speedometer" }, @@ -75,6 +93,9 @@ "heat_pump_state": { "default": "mdi:state-machine" }, + "last_cooling_time": { + "default": "mdi:snowflake-check" + }, "outside_temperature": { "default": "mdi:home-thermometer-outline" }, diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index c56ab44b0688..58ab024669f7 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -2,6 +2,8 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime +from enum import Enum from typing import override from weheat.abstractions.heat_pump import HeatPump @@ -47,7 +49,21 @@ PARALLEL_UPDATES = 0 class WeHeatSensorEntityDescription(SensorEntityDescription): """Describes Weheat sensor entity.""" - value_fn: Callable[[HeatPump], StateType] + value_fn: Callable[[HeatPump], StateType | datetime] + + +# The portal counts the conditions the heat pump waits on and leaves these two +# settings out of its tally. +COOLING_CONDITIONS_NOT_COUNTED = ("control_method", "contact_not_blocked") + + +# A cooling state is only reported during a cooling cycle and covers every substate +# of it, including the water check the overall heat pump state reports as its own. +def _latched_reason(status: HeatPump, reason: Enum | None) -> str | None: + """Return a reason from before the cycle, which says nothing while one runs.""" + if status.cooling_state is not None: + return "none" + return reason.name.lower() if reason is not None else None SENSORS = [ @@ -239,6 +255,89 @@ DHW_SENSORS = [ ), ] +COOLING_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="cooling_state", + key="cooling_state", + device_class=SensorDeviceClass.ENUM, + options=[activity.name.lower() for activity in HeatPump.CoolingActivity], + value_fn=lambda status: ( + status.cooling_activity.name.lower() + if status.cooling_activity is not None + else None + ), + ), + WeHeatSensorEntityDescription( + translation_key="cooling_blocked_by", + key="cooling_blocked_by", + device_class=SensorDeviceClass.ENUM, + options=["none", *HeatPump.COOLING_START_CONDITION_BITS], + value_fn=lambda status: ( + None + if status.cooling_start_conditions is None + else "none" + if status.cooling_state is not None + else next( + ( + name + for name in HeatPump.COOLING_START_CONDITION_BITS + if not status.cooling_start_conditions[name] + ), + "none", + ) + ), + ), + WeHeatSensorEntityDescription( + translation_key="cooling_conditions_met", + key="cooling_conditions_met", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: ( + None + if status.cooling_start_conditions is None + or status.cooling_state is not None + else sum( + met + for name, met in status.cooling_start_conditions.items() + if name not in COOLING_CONDITIONS_NOT_COUNTED + ) + ), + ), + WeHeatSensorEntityDescription( + translation_key="cooling_wait_until", + key="cooling_wait_until", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda status: ( + status.cooling_available_from + if status.cooling_start_conditions is not None + and not status.cooling_start_conditions["exponential_backoff"] + else None + ), + ), + WeHeatSensorEntityDescription( + translation_key="last_cooling_time", + key="last_cooling_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda status: status.last_cooling_time, + ), + WeHeatSensorEntityDescription( + translation_key="cooling_pause_reason", + key="cooling_pause_reason", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[reason.name.lower() for reason in HeatPump.CoolingPauseReason], + value_fn=lambda status: _latched_reason(status, status.cooling_pause_reason), + ), + WeHeatSensorEntityDescription( + translation_key="cooling_stop_reason", + key="cooling_stop_reason", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[reason.name.lower() for reason in HeatPump.CoolingStopReason], + value_fn=lambda status: _latched_reason(status, status.cooling_stop_reason), + ), +] + + ENERGY_SENSORS = [ WeHeatSensorEntityDescription( translation_key="electricity_used", @@ -360,6 +459,15 @@ async def async_setup_entry( for entity_description in SENSORS if entity_description.value_fn(weheatdata.data_coordinator.data) is not None ) + if weheatdata.data_coordinator.data.cooling_activity is not None: + entities.extend( + WeheatHeatPumpSensor( + weheatdata.heat_pump_info, + weheatdata.data_coordinator, + entity_description, + ) + for entity_description in COOLING_SENSORS + ) if weheatdata.heat_pump_info.has_dhw: entities.extend( WeheatHeatPumpSensor( @@ -412,6 +520,6 @@ class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 2f1fd03cca79..1b44b6efedde 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -63,6 +63,73 @@ "compressor_rpm": { "name": "Compressor speed" }, + "cooling_blocked_by": { + "name": "Cooling blocked by", + "state": { + "contact_not_blocked": "Blocked by external contact", + "control_method": "Control method does not allow cooling", + "demand": "No cooling demand", + "dtc": "Cooling fault active", + "exponential_backoff": "Waiting for restart delay", + "heat_cool_delay": "Waiting for heating to cooling delay", + "indoor_unit_connected": "Indoor unit not connected", + "inside_temperature": "Room not warmer than target", + "none": "Not blocked", + "outside_air_temperature": "Outside temperature too low", + "water_temperature": "Water not warmer than cooling curve", + "water_to_air": "Air not warmer than water" + } + }, + "cooling_conditions_met": { + "name": "Cooling conditions met", + "unit_of_measurement": "of 9" + }, + "cooling_pause_reason": { + "name": "Cooling pause reason", + "state": { + "contact_blocked": "Blocked by external contact", + "demand": "No cooling demand", + "heat_pump_control": "Paused for another function", + "none": "Not paused", + "outside_colder_than_water_temperature": "Outside colder than water", + "outside_temperature_too_low": "Outside temperature too low", + "room_temperature_too_low": "Room temperature too low", + "water_temperature_below_dewpoint": "Water temperature below dew point", + "water_temperature_below_setpoint": "Water temperature colder than setpoint" + } + }, + "cooling_state": { + "name": "Cooling state", + "state": { + "active": "Cooling", + "idle": "Idle", + "paused": "Paused", + "pausing": "Pausing", + "standby": "[%key:common::state::standby%]", + "standby_run_cp": "Standby, pump running", + "starting": "Starting", + "stopped": "Stopped", + "stopping": "Stopping", + "waiting": "Waiting to start", + "water_check": "Checking water temperature" + } + }, + "cooling_stop_reason": { + "name": "Cooling stop reason", + "state": { + "contact_switch_over": "External contact switched over", + "control_method": "Stopped by control method", + "cooling_control": "Stopped by cooling control", + "dtc": "Stopped by diagnostics", + "heat_pump_control": "Stopped for another function", + "no_indoor_unit_communication": "No indoor unit communication", + "none": "Not stopped", + "thermostat_disabled": "Thermostat disabled" + } + }, + "cooling_wait_until": { + "name": "Cooling wait until" + }, "cop": { "name": "COP" }, @@ -139,6 +206,9 @@ "water_check": "Checking water temperature" } }, + "last_cooling_time": { + "name": "Last cooling" + }, "outside_temperature": { "name": "Outside temperature" }, diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index cd57febf5a7e..3f1fbf656cfd 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -1,6 +1,7 @@ """Fixtures for Weheat tests.""" from collections.abc import Generator +from datetime import UTC, datetime from time import time from unittest.mock import AsyncMock, MagicMock, patch @@ -136,6 +137,26 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.compressor_rpm = 4500 mock_heat_pump_instance.compressor_percentage = 100 mock_heat_pump_instance.dhw_flow_volume = 1.12 + mock_heat_pump_instance.cooling_pause_reason_code = 4 + mock_heat_pump_instance.cooling_stop_reason_code = 0 + mock_heat_pump_instance.last_cooling_time = datetime( + 2025, 6, 21, 14, 30, tzinfo=UTC + ) + # The heat pump only reports a cooling state during a cooling cycle, so a + # heating one derives its cooling activity from the latched reasons instead. + mock_heat_pump_instance.cooling_state = None + mock_heat_pump_instance.cooling_activity = HeatPump.CoolingActivity.WAITING + mock_heat_pump_instance.cooling_pause_reason = ( + HeatPump.CoolingPauseReason.WATER_TEMPERATURE_BELOW_SETPOINT + ) + mock_heat_pump_instance.cooling_stop_reason = HeatPump.CoolingStopReason.NONE + mock_heat_pump_instance.cooling_backoff = 60 + mock_heat_pump_instance.cooling_available_from = datetime( + 2025, 6, 21, 15, 30, tzinfo=UTC + ) + mock_heat_pump_instance.cooling_start_conditions = { + name: name != "demand" for name in HeatPump.COOLING_START_CONDITION_BITS + } mock_heat_pump_instance.dhw_target_temperature = 55 mock_heat_pump_instance.dhw_control_method = HeatPump.DhwControlMethod.FIXED mock_heat_pump_instance.dhw_control_method_code = 1 diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 201fe11c7db0..ae3bc0dfc6bf 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -359,6 +359,415 @@ 'state': '100', }) # --- +# name: test_all_entities[sensor.test_model_cooling_blocked_by-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'control_method', + 'dtc', + 'outside_air_temperature', + 'inside_temperature', + 'indoor_unit_connected', + 'water_to_air', + 'demand', + 'water_temperature', + 'contact_not_blocked', + 'exponential_backoff', + 'heat_cool_delay', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_cooling_blocked_by', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling blocked by', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling blocked by', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_blocked_by', + 'unique_id': '0000-1111-2222-3333_cooling_blocked_by', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_blocked_by-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Test Model Cooling blocked by', + : list([ + 'none', + 'control_method', + 'dtc', + 'outside_air_temperature', + 'inside_temperature', + 'indoor_unit_connected', + 'water_to_air', + 'demand', + 'water_temperature', + 'contact_not_blocked', + 'exponential_backoff', + 'heat_cool_delay', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_blocked_by', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'demand', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_conditions_met-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_cooling_conditions_met', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling conditions met', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cooling conditions met', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_conditions_met', + 'unique_id': '0000-1111-2222-3333_cooling_conditions_met', + 'unit_of_measurement': 'of 9', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_conditions_met-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Test Model Cooling conditions met', + : , + : 'of 9', + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_conditions_met', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_pause_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'room_temperature_too_low', + 'outside_temperature_too_low', + 'outside_colder_than_water_temperature', + 'water_temperature_below_setpoint', + 'heat_pump_control', + 'water_temperature_below_dewpoint', + 'contact_blocked', + 'demand', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_model_cooling_pause_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling pause reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling pause reason', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_pause_reason', + 'unique_id': '0000-1111-2222-3333_cooling_pause_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_pause_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Test Model Cooling pause reason', + : list([ + 'none', + 'room_temperature_too_low', + 'outside_temperature_too_low', + 'outside_colder_than_water_temperature', + 'water_temperature_below_setpoint', + 'heat_pump_control', + 'water_temperature_below_dewpoint', + 'contact_blocked', + 'demand', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_pause_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'water_temperature_below_setpoint', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'idle', + 'starting', + 'active', + 'stopping', + 'standby', + 'pausing', + 'water_check', + 'standby_run_cp', + 'paused', + 'stopped', + 'waiting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_cooling_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling state', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_state', + 'unique_id': '0000-1111-2222-3333_cooling_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Test Model Cooling state', + : list([ + 'idle', + 'starting', + 'active', + 'stopping', + 'standby', + 'pausing', + 'water_check', + 'standby_run_cp', + 'paused', + 'stopped', + 'waiting', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_stop_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'dtc', + 'control_method', + 'no_indoor_unit_communication', + 'heat_pump_control', + 'cooling_control', + 'contact_switch_over', + 'thermostat_disabled', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_model_cooling_stop_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling stop reason', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling stop reason', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_stop_reason', + 'unique_id': '0000-1111-2222-3333_cooling_stop_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_stop_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Test Model Cooling stop reason', + : list([ + 'none', + 'dtc', + 'control_method', + 'no_indoor_unit_communication', + 'heat_pump_control', + 'cooling_control', + 'contact_switch_over', + 'thermostat_disabled', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_stop_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_wait_until-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_cooling_wait_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cooling wait until', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooling wait until', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cooling_wait_until', + 'unique_id': '0000-1111-2222-3333_cooling_wait_until', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cooling_wait_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Test Model Cooling wait until', + }), + 'context': , + 'entity_id': 'sensor.test_model_cooling_wait_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[sensor.test_model_cop-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1467,6 +1876,57 @@ 'state': '55', }) # --- +# name: test_all_entities[sensor.test_model_last_cooling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_last_cooling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last cooling', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last cooling', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_cooling_time', + 'unique_id': '0000-1111-2222-3333_last_cooling_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_last_cooling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'Test Model Last cooling', + }), + 'context': , + 'entity_id': 'sensor.test_model_last_cooling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-21T14:30:00+00:00', + }) +# --- # name: test_all_entities[sensor.test_model_output_power-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index 48c089761180..c96f3f6e3a88 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -5,8 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery +from weheat.abstractions.heat_pump import HeatPump -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weheat.sensor import COOLING_CONDITIONS_NOT_COUNTED +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,7 +36,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 25), (True, 32)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 32), (True, 39)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, @@ -95,3 +98,182 @@ async def test_an_unknown_dhw_control_method_keeps_the_sensor( assert ( hass.states.get("sensor.test_model_dhw_control_method").state == STATE_UNKNOWN ) + + +# The cooling sensors a heat pump that does not cool must not get. The cooling +# energy counters are not among them: those are reported either way, at zero. +COOLING_SENSORS = { + "sensor.test_model_cooling_state", + "sensor.test_model_cooling_blocked_by", + "sensor.test_model_cooling_conditions_met", + "sensor.test_model_cooling_wait_until", + "sensor.test_model_last_cooling", + "sensor.test_model_cooling_pause_reason", + "sensor.test_model_cooling_stop_reason", +} + + +CONDITIONS_COUNTED = len(HeatPump.COOLING_START_CONDITION_BITS) - len( + COOLING_CONDITIONS_NOT_COUNTED +) + + +def _start_conditions(*unmet: str) -> dict[str, bool]: + """Build a start condition mapping with the named conditions not met.""" + return {name: name not in unmet for name in HeatPump.COOLING_START_CONDITION_BITS} + + +@pytest.mark.parametrize( + ("unmet", "expected"), + [ + pytest.param((), "9", id="all_met"), + pytest.param(("demand",), "8", id="one_unmet"), + pytest.param(("outside_air_temperature", "exponential_backoff"), "7", id="two"), + pytest.param( + COOLING_CONDITIONS_NOT_COUNTED, "9", id="settings_are_not_counted" + ), + ], +) +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_cooling_conditions_met( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + unmet: tuple[str, ...], + expected: str, +) -> None: + """Test how many start conditions are met is counted as the portal counts.""" + mock_weheat_heat_pump.cooling_start_conditions = _start_conditions(*unmet) + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_model_cooling_conditions_met") + + assert state.state == expected + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == f"of {CONDITIONS_COUNTED}" + + +@pytest.mark.parametrize( + ("cooling_state", "heat_pump_state"), + [ + pytest.param(HeatPump.CoolingState.ACTIVE, HeatPump.State.COOLING, id="active"), + pytest.param(HeatPump.CoolingState.IDLE, HeatPump.State.COOLING, id="idle"), + # the heat pump reports the water check as a state of its own, so the + # overall state is not cooling while the cooling cycle still is + pytest.param( + HeatPump.CoolingState.WATER_CHECK, + HeatPump.State.WATER_CHECK, + id="water_check", + ), + ], +) +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_cooling_conditions_met_is_unknown_while_cooling( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + cooling_state: HeatPump.CoolingState, + heat_pump_state: HeatPump.State, +) -> None: + """Test the count is not reported once a cooling cycle is running.""" + mock_weheat_heat_pump.heat_pump_state = heat_pump_state + mock_weheat_heat_pump.cooling_state = cooling_state + mock_weheat_heat_pump.cooling_start_conditions = _start_conditions("demand") + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_model_cooling_conditions_met").state + == STATE_UNKNOWN + ) + + +@pytest.mark.parametrize( + ("sensor", "attribute", "stale"), + [ + pytest.param( + "cooling_pause_reason", + "cooling_pause_reason", + HeatPump.CoolingPauseReason.ROOM_TEMPERATURE_TOO_LOW, + id="pause_reason", + ), + pytest.param( + "cooling_stop_reason", + "cooling_stop_reason", + HeatPump.CoolingStopReason.HEAT_PUMP_CONTROL, + id="stop_reason", + ), + pytest.param( + "cooling_blocked_by", + "cooling_start_conditions", + dict.fromkeys(HeatPump.COOLING_START_CONDITION_BITS, False), + id="blocked_by", + ), + ], +) +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_stale_cooling_reasons_are_not_reported_while_cooling( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + sensor: str, + attribute: str, + stale: object, +) -> None: + """Test what held cooling off is not reported once a cycle is running.""" + mock_weheat_heat_pump.heat_pump_state = HeatPump.State.COOLING + mock_weheat_heat_pump.cooling_state = HeatPump.CoolingState.ACTIVE + setattr(mock_weheat_heat_pump, attribute, stale) + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(f"sensor.test_model_{sensor}").state == "none" + + +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_a_heat_pump_without_cooling_gets_no_cooling_sensors( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a heat pump that does not cool gets no cooling sensors at all.""" + mock_weheat_heat_pump.cooling_activity = None + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert [ + entity_id + for entity_id in hass.states.async_entity_ids(SENSOR_DOMAIN) + if entity_id in COOLING_SENSORS + ] == [] + + +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_cooling_without_start_conditions( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a cooling heat pump that reports no start conditions keeps its sensors.""" + mock_weheat_heat_pump.cooling_start_conditions = None + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_model_cooling_blocked_by").state == STATE_UNKNOWN + ) + assert ( + hass.states.get("sensor.test_model_cooling_conditions_met").state + == STATE_UNKNOWN + ) + assert ( + hass.states.get("sensor.test_model_cooling_wait_until").state == STATE_UNKNOWN + ) + assert ( + hass.states.get("sensor.test_model_cooling_pause_reason").state != STATE_UNKNOWN + )