Add support for Z-Wave JS siren (#52948)

* Add support for Z-Wave JS siren

* Add additional device class to discovery

* fix docstring

* Remove device class specific part of discovery schema

* rename test

* switch to entry.async_on_remove

* Fix logic based on #52971

* Use constants to unblock PR

* Add support to set volume level

* Update homeassistant/components/zwave_js/siren.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/52972/head^2
Raman Gupta 2021-07-14 10:14:13 -04:00 committed by GitHub
parent e541bcd54d
commit 1f15181522
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 4025 additions and 0 deletions

View File

@ -69,3 +69,7 @@ ATTR_BROADCAST = "broadcast"
SERVICE_PING = "ping"
ADDON_SLUG = "core_zwave_js"
# Siren constants
TONE_ID_DEFAULT = 255
TONE_ID_OFF = 0

View File

@ -175,6 +175,10 @@ SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"}
)
SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.SOUND_SWITCH}, property={"toneId"}, type={"number"}
)
# For device class mapping see:
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json
DISCOVERY_SCHEMAS = [
@ -582,6 +586,11 @@ DISCOVERY_SCHEMAS = [
platform="light",
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
),
# sirens
ZWaveDiscoverySchema(
platform="siren",
primary_value=SIREN_TONE_SCHEMA,
),
]

View File

@ -0,0 +1,105 @@
"""Support for Z-Wave controls using the siren platform."""
from __future__ import annotations
from typing import Any
from zwave_js_server.client import Client as ZwaveClient
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity
from homeassistant.components.siren.const import (
ATTR_TONE,
ATTR_VOLUME_LEVEL,
SUPPORT_TONES,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
SUPPORT_VOLUME_SET,
)
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, TONE_ID_DEFAULT, TONE_ID_OFF
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 Siren entity from Config Entry."""
client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
@callback
def async_add_siren(info: ZwaveDiscoveryInfo) -> None:
"""Add Z-Wave siren entity."""
entities: list[ZWaveBaseEntity] = []
entities.append(ZwaveSirenEntity(config_entry, client, info))
async_add_entities(entities)
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{config_entry.entry_id}_add_{SIREN_DOMAIN}",
async_add_siren,
)
)
class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity):
"""Representation of a Z-Wave siren entity."""
def __init__(
self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo
) -> None:
"""Initialize a ZwaveSirenEntity entity."""
super().__init__(config_entry, client, info)
# Entity class attributes
self._attr_available_tones = list(
self.info.primary_value.metadata.states.values()
)
self._attr_supported_features = (
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET
)
if self._attr_available_tones:
self._attr_supported_features |= SUPPORT_TONES
@property
def is_on(self) -> bool:
"""Return whether device is on."""
return bool(self.info.primary_value.value)
async def async_set_value(
self, new_value: int, options: dict[str, Any] | None = None
) -> None:
"""Set a value on a siren node."""
await self.info.node.async_set_value(
self.info.primary_value, new_value, options=options
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
tone: str | None = kwargs.get(ATTR_TONE)
options = {}
if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
options["volume"] = round(volume * 100)
# Play the default tone if a tone isn't provided
if tone is None:
await self.async_set_value(TONE_ID_DEFAULT, options)
return
tone_id = int(
next(
key
for key, value in self.info.primary_value.metadata.states.items()
if value == tone
)
)
await self.async_set_value(tone_id, options)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self.async_set_value(TONE_ID_OFF)

View File

@ -429,6 +429,12 @@ def wallmote_central_scene_state_fixture():
return json.loads(load_fixture("zwave_js/wallmote_central_scene_state.json"))
@pytest.fixture(name="aeotec_zw164_siren_state", scope="session")
def aeotec_zw164_siren_state_fixture():
"""Load the aeotec zw164 siren node state fixture data."""
return json.loads(load_fixture("zwave_js/aeotec_zw164_siren_state.json"))
@pytest.fixture(name="client")
def mock_client_fixture(controller_state, version_state, log_config_state):
"""Mock a client."""
@ -789,6 +795,14 @@ def wallmote_central_scene_fixture(client, wallmote_central_scene_state):
return node
@pytest.fixture(name="aeotec_zw164_siren")
def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state):
"""Mock a wallmote central scene node."""
node = Node(client, copy.deepcopy(aeotec_zw164_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."""

