Add zwave_js.set_config_parameter service (#46673)

* create zwave_js.set_config_value service

* update docstring

* PR comments

* make proposed changes

* handle providing a label for the new value

* fix docstring

* use new library function

* config param endpoint is always 0

* corresponding changes from upstream PR

* bug fixes and add tests

* create zwave_js.set_config_value service

* update docstring

* PR comments

* make proposed changes

* handle providing a label for the new value

* fix docstring

* use new library function

* config param endpoint is always 0

* corresponding changes from upstream PR

* bug fixes and add tests

* use lambda to avoid extra function

* add services description file

* bring back the missing selector

* move helper functions to helper file for reuse

* allow target selector for automation editor

* formatting

* fix service schema

* update docstrings

* raise error in service if call to set value is unsuccessful

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* Update homeassistant/components/zwave_js/services.yaml

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

* remove extra param to vol.Optional

* switch to set over list for nodes

* switch to set over list for nodes

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Raman Gupta 2021-02-23 11:35:11 -05:00 committed by GitHub
parent c94968d811
commit 5a3bd30e01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 548 additions and 11 deletions

View file

@ -43,7 +43,8 @@ from .const import (
ZWAVE_JS_EVENT,
)
from .discovery import async_discover_values
from .entity import get_device_id
from .helpers import get_device_id
from .services import ZWaveServices
LOGGER = logging.getLogger(__package__)
CONNECT_TIMEOUT = 10
@ -192,6 +193,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DATA_UNSUBSCRIBE: unsubscribe_callbacks,
}
services = ZWaveServices(hass)
services.async_register()
# Set up websocket API
async_register_api(hass)

View file

@ -33,4 +33,11 @@ ATTR_PROPERTY_NAME = "property_name"
ATTR_PROPERTY_KEY_NAME = "property_key_name"
ATTR_PARAMETERS = "parameters"
# service constants
SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter"
ATTR_CONFIG_PARAMETER = "parameter"
ATTR_CONFIG_PARAMETER_BITMASK = "bitmask"
ATTR_CONFIG_VALUE = "value"
ADDON_SLUG = "core_zwave_js"

View file

@ -1,30 +1,23 @@
"""Generic Z-Wave Entity Class."""
import logging
from typing import List, Optional, Tuple, Union
from typing import List, Optional, Union
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, get_value_id
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .helpers import get_device_id
LOGGER = logging.getLogger(__name__)
EVENT_VALUE_UPDATED = "value updated"
@callback
def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]:
"""Get device registry identifier for Z-Wave node."""
return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}")
class ZWaveBaseEntity(Entity):
"""Generic Entity Class for a Z-Wave Device."""

View file

@ -0,0 +1,100 @@
"""Helper functions for Z-Wave JS integration."""
from typing import List, Tuple, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.model.node import Node as ZwaveNode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import async_get as async_get_dev_reg
from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg
from .const import DATA_CLIENT, DOMAIN
@callback
def get_device_id(client: ZwaveClient, node: ZwaveNode) -> Tuple[str, str]:
"""Get device registry identifier for Z-Wave node."""
return (DOMAIN, f"{client.driver.controller.home_id}-{node.node_id}")
@callback
def get_home_and_node_id_from_device_id(device_id: Tuple[str, str]) -> List[str]:
"""
Get home ID and node ID for Z-Wave device registry entry.
Returns [home_id, node_id]
"""
return device_id[1].split("-")
@callback
def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveNode:
"""
Get node from a device ID.
Raises ValueError if device is invalid or node can't be found.
"""
device_entry = async_get_dev_reg(hass).async_get(device_id)
if not device_entry:
raise ValueError("Device ID is not valid")
# Use device config entry ID's to validate that this is a valid zwave_js device
# and to get the client
config_entry_ids = device_entry.config_entries
config_entry_id = next(
(
config_entry_id
for config_entry_id in config_entry_ids
if cast(
ConfigEntry,
hass.config_entries.async_get_entry(config_entry_id),
).domain
== DOMAIN
),
None,
)
if config_entry_id is None or config_entry_id not in hass.data[DOMAIN]:
raise ValueError("Device is not from an existing zwave_js config entry")
client = hass.data[DOMAIN][config_entry_id][DATA_CLIENT]
# Get node ID from device identifier, perform some validation, and then get the
# node
identifier = next(
(
get_home_and_node_id_from_device_id(identifier)
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
),
None,
)
node_id = int(identifier[1]) if identifier is not None else None
if node_id is None or node_id not in client.driver.controller.nodes:
raise ValueError("Device node can't be found")
return client.driver.controller.nodes[node_id]
@callback
def async_get_node_from_entity_id(hass: HomeAssistant, entity_id: str) -> ZwaveNode:
"""
Get node from an entity ID.
Raises ValueError if entity is invalid.
"""
entity_entry = async_get_ent_reg(hass).async_get(entity_id)
if not entity_entry:
raise ValueError("Entity ID is not valid")
if entity_entry.platform != DOMAIN:
raise ValueError("Entity is not from zwave_js integration")
# Assert for mypy, safe because we know that zwave_js entities are always
# tied to a device
assert entity_entry.device_id
return async_get_node_from_device_id(hass, entity_entry.device_id)

