diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 77590e780a5..58dae39781e 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -642,6 +642,30 @@ DISCOVERY_SCHEMAS = [ platform="siren", primary_value=SIREN_TONE_SCHEMA, ), + # select + # siren default tone + ZWaveDiscoverySchema( + platform="select", + hint="Default tone", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultToneId"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), + # number + # siren default volume + ZWaveDiscoverySchema( + platform="number", + hint="volume", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, + property={"defaultVolume"}, + type={"number"}, + ), + required_values=[SIREN_TONE_SCHEMA], + ), ] diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index e53e5942999..675a396fb7b 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -26,7 +26,10 @@ async def async_setup_entry( def async_add_number(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave number entity.""" entities: list[ZWaveBaseEntity] = [] - entities.append(ZwaveNumberEntity(config_entry, client, info)) + if info.platform_hint == "volume": + entities.append(ZwaveVolumeNumberEntity(config_entry, client, info)) + else: + entities.append(ZwaveNumberEntity(config_entry, client, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -87,3 +90,38 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set new value.""" await self.info.node.async_set_value(self._target_value, value) + + +class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): + """Representation of a volume number entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveVolumeNumberEntity entity.""" + super().__init__(config_entry, client, info) + self.correction_factor = int( + self.info.primary_value.metadata.max - self.info.primary_value.metadata.min + ) + # Fallback in case we can't properly calculate correction factor + if self.correction_factor == 0: + self.correction_factor = 1 + + # Entity class attributes + self._attr_min_value = 0 + self._attr_max_value = 1 + self._attr_step = 0.01 + self._attr_name = self.generate_name(include_value_name=True) + + @property + def value(self) -> float | None: + """Return the entity value.""" + if self.info.primary_value.value is None: + return None + return float(self.info.primary_value.value) / self.correction_factor + + async def async_set_value(self, value: float) -> None: + """Set new value.""" + await self.info.node.async_set_value( + self.info.primary_value, round(value * self.correction_factor) + ) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py new file mode 100644 index 00000000000..2bd711bfde3 --- /dev/null +++ b/homeassistant/components/zwave_js/select.py @@ -0,0 +1,91 @@ +"""Support for Z-Wave controls using the select platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import CommandClass, ToneID + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Select entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_select(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave select entity.""" + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "Default tone": + entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{SELECT_DOMAIN}", + async_add_select, + ) + ) + + +class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave default tone select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveDefaultToneSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._tones_value = self.get_zwave_value( + "toneId", command_class=CommandClass.SOUND_SWITCH + ) + + # Entity class attributes + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return [ + val + for key, val in self._tones_value.metadata.states.items() + if int(key) not in (ToneID.DEFAULT, ToneID.OFF) + ] + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + return str( + self._tones_value.metadata.states.get( + str(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str | int) -> None: + """Change the selected option.""" + # We know we can assert because this value is part of the discovery schema + assert self._tones_value + key = next( + key + for key, val in self._tones_value.metadata.states.items() + if val == option + ) + await self.info.node.async_set_value(self.info.primary_value, int(key)) diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 6439d034587..6d9458d096c 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -1,11 +1,13 @@ """Test the Z-Wave JS number platform.""" from zwave_js_server.event import Event +from homeassistant.const import STATE_UNKNOWN from homeassistant.helpers import entity_registry as er from .common import BASIC_NUMBER_ENTITY NUMBER_ENTITY = "number.thermostat_hvac_valve_control" +VOLUME_NUMBER_ENTITY = "number.indoor_siren_6_default_volume_2" async def test_number(hass, client, aeotec_radiator_thermostat, integration): @@ -73,6 +75,98 @@ async def test_number(hass, client, aeotec_radiator_thermostat, integration): assert state.state == "99.0" +async def test_volume_number(hass, client, aeotec_zw164_siren, integration): + """Test the volume number entity.""" + node = aeotec_zw164_siren + state = hass.states.get(VOLUME_NUMBER_ENTITY) + + assert state + assert state.state == "1.0" + assert state.attributes["step"] == 0.01 + assert state.attributes["max"] == 1.0 + assert state.attributes["min"] == 0 + + # Test turn on setting value + await hass.services.async_call( + "number", + "set_value", + {"entity_id": VOLUME_NUMBER_ENTITY, "value": 0.3}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%", + }, + "value": 100, + } + assert args["value"] == 30 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": 30, + "prevValue": 100, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == "0.3" + + # Test null value + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 4, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultVolume", + "newValue": None, + "prevValue": 30, + "propertyName": "defaultVolume", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(VOLUME_NUMBER_ENTITY) + assert state.state == STATE_UNKNOWN + + async def test_disabled_basic_number(hass, ge_in_wall_dimmer_switch, integration): """Test number is created from Basic CC and is disabled.""" ent_reg = er.async_get(hass) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py new file mode 100644 index 00000000000..b94bac812b6 --- /dev/null +++ b/tests/components/zwave_js/test_select.py @@ -0,0 +1,101 @@ +"""Test the Z-Wave JS number platform.""" +from zwave_js_server.event import Event + +DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" + + +async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): + """Test the default tone select entity.""" + node = aeotec_zw164_siren + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + + assert state + assert state.state == "17ALAR~1 (35 sec)" + attr = state.attributes + assert attr["options"] == [ + "01DING~1 (5 sec)", + "02DING~1 (9 sec)", + "03TRAD~1 (11 sec)", + "04ELEC~1 (2 sec)", + "05WEST~1 (13 sec)", + "06CHIM~1 (7 sec)", + "07CUCK~1 (31 sec)", + "08TRAD~1 (6 sec)", + "09SMOK~1 (11 sec)", + "10SMOK~1 (6 sec)", + "11FIRE~1 (35 sec)", + "12COSE~1 (5 sec)", + "13KLAX~1 (38 sec)", + "14DEEP~1 (41 sec)", + "15WARN~1 (37 sec)", + "16TORN~1 (46 sec)", + "17ALAR~1 (35 sec)", + "18DEEP~1 (62 sec)", + "19ALAR~1 (15 sec)", + "20ALAR~1 (7 sec)", + "21DIGI~1 (8 sec)", + "22ALER~1 (64 sec)", + "23SHIP~1 (4 sec)", + "25CHRI~1 (4 sec)", + "26GONG~1 (12 sec)", + "27SING~1 (1 sec)", + "28TONA~1 (5 sec)", + "29UPWA~1 (2 sec)", + "30DOOR~1 (27 sec)", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": DEFAULT_TONE_SELECT_ENTITY, "option": "30DOOR~1 (27 sec)"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Default tone ID", + "min": 0, + "max": 254, + }, + "value": 17, + } + assert args["value"] == 30 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "defaultToneId", + "newValue": 30, + "prevValue": 17, + "propertyName": "defaultToneId", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(DEFAULT_TONE_SELECT_ENTITY) + assert state.state == "30DOOR~1 (27 sec)"