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, ZWAVE_JS_EVENT,
) )
from .discovery import async_discover_values 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__) LOGGER = logging.getLogger(__package__)
CONNECT_TIMEOUT = 10 CONNECT_TIMEOUT = 10
@ -192,6 +193,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DATA_UNSUBSCRIBE: unsubscribe_callbacks, DATA_UNSUBSCRIBE: unsubscribe_callbacks,
} }
services = ZWaveServices(hass)
services.async_register()
# Set up websocket API # Set up websocket API
async_register_api(hass) async_register_api(hass)

View file

@ -33,4 +33,11 @@ ATTR_PROPERTY_NAME = "property_name"
ATTR_PROPERTY_KEY_NAME = "property_key_name" ATTR_PROPERTY_KEY_NAME = "property_key_name"
ATTR_PARAMETERS = "parameters" 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" ADDON_SLUG = "core_zwave_js"

View file

@ -1,30 +1,23 @@
"""Generic Z-Wave Entity Class.""" """Generic Z-Wave Entity Class."""
import logging 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.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 zwave_js_server.model.value import Value as ZwaveValue, get_value_id
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo from .discovery import ZwaveDiscoveryInfo
from .helpers import get_device_id
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
EVENT_VALUE_UPDATED = "value updated" 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): class ZWaveBaseEntity(Entity):
"""Generic Entity Class for a Z-Wave Device.""" """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 example: 1234
selector: selector:
text: 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.hassio.handler import HassioAPIError
from homeassistant.components.zwave_js.const import DOMAIN 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 ( from homeassistant.config_entries import (
CONN_CLASS_LOCAL_PUSH, CONN_CLASS_LOCAL_PUSH,
ENTRY_STATE_LOADED, 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,
)