Add ZHA ZCL thermostat entities (#106563)

This commit is contained in:
Caius-Bonus 2024-01-31 03:26:19 +01:00 committed by GitHub
parent 82e1ed43f8
commit a7a41e54f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 237 additions and 2 deletions

View File

@ -146,6 +146,7 @@ class ThermostatClusterHandler(ClusterHandler):
Thermostat.AttributeDefs.min_cool_setpoint_limit.name: True,
Thermostat.AttributeDefs.min_heat_setpoint_limit.name: True,
Thermostat.AttributeDefs.local_temperature_calibration.name: True,
Thermostat.AttributeDefs.setpoint_change_source.name: True,
}
@property
@ -341,3 +342,5 @@ class ThermostatClusterHandler(ClusterHandler):
@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id)
class UserInterfaceClusterHandler(ClusterHandler):
"""User interface (thermostat) cluster handler."""
ZCL_INIT_ATTRS = {UserInterface.AttributeDefs.keypad_lockout.name: True}

View File

@ -5,6 +5,8 @@ import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from zigpy.zcl.clusters.hvac import Thermostat
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature
@ -985,3 +987,69 @@ class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity):
_attr_mode: NumberMode = NumberMode.BOX
_attr_icon: str = "mdi:timer-edit"
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ZCLTemperatureEntity(ZHANumberConfigurationEntity):
"""Common entity class for ZCL temperature input."""
_attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS
_attr_mode: NumberMode = NumberMode.BOX
_attr_native_step: float = 0.01
_attr_multiplier: float = 0.01
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class ZCLHeatSetpointLimitEntity(ZCLTemperatureEntity):
"""Min or max heat setpoint setting on thermostats."""
_attr_icon: str = "mdi:thermostat"
_attr_native_step: float = 0.5
_min_source = Thermostat.AttributeDefs.abs_min_heat_setpoint_limit.name
_max_source = Thermostat.AttributeDefs.abs_max_heat_setpoint_limit.name
@property
def native_min_value(self) -> float:
"""Return the minimum value."""
# The spec says 0x954D, which is a signed integer, therefore the value is in decimals
min_present_value = self._cluster_handler.cluster.get(self._min_source, -27315)
return min_present_value * self._attr_multiplier
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
max_present_value = self._cluster_handler.cluster.get(self._max_source, 0x7FFF)
return max_present_value * self._attr_multiplier
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity):
"""Max heat setpoint setting on thermostats.
Optional thermostat attribute.
"""
_unique_id_suffix = "max_heat_setpoint_limit"
_attribute_name: str = "max_heat_setpoint_limit"
_attr_translation_key: str = "max_heat_setpoint_limit"
_attr_entity_category = EntityCategory.CONFIG
_min_source = Thermostat.AttributeDefs.min_heat_setpoint_limit.name
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity):
"""Min heat setpoint setting on thermostats.
Optional thermostat attribute.
"""
_unique_id_suffix = "min_heat_setpoint_limit"
_attribute_name: str = "min_heat_setpoint_limit"
_attr_translation_key: str = "min_heat_setpoint_limit"
_attr_entity_category = EntityCategory.CONFIG
_max_source = Thermostat.AttributeDefs.max_heat_setpoint_limit.name

View File

@ -673,3 +673,28 @@ class SonoffPresenceDetectionSensitivity(ZCLEnumSelectEntity):
_attribute_name = "ultrasonic_u_to_o_threshold"
_enum = SonoffPresenceDetectionSensitivityEnum
_attr_translation_key: str = "detection_sensitivity"
class KeypadLockoutEnum(types.enum8):
"""Keypad lockout options."""
Unlock = 0x00
Lock1 = 0x01
Lock2 = 0x02
Lock3 = 0x03
Lock4 = 0x04
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="thermostat_ui")
class KeypadLockout(ZCLEnumSelectEntity):
"""Mandatory attribute for thermostat_ui cluster.
Often only the first two are implemented, and Lock2 to Lock4 should map to Lock1 in the firmware.
This however covers all bases.
"""
_unique_id_suffix = "keypad_lockout"
_attribute_name: str = "keypad_lockout"
_enum = KeypadLockoutEnum
_attr_translation_key: str = "keypad_lockout"
_attr_icon: str = "mdi:lock"

View File

