diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c32e74c5b5f..7fa15f9f95a 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -291,7 +291,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # Internal Sensor "A": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -321,7 +321,7 @@ DISCOVERY_SCHEMAS = [ endpoint=4, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), ), ), # Heatit Z-TRM2fx @@ -338,7 +338,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), data_template=DynamicCurrentTempClimateDataTemplate( - { + lookup_table={ # External Sensor "A2": ZwaveValueID( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -357,7 +357,24 @@ DISCOVERY_SCHEMAS = [ endpoint=3, ), }, - ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + dependent_value=ZwaveValueID(2, CommandClass.CONFIGURATION, endpoint=0), + ), + ), + # FortrezZ SSA1/SSA2 + ZWaveDiscoverySchema( + platform="select", + hint="multilevel_switch", + manufacturer_id={0x0084}, + product_id={0x0107, 0x0108, 0x010B, 0x0205}, + product_type={0x0311, 0x0313, 0x0341, 0x0343}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + data_template=BaseDiscoverySchemaDataTemplate( + { + 0: "Off", + 33: "Strobe ONLY", + 66: "Siren ONLY", + 99: "Siren & Strobe FULL Alarm", + }, ), ), # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index f294294625a..7b76465d60e 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any from zwave_js_server.const import CommandClass @@ -92,9 +92,12 @@ class ZwaveValueID: property_key: str | int | None = None +@dataclass class BaseDiscoverySchemaDataTemplate: """Base class for discovery schema data templates.""" + static_data: Any | None = None + def resolve_data(self, value: ZwaveValue) -> Any: """ Resolve helper class data for a discovered value. @@ -141,11 +144,13 @@ class BaseDiscoverySchemaDataTemplate: 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 + lookup_table: dict[str | int, ZwaveValueID] = field(default_factory=dict) + dependent_value: ZwaveValueID | None = None def resolve_data(self, value: ZwaveValue) -> dict[str, Any]: """Resolve helper class data for a discovered value.""" + if not self.lookup_table or not self.dependent_value: + raise ValueError("Invalid discovery data template") data: dict[str, Any] = { "lookup_table": {}, "dependent_value": self._get_value_from_id( diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index fae87fd24de..9ec4d02bfec 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -1,6 +1,8 @@ """Support for Z-Wave controls using the select platform.""" from __future__ import annotations +from typing import Dict, cast + from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.sound_switch import ToneID @@ -30,6 +32,10 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "Default tone": entities.append(ZwaveDefaultToneSelectEntity(config_entry, client, info)) + elif info.platform_hint == "multilevel_switch": + entities.append( + ZwaveMultilevelSwitchSelectEntity(config_entry, client, info) + ) else: entities.append(ZwaveSelectEntity(config_entry, client, info)) async_add_entities(entities) @@ -126,3 +132,37 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): if val == option ) await self.info.node.async_set_value(self.info.primary_value, int(key)) + + +class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): + """Representation of a Z-Wave Multilevel Switch CC select entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSelectEntity entity.""" + super().__init__(config_entry, client, info) + self._target_value = self.get_zwave_value("targetValue") + assert self.info.platform_data_template + self._lookup_map = cast( + Dict[int, str], self.info.platform_data_template.static_data + ) + + # Entity class attributes + self._attr_options = list(self._lookup_map.values()) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + if self.info.primary_value.value is None: + return None + return str( + self._lookup_map.get( + int(self.info.primary_value.value), self.info.primary_value.value + ) + ) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + key = next(key for key, val in self._lookup_map.items() if val == option) + await self.info.node.async_set_value(self._target_value, int(key)) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 6634fdf759d..422f4b55c16 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -455,6 +455,12 @@ def lock_popp_electric_strike_lock_control_state_fixture(): ) +@pytest.fixture(name="fortrezz_ssa1_siren_state", scope="session") +def fortrezz_ssa1_siren_state_fixture(): + """Load the fortrezz ssa1 siren node state fixture data.""" + return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -859,6 +865,14 @@ def lock_popp_electric_strike_lock_control_fixture( return node +@pytest.fixture(name="fortrezz_ssa1_siren") +def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): + """Mock a fortrezz ssa1 siren node.""" + node = Node(client, copy.deepcopy(fortrezz_ssa1_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 9758d3b0f44..ad176d0168e 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -6,6 +6,9 @@ from homeassistant.components.zwave_js.discovery import ( ZWaveDiscoverySchema, ZWaveValueDiscoverySchema, ) +from homeassistant.components.zwave_js.discovery_data_template import ( + DynamicCurrentTempClimateDataTemplate, +) async def test_iblinds_v2(hass, client, iblinds_v2, integration): @@ -76,3 +79,12 @@ async def test_firmware_version_range_exception(hass): ZWaveValueDiscoverySchema(command_class=1), firmware_version_range=FirmwareVersionRange(), ) + + +async def test_dynamic_climate_data_discovery_template_failure(hass, multisensor_6): + """Test that initing a DynamicCurrentTempClimateDataTemplate with no data raises.""" + node = multisensor_6 + with pytest.raises(ValueError): + DynamicCurrentTempClimateDataTemplate().resolve_data( + node.values[f"{node.node_id}-49-0-Ultraviolet"] + ) diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index 43f44f0bba0..5ed0804723c 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -5,6 +5,7 @@ from homeassistant.const import STATE_UNKNOWN DEFAULT_TONE_SELECT_ENTITY = "select.indoor_siren_6_default_tone_2" PROTECTION_SELECT_ENTITY = "select.family_room_combo_local_protection_state" +MULTILEVEL_SWITCH_SELECT_ENTITY = "select.front_door_siren" async def test_default_tone_select(hass, client, aeotec_zw164_siren, integration): @@ -199,3 +200,75 @@ async def test_protection_select(hass, client, inovelli_lzw36, integration): state = hass.states.get(PROTECTION_SELECT_ENTITY) assert state.state == STATE_UNKNOWN + + +async def test_multilevel_switch_select(hass, client, fortrezz_ssa1_siren, integration): + """Test Multilevel Switch CC based select entity.""" + node = fortrezz_ssa1_siren + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + + assert state + assert state.state == "Off" + attr = state.attributes + assert attr["options"] == [ + "Off", + "Strobe ONLY", + "Siren ONLY", + "Siren & Strobe FULL Alarm", + ] + + # Test select option with string value + await hass.services.async_call( + "select", + "select_option", + {"entity_id": MULTILEVEL_SWITCH_SELECT_ENTITY, "option": "Strobe ONLY"}, + 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": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + }, + } + assert args["value"] == 33 + + 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": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 33, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(MULTILEVEL_SWITCH_SELECT_ENTITY) + assert state.state == "Strobe ONLY" diff --git a/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json new file mode 100644 index 00000000000..d8973f2688e --- /dev/null +++ b/tests/fixtures/zwave_js/fortrezz_ssa1_siren_state.json @@ -0,0 +1,350 @@ +{ + "nodeId": 80, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 132, + "productId": 267, + "productType": 787, + "firmwareVersion": "1.11", + "name": "Front Door Siren", + "location": "Outside", + "deviceConfig": { + "filename": "/data/db/devices/0x0084/ssa1_ssa2.json", + "isEmbedded": true, + "manufacturer": "FortrezZ LLC", + "manufacturerId": 132, + "label": "SSA1/SSA2", + "description": "Siren and Strobe Alarm", + "devices": [ + { + "productType": 785, + "productId": 267 + }, + { + "productType": 787, + "productId": 264 + }, + { + "productType": 787, + "productId": 267 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + } + }, + "label": "SSA1/SSA2", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 80, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 132 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 787 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 267 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.97" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.11"] + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Delay before accept of Basic Set Off", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay, from the time the siren-strobe turns on", + "label": "Delay before accept of Basic Set Off", + "default": 0, + "min": 0, + "max": 255, + "unit": "Seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0084:0x0313:0x010b:1.11", + "statistics": { + "commandsTX": 12, + "commandsRX": 64, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 2 + } +}