View file

@ -0,0 +1,110 @@
"""Methods and classes related to executing Z-Wave commands and publishing these to hass."""
import logging
from typing import Dict, Set, Union
import voluptuous as vol
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.util.node import async_set_config_parameter
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
import homeassistant.helpers.config_validation as cv
from . import const
from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id
_LOGGER = logging.getLogger(__name__)
def parameter_name_does_not_need_bitmask(
val: Dict[str, Union[int, str]]
) -> Dict[str, Union[int, str]]:
"""Validate that if a parameter name is provided, bitmask is not as well."""
if isinstance(val[const.ATTR_CONFIG_PARAMETER], str) and (
val.get(const.ATTR_CONFIG_PARAMETER_BITMASK)
):
raise vol.Invalid(
"Don't include a bitmask when a parameter name is specified",
path=[const.ATTR_CONFIG_PARAMETER, const.ATTR_CONFIG_PARAMETER_BITMASK],
)
return val
# Validates that a bitmask is provided in hex form and converts it to decimal
# int equivalent since that's what the library uses
BITMASK_SCHEMA = vol.All(
cv.string, vol.Lower, vol.Match(r"^(0x)?[0-9a-f]+$"), lambda value: int(value, 16)
)
class ZWaveServices:
"""Class that holds our services (Zwave Commands) that should be published to hass."""
def __init__(self, hass: HomeAssistant):
"""Initialize with hass object."""
self._hass = hass
@callback
def async_register(self) -> None:
"""Register all our services."""
self._hass.services.async_register(
const.DOMAIN,
const.SERVICE_SET_CONFIG_PARAMETER,
self.async_set_config_parameter,
schema=vol.All(
{
vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any(
vol.Coerce(int), cv.string
),
vol.Optional(const.ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(
vol.Coerce(int), BITMASK_SCHEMA
),
vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(
vol.Coerce(int), cv.string
),
},
cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID),
parameter_name_does_not_need_bitmask,
),
)
async def async_set_config_parameter(self, service: ServiceCall) -> None:
"""Set a config value on a node."""
nodes: Set[ZwaveNode] = set()
if ATTR_ENTITY_ID in service.data:
nodes |= {
async_get_node_from_entity_id(self._hass, entity_id)
for entity_id in service.data[ATTR_ENTITY_ID]
}
if ATTR_DEVICE_ID in service.data:
nodes |= {
async_get_node_from_device_id(self._hass, device_id)
for device_id in service.data[ATTR_DEVICE_ID]
}
property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER]
property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK)
new_value = service.data[const.ATTR_CONFIG_VALUE]
for node in nodes:
zwave_value = await async_set_config_parameter(
node,
new_value,
property_or_property_name,
property_key=property_key,
)
if zwave_value:
_LOGGER.info(
"Set configuration parameter %s on Node %s with value %s",
zwave_value,
node,
new_value,
)
else:
raise ValueError(
f"Unable to set configuration parameter on Node {node} with "
f"value {new_value}"
)

View file

@ -38,3 +38,31 @@ set_lock_usercode:
example: 1234
selector:
text:
set_config_parameter:
name: Set a Z-Wave device configuration parameter
description: Allow for changing configuration parameters of your Z-Wave devices.
target:
entity:
integration: zwave_js
fields:
parameter:
name: Parameter
description: The (name or id of the) configuration parameter you want to configure.
example: Minimum brightness level
required: true
selector:
text:
value:
name: Value
description: The new value to set for this configuration parameter.
example: 5
required: true
selector:
object:
bitmask:
name: Bitmask
description: Target a specific bitmask (see the documentation for more information).
advanced: true
selector:
object:

View file