@ -95,6 +95,9 @@ CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = (
)
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR)
MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR)
CONFIG_DIAGNOSTIC_MATCH = functools.partial(
ZHA_ENTITIES.config_diagnostic_match, Platform.SENSOR
)
async def async_setup_entry(
@ -238,6 +241,19 @@ class PollableSensor(Sensor):
)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class EnumSensor(Sensor):
"""Sensor with value from enum."""
_attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM
_enum: type[enum.Enum]
def formatter(self, value: int) -> str | None:
"""Use name of enum."""
assert self._enum is not None
return self._enum(value).name
@MULTI_MATCH(
cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT,
manufacturers="Digi",
@ -1254,3 +1270,45 @@ class SonoffPresenceSenorIlluminationStatus(Sensor):
def formatter(self, value: int) -> int | float | None:
"""Numeric pass-through formatter."""
return SonoffIlluminationStates(value).name
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class PiHeatingDemand(Sensor):
"""Sensor that displays the percentage of heating power demanded.
Optional thermostat attribute.
"""
_unique_id_suffix = "pi_heating_demand"
_attribute_name = "pi_heating_demand"
_attr_translation_key: str = "pi_heating_demand"
_attr_icon: str = "mdi:radiator"
_attr_native_unit_of_measurement = PERCENTAGE
_decimals = 0
_attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC
class SetpointChangeSourceEnum(types.enum8):
"""The source of the setpoint change."""
Manual = 0x00
Schedule = 0x01
External = 0x02
@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT)
# pylint: disable-next=hass-invalid-inheritance # needs fixing
class SetpointChangeSource(EnumSensor):
"""Sensor that displays the source of the setpoint change.
Optional thermostat attribute.
"""
_unique_id_suffix = "setpoint_change_source"
_attribute_name = "setpoint_change_source"
_attr_translation_key: str = "setpoint_change_source"
_attr_icon: str = "mdi:thermostat"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_enum = SetpointChangeSourceEnum

View File

@ -730,6 +730,12 @@
},
"presence_detection_timeout": {
"name": "Presence detection timeout"
},
"max_heat_setpoint_limit": {
"name": "Max heat setpoint limit"
},
"min_heat_setpoint_limit": {
"name": "Min heat setpoint limit"
}
},
"select": {
@ -798,6 +804,9 @@
},
"detection_sensitivity": {
"name": "Detection Sensitivity"
},
"keypad_lockout": {
"name": "Keypad lockout"
}
},
"sensor": {
@ -881,6 +890,12 @@
},
"last_illumination_state": {
"name": "Last illumination state"
},
"pi_heating_demand": {
"name": "Pi heating demand"
},
"setpoint_change_source": {
"name": "Setpoint change source"
}
},
"switch": {

View File

@ -400,7 +400,9 @@ async def test_climate_hvac_action_running_state_zen(
thrm_cluster = device_climate_zen.device.endpoints[1].thermostat
entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen, hass)
sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_zen, hass)
sensor_entity_id = find_entity_id(
Platform.SENSOR, device_climate_zen, hass, "hvac_action"
)
state = hass.states.get(entity_id)
assert ATTR_HVAC_ACTION not in state.attributes

View File

@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch
import pytest
import zigpy.profiles.zha
from zigpy.zcl.clusters import general, homeautomation, measurement, smartenergy
from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy
from zigpy.zcl.clusters.hvac import Thermostat
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.zha.core.const import ZHA_CLUSTER_HANDLER_READS_PER_REQ
@ -342,6 +343,23 @@ async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id)
assert_state(hass, entity_id, "29.0", UnitOfTemperature.CELSIUS)
async def async_test_setpoint_change_source(hass, cluster, entity_id):
"""Test the translation of numerical state into enum text."""
await send_attributes_report(
hass, cluster, {Thermostat.AttributeDefs.setpoint_change_source.id: 0x01}
)
hass_state = hass.states.get(entity_id)
assert hass_state.state == "Schedule"
async def async_test_pi_heating_demand(hass, cluster, entity_id):
"""Test pi heating demand is correctly returned."""
await send_attributes_report(
hass, cluster, {Thermostat.AttributeDefs.pi_heating_demand.id: 1}
)
assert_state(hass, entity_id, "1", "%")
@pytest.mark.parametrize(
(
"cluster_id",
@ -502,6 +520,22 @@ async def async_test_device_temperature(hass: HomeAssistant, cluster, entity_id)
None,
None,
),
(
hvac.Thermostat.cluster_id,
"setpoint_change_source",
async_test_setpoint_change_source,
10,
None,
None,
),
(
hvac.Thermostat.cluster_id,
"pi_heating_demand",
async_test_pi_heating_demand,
10,
None,
None,
),
),
)
async def test_sensor(

View File

@ -4434,6 +4434,16 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_pi_heating_demand",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_setpoint_change_source",
},
},
},
{
@ -4524,6 +4534,16 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_pi_heating_demand",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_setpoint_change_source",
},
},
},
{
@ -4817,6 +4837,16 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand",
DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_pi_heating_demand",
},
("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): {
DEV_SIG_CLUSTER_HANDLERS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource",
DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_setpoint_change_source",
},
},
},
{