View File

@ -0,0 +1,145 @@
"""Test the Z-Wave JS siren platform."""
from zwave_js_server.event import Event
from homeassistant.components.siren import ATTR_TONE, ATTR_VOLUME_LEVEL
from homeassistant.const import STATE_OFF, STATE_ON
SIREN_ENTITY = "siren.indoor_siren_6_2"
TONE_ID_VALUE_ID = {
"endpoint": 2,
"commandClass": 121,
"commandClassName": "Sound Switch",
"property": "toneId",
"propertyName": "toneId",
"ccVersion": 1,
"metadata": {
"type": "number",
"readable": True,
"writeable": True,
"label": "Play Tone",
"min": 0,
"max": 30,
"states": {
"0": "off",
"1": "01DING~1 (5 sec)",
"2": "02DING~1 (9 sec)",
"3": "03TRAD~1 (11 sec)",
"4": "04ELEC~1 (2 sec)",
"5": "05WEST~1 (13 sec)",
"6": "06CHIM~1 (7 sec)",
"7": "07CUCK~1 (31 sec)",
"8": "08TRAD~1 (6 sec)",
"9": "09SMOK~1 (11 sec)",
"10": "10SMOK~1 (6 sec)",
"11": "11FIRE~1 (35 sec)",
"12": "12COSE~1 (5 sec)",
"13": "13KLAX~1 (38 sec)",
"14": "14DEEP~1 (41 sec)",
"15": "15WARN~1 (37 sec)",
"16": "16TORN~1 (46 sec)",
"17": "17ALAR~1 (35 sec)",
"18": "18DEEP~1 (62 sec)",
"19": "19ALAR~1 (15 sec)",
"20": "20ALAR~1 (7 sec)",
"21": "21DIGI~1 (8 sec)",
"22": "22ALER~1 (64 sec)",
"23": "23SHIP~1 (4 sec)",
"25": "25CHRI~1 (4 sec)",
"26": "26GONG~1 (12 sec)",
"27": "27SING~1 (1 sec)",
"28": "28TONA~1 (5 sec)",
"29": "29UPWA~1 (2 sec)",
"30": "30DOOR~1 (27 sec)",
"255": "default",
},
},
}
async def test_siren(hass, client, aeotec_zw164_siren, integration):
"""Test the siren entity."""
node = aeotec_zw164_siren
state = hass.states.get(SIREN_ENTITY)
assert state
assert state.state == STATE_OFF
# Test turn on with default
await hass.services.async_call(
"siren",
"turn_on",
{"entity_id": SIREN_ENTITY},
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"] == TONE_ID_VALUE_ID
assert args["value"] == 255
client.async_send_command.reset_mock()
# Test turn on with specific tone name and volume level
await hass.services.async_call(
"siren",
"turn_on",
{
"entity_id": SIREN_ENTITY,
ATTR_TONE: "01DING~1 (5 sec)",
ATTR_VOLUME_LEVEL: 0.5,
},
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"] == TONE_ID_VALUE_ID
assert args["value"] == 1
assert args["options"] == {"volume": 50}
client.async_send_command.reset_mock()
# Test turn off
await hass.services.async_call(
"siren",
"turn_off",
{"entity_id": SIREN_ENTITY},
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"] == TONE_ID_VALUE_ID
assert args["value"] == 0
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": "toneId",
"newValue": 255,
"prevValue": 0,
"propertyName": "toneId",
},
},
)
node.receive_event(event)
state = hass.states.get(SIREN_ENTITY)
assert state.state == STATE_ON

File diff suppressed because it is too large Load Diff