@ -8,7 +8,7 @@ from zwave_js_server.model.node import Node
from homeassistant.components.hassio.handler import HassioAPIError
from homeassistant.components.zwave_js.const import DOMAIN
from homeassistant.components.zwave_js.entity import get_device_id
from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.config_entries import (
CONN_CLASS_LOCAL_PUSH,
ENTRY_STATE_LOADED,

View file

@ -0,0 +1,295 @@
"""Test the Z-Wave JS services."""
import pytest
import voluptuous as vol
from homeassistant.components.zwave_js.const import (
ATTR_CONFIG_PARAMETER,
ATTR_CONFIG_PARAMETER_BITMASK,
ATTR_CONFIG_VALUE,
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
)
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID
from homeassistant.helpers.device_registry import async_get as async_get_dev_reg
from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg
from .common import AIR_TEMPERATURE_SENSOR
from tests.common import MockConfigEntry
async def test_set_config_parameter(hass, client, multisensor_6, integration):
"""Test the set_config_parameter service."""
dev_reg = async_get_dev_reg(hass)
ent_reg = async_get_ent_reg(hass)
entity_entry = ent_reg.async_get(AIR_TEMPERATURE_SENSOR)
# Test setting config parameter by property and property_key
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR,
ATTR_CONFIG_PARAMETER: 102,
ATTR_CONFIG_PARAMETER_BITMASK: 1,
ATTR_CONFIG_VALUE: 1,
},
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"] == 52
assert args["valueId"] == {
"commandClassName": "Configuration",
"commandClass": 112,
"endpoint": 0,
"property": 102,
"propertyName": "Group 2: Send battery reports",
"propertyKey": 1,
"metadata": {
"type": "number",
"readable": True,
"writeable": True,
"valueSize": 4,
"min": 0,
"max": 1,
"default": 1,
"format": 0,
"allowManualEntry": True,
"label": "Group 2: Send battery reports",
"description": "Include battery information in periodic reports to Group 2",
"isFromConfig": True,
},
"value": 0,
}
assert args["value"] == 1
client.async_send_command.reset_mock()
# Test setting parameter by property name
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR,
ATTR_CONFIG_PARAMETER: "Group 2: Send battery reports",
ATTR_CONFIG_VALUE: 1,
},
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"] == 52
assert args["valueId"] == {
"commandClassName": "Configuration",
"commandClass": 112,
"endpoint": 0,
"property": 102,
"propertyName": "Group 2: Send battery reports",
"propertyKey": 1,
"metadata": {
"type": "number",
"readable": True,
"writeable": True,
"valueSize": 4,
"min": 0,
"max": 1,
"default": 1,
"format": 0,
"allowManualEntry": True,
"label": "Group 2: Send battery reports",
"description": "Include battery information in periodic reports to Group 2",
"isFromConfig": True,
},
"value": 0,
}
assert args["value"] == 1
client.async_send_command.reset_mock()
# Test setting parameter by property name and state label
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_DEVICE_ID: entity_entry.device_id,
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
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"] == 52
assert args["valueId"] == {
"commandClassName": "Configuration",
"commandClass": 112,
"endpoint": 0,
"property": 41,
"propertyName": "Temperature Threshold (Unit)",
"propertyKey": 15,
"metadata": {
"type": "number",
"readable": True,
"writeable": True,
"valueSize": 3,
"min": 1,
"max": 2,
"default": 1,
"format": 0,
"allowManualEntry": False,
"states": {"1": "Celsius", "2": "Fahrenheit"},
"label": "Temperature Threshold (Unit)",
"isFromConfig": True,
},
"value": 0,
}
assert args["value"] == 2
client.async_send_command.reset_mock()
# Test setting parameter by property and bitmask
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_ENTITY_ID: AIR_TEMPERATURE_SENSOR,
ATTR_CONFIG_PARAMETER: 102,
ATTR_CONFIG_PARAMETER_BITMASK: "0x01",
ATTR_CONFIG_VALUE: 1,
},
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"] == 52
assert args["valueId"] == {
"commandClassName": "Configuration",
"commandClass": 112,
"endpoint": 0,
"property": 102,
"propertyName": "Group 2: Send battery reports",
"propertyKey": 1,
"metadata": {
"type": "number",
"readable": True,
"writeable": True,
"valueSize": 4,
"min": 0,
"max": 1,
"default": 1,
"format": 0,
"allowManualEntry": True,
"label": "Group 2: Send battery reports",
"description": "Include battery information in periodic reports to Group 2",
"isFromConfig": True,
},
"value": 0,
}
assert args["value"] == 1
# Test that an invalid entity ID raises a ValueError
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_ENTITY_ID: "sensor.fake_entity",
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)
# Test that an invalid device ID raises a ValueError
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_DEVICE_ID: "fake_device_id",
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)
# Test that we can't include a bitmask value if parameter is a string
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_DEVICE_ID: entity_entry.device_id,
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_PARAMETER_BITMASK: 1,
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)
non_zwave_js_config_entry = MockConfigEntry(entry_id="fake_entry_id")
non_zwave_js_config_entry.add_to_hass(hass)
non_zwave_js_device = dev_reg.async_get_or_create(
config_entry_id=non_zwave_js_config_entry.entry_id,
identifiers={("test", "test")},
)
# Test that a non Z-Wave JS device raises a ValueError
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_DEVICE_ID: non_zwave_js_device.id,
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)
zwave_js_device_with_invalid_node_id = dev_reg.async_get_or_create(
config_entry_id=integration.entry_id, identifiers={(DOMAIN, "500-500")}
)
# Test that a Z-Wave JS device with an invalid node ID raises a ValueError
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_DEVICE_ID: zwave_js_device_with_invalid_node_id.id,
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)
non_zwave_js_entity = ent_reg.async_get_or_create(
"test",
"sensor",
"test_sensor",
suggested_object_id="test_sensor",
config_entry=non_zwave_js_config_entry,
)
# Test that a non Z-Wave JS entity raises a ValueError
with pytest.raises(ValueError):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG_PARAMETER,
{
ATTR_ENTITY_ID: non_zwave_js_entity.entity_id,
ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)",
ATTR_CONFIG_VALUE: "Fahrenheit",
},
blocking=True,
)