diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b5e60614dfc..4ef13276fbe 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -44,6 +44,9 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.components.zwave_js.discovery_data_template import ( + DynamicCurrentTempClimateDataTemplate, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, @@ -59,6 +62,7 @@ from homeassistant.helpers.temperature import convert_temperature from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .helpers import get_value_of_zwave_value # Map Z-Wave HVAC Mode to Home Assistant value # Note: We treat "auto" as "heat_cool" as most Z-Wave devices @@ -110,7 +114,10 @@ async def async_setup_entry( def async_add_climate(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave Climate.""" entities: list[ZWaveBaseEntity] = [] - entities.append(ZWaveClimate(config_entry, client, info)) + if info.platform_hint == "dynamic_current_temp": + entities.append(DynamicCurrentTempClimate(config_entry, client, info)) + else: + entities.append(ZWaveClimate(config_entry, client, info)) async_add_entities(entities) @@ -129,7 +136,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo ) -> None: - """Initialize lock.""" + """Initialize thermostat.""" super().__init__(config_entry, client, info) self._hvac_modes: dict[str, int | None] = {} self._hvac_presets: dict[str, int | None] = {} @@ -285,12 +292,12 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): @property def current_humidity(self) -> int | None: """Return the current humidity level.""" - return self._current_humidity.value if self._current_humidity else None + return get_value_of_zwave_value(self._current_humidity) @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._current_temp.value if self._current_temp else None + return get_value_of_zwave_value(self._current_temp) @property def target_temperature(self) -> float | None: @@ -302,7 +309,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) except (IndexError, ValueError): return None - return temp.value if temp else None + return get_value_of_zwave_value(temp) @property def target_temperature_high(self) -> float | None: @@ -314,7 +321,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) except (IndexError, ValueError): return None - return temp.value if temp else None + return get_value_of_zwave_value(temp) @property def target_temperature_low(self) -> float | None: @@ -482,3 +489,25 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") await self.info.node.async_set_value(self._current_mode, preset_mode_value) + + +class DynamicCurrentTempClimate(ZWaveClimate): + """Representation of a thermostat that can dynamically use a different Zwave Value for current temp.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize thermostat.""" + super().__init__(config_entry, client, info) + self.data_template = cast( + DynamicCurrentTempClimateDataTemplate, self.info.platform_data_template + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + assert self.info.platform_data + val = get_value_of_zwave_value( + self.data_template.current_temperature_value(self.info.platform_data) + ) + return val if val is not None else super().current_temperature diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 21e75a8ea51..217a0493ce2 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -5,13 +5,19 @@ from collections.abc import Generator from dataclasses import dataclass from typing import Any -from zwave_js_server.const import CommandClass +from zwave_js_server.const import THERMOSTAT_CURRENT_TEMP_PROPERTY, CommandClass from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.core import callback +from .discovery_data_template import ( + BaseDiscoverySchemaDataTemplate, + DynamicCurrentTempClimateDataTemplate, + ZwaveValueID, +) + @dataclass class ZwaveDiscoveryInfo: @@ -27,6 +33,12 @@ class ZwaveDiscoveryInfo: platform: str # hint for the platform about this discovered entity platform_hint: str | None = "" + # data template to use in platform logic + platform_data_template: BaseDiscoverySchemaDataTemplate | None = None + # helper data to use in platform setup + platform_data: dict[str, Any] | None = None + # additional values that need to be watched by entity + additional_value_ids_to_watch: set[str] | None = None @dataclass @@ -69,6 +81,8 @@ class ZWaveDiscoverySchema: primary_value: ZWaveValueDiscoverySchema # [optional] hint for platform hint: str | None = None + # [optional] template to generate platform specific data to use in setup + data_template: BaseDiscoverySchemaDataTemplate | None = None # [optional] the node's manufacturer_id must match ANY of these values manufacturer_id: set[int] | None = None # [optional] the node's product_id must match ANY of these values @@ -214,6 +228,52 @@ DISCOVERY_SCHEMAS = [ primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, assumed_state=True, ), + # Heatit Z-TRM3 + ZWaveDiscoverySchema( + platform="climate", + hint="dynamic_current_temp", + manufacturer_id={0x019B}, + product_id={0x0203}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.THERMOSTAT_MODE}, + property={"mode"}, + type={"number"}, + ), + data_template=DynamicCurrentTempClimateDataTemplate( + { + # Internal Sensor + "A": ZwaveValueID( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + "AF": ZwaveValueID( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + CommandClass.SENSOR_MULTILEVEL, + endpoint=2, + ), + # External Sensor + "A2": ZwaveValueID( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + "A2F": ZwaveValueID( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + CommandClass.SENSOR_MULTILEVEL, + endpoint=3, + ), + # Floor sensor + "F": ZwaveValueID( + THERMOSTAT_CURRENT_TEMP_PROPERTY, + CommandClass.SENSOR_MULTILEVEL, + endpoint=4, + ), + }, + ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + ), + ), # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= # Door lock mode config parameter. Functionality equivalent to Notification CC # list sensors. @@ -524,6 +584,15 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None ): continue + # resolve helper data from template + resolved_data = None + additional_value_ids_to_watch = None + if schema.data_template: + resolved_data = schema.data_template.resolve_data(value) + additional_value_ids_to_watch = schema.data_template.value_ids_to_watch( + resolved_data + ) + # all checks passed, this value belongs to an entity yield ZwaveDiscoveryInfo( node=value.node, @@ -531,6 +600,9 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None assumed_state=schema.assumed_state, platform=schema.platform, platform_hint=schema.hint, + platform_data_template=schema.data_template, + platform_data=resolved_data, + additional_value_ids_to_watch=additional_value_ids_to_watch, ) if not schema.allow_multi: diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py new file mode 100644 index 00000000000..4a2a8d2da94 --- /dev/null +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -0,0 +1,109 @@ +"""Data template classes for discovery used to generate device specific data for setup.""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.value import Value as ZwaveValue, get_value_id + + +@dataclass +class ZwaveValueID: + """Class to represent a value ID.""" + + property_: str | int + command_class: int + endpoint: int | None = None + property_key: str | int | None = None + + +@dataclass +class BaseDiscoverySchemaDataTemplate: + """Base class for discovery schema data templates.""" + + def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: + """ + Resolve helper class data for a discovered value. + + Can optionally be implemented by subclasses if input data needs to be + transformed once discovered Value is available. + """ + # pylint: disable=no-self-use + return {} + + def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + """ + Return list of all ZwaveValues resolved by helper that should be watched. + + Should be implemented by subclasses only if there are values to watch. + """ + # pylint: disable=no-self-use + return [] + + def value_ids_to_watch(self, resolved_data: dict[str, Any]) -> set[str]: + """ + Return list of all Value IDs resolved by helper that should be watched. + + Not to be overwritten by subclasses. + """ + return {val.value_id for val in self.values_to_watch(resolved_data) if val} + + @staticmethod + def _get_value_from_id( + node: ZwaveNode, value_id_obj: ZwaveValueID + ) -> ZwaveValue | None: + """Get a ZwaveValue from a node using a ZwaveValueDict.""" + value_id = get_value_id( + node, + value_id_obj.command_class, + value_id_obj.property_, + endpoint=value_id_obj.endpoint, + property_key=value_id_obj.property_key, + ) + return node.values.get(value_id) + + +@dataclass +class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): + """Data template class for Z-Wave JS Climate entities with dynamic current temps.""" + + lookup_table: dict[str | int, ZwaveValueID] + dependent_value: ZwaveValueID + + def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: + """Resolve helper class data for a discovered value.""" + data: dict[str, Any] = { + "lookup_table": {}, + "dependent_value": self._get_value_from_id( + value.node, self.dependent_value + ), + } + for key in self.lookup_table: + data["lookup_table"][key] = self._get_value_from_id( + value.node, self.lookup_table[key] + ) + + return data + + def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue]: + """Return list of all ZwaveValues resolved by helper that should be watched.""" + return [ + *resolved_data["lookup_table"].values(), + resolved_data["dependent_value"], + ] + + @staticmethod + def current_temperature_value(resolved_data: dict[str, Any]) -> ZwaveValue | None: + """Get current temperature ZwaveValue from resolved data.""" + lookup_table: dict[str | int, ZwaveValue | None] = resolved_data["lookup_table"] + dependent_value: ZwaveValue | None = resolved_data["dependent_value"] + + if dependent_value: + lookup_key = dependent_value.metadata.states[ + str(dependent_value.value) + ].split("-")[0] + return lookup_table.get(lookup_key) + + return None diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 20efa9ed7db..322bdb0f286 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -37,6 +37,11 @@ class ZWaveBaseEntity(Entity): # entities requiring additional values, can add extra ids to this list self.watched_value_ids = {self.info.primary_value.value_id} + if self.info.additional_value_ids_to_watch: + self.watched_value_ids = self.watched_value_ids.union( + self.info.additional_value_ids_to_watch + ) + @callback def on_value_update(self) -> None: """Call when one of the watched values change. diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 664d35ea972..beee7fefa30 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -1,10 +1,11 @@ """Helper functions for Z-Wave JS integration.""" from __future__ import annotations -from typing import cast +from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION @@ -15,6 +16,12 @@ from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg from .const import CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN +@callback +def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: + """Return the value of a ZwaveValue.""" + return value.value if value else None + + async def async_enable_statistics(client: ZwaveClient) -> None: """Enable statistics on the driver.""" await client.driver.async_enable_statistics("Home Assistant", HA_VERSION) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index 8682ce98b5b..cb84570158c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -438,6 +438,7 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integration): """Test a thermostat v2 command class entity.""" + node = climate_heatit_z_trm3 state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) assert state @@ -453,6 +454,52 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio assert state.attributes[ATTR_MIN_TEMP] == 5 assert state.attributes[ATTR_MAX_TEMP] == 35 + # Try switching to external sensor + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 24, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 4, + "prevValue": 2, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 0 + + # Try switching to floor sensor + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 24, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 2, + "propertyName": "Sensor mode", + "newValue": 0, + "prevValue": 4, + }, + }, + ) + node.receive_event(event) + state = hass.states.get(CLIMATE_FLOOR_THERMOSTAT_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 + async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration): """Test a climate entity from a HRT4-ZW / SRT321 thermostat device.