diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 8c9ee07c6f1..4c03d31135e 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -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} diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c3c4c0b604a..2b6a64edf69 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -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 diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 5c32ca44dee..58f2a608e47 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -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" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 4986742c63d..d3c8fc0b29d 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -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 diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 08c485f01b3..0c9ff765710 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -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": { diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index b693c034199..d60b4bd1a49 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -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 diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index e25430a293b..005e9b86e3a 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -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( diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 8078b9e13bd..a45ffce9e47 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -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", + }, }, }, {