Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Z-Wave JS siren #52948

Merged
merged 10 commits into from Jul 14, 2021
4 changes: 4 additions & 0 deletions homeassistant/components/zwave_js/const.py
Expand Up @@ -70,3 +70,7 @@
SERVICE_PING = "ping"

ADDON_SLUG = "core_zwave_js"

# Siren constants
TONE_ID_DEFAULT = 255
TONE_ID_OFF = 0
9 changes: 9 additions & 0 deletions homeassistant/components/zwave_js/discovery.py
Expand Up @@ -175,6 +175,10 @@ def get_config_parameter_discovery_schema(
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 = [
Expand Down Expand Up @@ -582,6 +586,11 @@ def get_config_parameter_discovery_schema(
platform="light",
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
),
# sirens
ZWaveDiscoverySchema(
platform="siren",
primary_value=SIREN_TONE_SCHEMA,
),
]


Expand Down
105 changes: 105 additions & 0 deletions homeassistant/components/zwave_js/siren.py
@@ -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)
14 changes: 14 additions & 0 deletions tests/components/zwave_js/conftest.py
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
145 changes: 145 additions & 0 deletions tests/components/zwave_js/test_siren.py
@@ -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