Add cooling status/information sensors to Weheat (#181278)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Barry vd. Heuvel
2026-09-07 15:38:47 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent ab9ccbf5b9
commit 488ad0645d
6 changed files with 866 additions and 4 deletions
@@ -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"
},
+110 -2
View File
@@ -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)
@@ -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"
},
+21
View File
@@ -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
@@ -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({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: 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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cooling blocked by',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling blocked by',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: 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': <ANY>,
'entity_id': 'sensor.test_model_cooling_blocked_by',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'demand',
})
# ---
# name: test_all_entities[sensor.test_model_cooling_conditions_met-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling conditions met',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'of 9',
}),
'context': <ANY>,
'entity_id': 'sensor.test_model_cooling_conditions_met',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8',
})
# ---
# name: test_all_entities[sensor.test_model_cooling_pause_reason-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: 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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_model_cooling_pause_reason',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cooling pause reason',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling pause reason',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: 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': <ANY>,
'entity_id': 'sensor.test_model_cooling_pause_reason',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'water_temperature_below_setpoint',
})
# ---
# name: test_all_entities[sensor.test_model_cooling_state-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'idle',
'starting',
'active',
'stopping',
'standby',
'pausing',
'water_check',
'standby_run_cp',
'paused',
'stopped',
'waiting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cooling state',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling state',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'idle',
'starting',
'active',
'stopping',
'standby',
'pausing',
'water_check',
'standby_run_cp',
'paused',
'stopped',
'waiting',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_model_cooling_state',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'waiting',
})
# ---
# name: test_all_entities[sensor.test_model_cooling_stop_reason-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'none',
'dtc',
'control_method',
'no_indoor_unit_communication',
'heat_pump_control',
'cooling_control',
'contact_switch_over',
'thermostat_disabled',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_model_cooling_stop_reason',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cooling stop reason',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling stop reason',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'none',
'dtc',
'control_method',
'no_indoor_unit_communication',
'heat_pump_control',
'cooling_control',
'contact_switch_over',
'thermostat_disabled',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_model_cooling_stop_reason',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Cooling wait until',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Cooling wait until',
}),
'context': <ANY>,
'entity_id': 'sensor.test_model_cooling_wait_until',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last cooling',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test Model Last cooling',
}),
'context': <ANY>,
'entity_id': 'sensor.test_model_last_cooling',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-06-21T14:30:00+00:00',
})
# ---
# name: test_all_entities[sensor.test_model_output_power-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+184 -2
View File
@@ -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
)