From 24b090a038c540e32b000deab032b2a46886487d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 26 Apr 2022 11:30:49 -0400 Subject: [PATCH] Create zwave_js.invoke_cc_api service (#70466) --- homeassistant/components/zwave_js/const.py | 12 +- .../components/zwave_js/diagnostics.py | 36 ++-- homeassistant/components/zwave_js/helpers.py | 34 +++- homeassistant/components/zwave_js/services.py | 144 +++++++++++++-- .../components/zwave_js/services.yaml | 36 ++++ tests/components/zwave_js/test_services.py | 171 +++++++++++++++++- 6 files changed, 391 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index d6d63487b8a..e2b339e6d79 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -70,15 +70,16 @@ ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PARTIAL_DICT_MATCH = "partial_dict_match" # service constants -SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS = "bulk_set_partial_config_parameters" SERVICE_CLEAR_LOCK_USERCODE = "clear_lock_usercode" -SERVICE_SET_VALUE = "set_value" -SERVICE_RESET_METER = "reset_meter" +SERVICE_INVOKE_CC_API = "invoke_cc_api" SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" SERVICE_PING = "ping" SERVICE_REFRESH_VALUE = "refresh_value" +SERVICE_RESET_METER = "reset_meter" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" -SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS = "bulk_set_partial_config_parameters" +SERVICE_SET_LOCK_USERCODE = "set_lock_usercode" +SERVICE_SET_VALUE = "set_value" ATTR_NODES = "nodes" # config parameter @@ -92,6 +93,9 @@ ATTR_BROADCAST = "broadcast" # meter reset ATTR_METER_TYPE = "meter_type" ATTR_METER_TYPE_NAME = "meter_type_name" +# invoke CC API +ATTR_METHOD_NAME = "method_name" +ATTR_PARAMETERS = "parameters" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index b38d8e3ca13..dfb6661b5c0 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -15,13 +15,16 @@ from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity_registry import async_entries_for_device, async_get from .const import DATA_CLIENT, DOMAIN -from .helpers import ZwaveValueID, get_home_and_node_id_from_device_entry +from .helpers import ( + ZwaveValueID, + get_home_and_node_id_from_device_entry, + get_state_key_from_unique_id, + get_value_id_from_unique_id, +) KEYS_TO_REDACT = {"homeId", "location"} @@ -61,28 +64,19 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: def get_device_entities( - hass: HomeAssistant, node: Node, device: DeviceEntry + hass: HomeAssistant, node: Node, device: dr.DeviceEntry ) -> list[dict[str, Any]]: """Get entities for a device.""" - entity_entries = async_entries_for_device( - async_get(hass), device.id, include_disabled_entities=True + entity_entries = er.async_entries_for_device( + er.async_get(hass), device.id, include_disabled_entities=True ) entities = [] for entry in entity_entries: - state_key = None - split_unique_id = entry.unique_id.split(".") - # If the unique ID has three parts, it's either one of the generic per node - # entities (node status sensor, ping button) or a binary sensor for a particular - # state. If we can get the state key, we will add it to the dictionary. - if len(split_unique_id) == 3: - try: - state_key = int(split_unique_id[-1]) - # If the third part of the unique ID isn't a state key, the entity must be a - # generic entity. We won't add those since they won't help with - # troubleshooting. - except ValueError: - continue - value_id = split_unique_id[1] + # If the value ID returns as None, we don't need to include this entity + if (value_id := get_value_id_from_unique_id(entry.unique_id)) is None: + continue + state_key = get_state_key_from_unique_id(entry.unique_id) + zwave_value = node.values[value_id] primary_value_data = { "command_class": zwave_value.command_class, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 35f278d4571..a5a2da23206 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import astuple, dataclass +import logging from typing import Any, cast import voluptuous as vol @@ -57,6 +58,34 @@ class ZwaveValueID: raise ValueError("At least one of the fields must be set.") +@callback +def get_value_id_from_unique_id(unique_id: str) -> str | None: + """ + Get the value ID and optional state key from a unique ID. + + Raises ValueError + """ + split_unique_id = unique_id.split(".") + # If the unique ID contains a `-` in its second part, the unique ID contains + # a value ID and we can return it. + if "-" in (value_id := split_unique_id[1]): + return value_id + return None + + +@callback +def get_state_key_from_unique_id(unique_id: str) -> int | None: + """Get the state key from a unique ID.""" + # If the unique ID has more than two parts, it's a special unique ID. If the last + # part of the unique ID is an int, then it's a state key and we return it. + if len(split_unique_id := unique_id.split(".")) > 2: + try: + return int(split_unique_id[-1]) + except ValueError: + pass + return None + + @callback def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None: """Return the value of a ZwaveValue.""" @@ -251,6 +280,7 @@ def async_get_nodes_from_targets( val: dict[str, Any], ent_reg: er.EntityRegistry | None = None, dev_reg: dr.DeviceRegistry | None = None, + logger: logging.Logger = LOGGER, ) -> set[ZwaveNode]: """ Get nodes for all targets. @@ -263,7 +293,7 @@ def async_get_nodes_from_targets( try: nodes.add(async_get_node_from_entity_id(hass, entity_id, ent_reg, dev_reg)) except ValueError as err: - LOGGER.warning(err.args[0]) + logger.warning(err.args[0]) # Convert all area IDs to nodes for area_id in val.get(ATTR_AREA_ID, []): @@ -274,7 +304,7 @@ def async_get_nodes_from_targets( try: nodes.add(async_get_node_from_device_id(hass, device_id, dev_reg)) except ValueError as err: - LOGGER.warning(err.args[0]) + logger.warning(err.args[0]) return nodes diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index d4999ffdb0c..70099859d39 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -3,12 +3,13 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import Any, cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandStatus -from zwave_js_server.exceptions import SetValueFailed +from zwave_js_server.const import CommandClass, CommandStatus +from zwave_js_server.exceptions import FailedCommand, SetValueFailed +from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import get_value_id from zwave_js_server.util.multicast import async_multicast_set_value @@ -20,13 +21,20 @@ from zwave_js_server.util.node import ( from homeassistant.components.group import expand_entity_ids from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const from .config_validation import BITMASK_SCHEMA, VALUE_SCHEMA -from .helpers import async_get_nodes_from_targets +from .helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + async_get_nodes_from_area_id, + async_get_nodes_from_targets, + get_value_id_from_unique_id, +) _LOGGER = logging.getLogger(__name__) @@ -78,7 +86,7 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" val[const.ATTR_NODES] = async_get_nodes_from_targets( - self._hass, val, self._ent_reg, self._dev_reg + self._hass, val, self._ent_reg, self._dev_reg, _LOGGER ) return val @@ -132,8 +140,8 @@ class ZWaveServices: for entity_id in val[ATTR_ENTITY_ID]: entry = self._ent_reg.async_get(entity_id) if entry is None or entry.platform != const.DOMAIN: - const.LOGGER.info( - "Entity %s is not a valid %s entity.", entity_id, const.DOMAIN + _LOGGER.info( + "Entity %s is not a valid %s entity", entity_id, const.DOMAIN ) invalid_entities.append(entity_id) @@ -326,6 +334,36 @@ class ZWaveServices: ), ) + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_INVOKE_CC_API, + self.async_invoke_cc_api, + schema=vol.Schema( + vol.All( + { + vol.Optional(ATTR_AREA_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_DEVICE_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(const.ATTR_COMMAND_CLASS): vol.All( + vol.Coerce(int), vol.Coerce(CommandClass) + ), + vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(const.ATTR_METHOD_NAME): cv.string, + vol.Required(const.ATTR_PARAMETERS): list, + }, + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), + get_nodes_from_service_data, + has_at_least_one_node, + ), + ), + ) + async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes = service.data[const.ATTR_NODES] @@ -425,11 +463,11 @@ class ZWaveServices: ) if success is False: - raise SetValueFailed( + raise HomeAssistantError( "Unable to set value, refer to " "https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue " "for possible reasons" - ) + ) from SetValueFailed async def async_multicast_set_value(self, service: ServiceCall) -> None: """Set a value via multicast to multiple nodes.""" @@ -438,7 +476,7 @@ class ZWaveServices: options = service.data.get(const.ATTR_OPTIONS) if not broadcast and len(nodes) == 1: - const.LOGGER.info( + _LOGGER.info( "Passing the zwave_js.multicast_set_value service call to the " "zwave_js.set_value service since only one node was targeted" ) @@ -496,14 +534,96 @@ class ZWaveServices: ) if success is False: - raise SetValueFailed("Unable to set value via multicast") + raise HomeAssistantError( + "Unable to set value via multicast" + ) from SetValueFailed async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" - const.LOGGER.warning( + # pylint: disable=no-self-use + _LOGGER.warning( "This service is deprecated in favor of the ping button entity. Service " "calls will still work for now but the service will be removed in a " "future release" ) nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] await asyncio.gather(*(node.async_ping() for node in nodes)) + + async def async_invoke_cc_api(self, service: ServiceCall) -> None: + """Invoke a command class API.""" + command_class: CommandClass = service.data[const.ATTR_COMMAND_CLASS] + method_name: str = service.data[const.ATTR_METHOD_NAME] + parameters: list[Any] = service.data[const.ATTR_PARAMETERS] + + async def _async_invoke_cc_api(endpoints: set[Endpoint]) -> None: + """Invoke the CC API on a node endpoint.""" + errors: list[str] = [] + for endpoint in endpoints: + _LOGGER.info( + "Invoking %s CC API method %s on endpoint %s", + command_class.name, + method_name, + endpoint, + ) + try: + await endpoint.async_invoke_cc_api( + command_class, method_name, *parameters + ) + except FailedCommand as err: + errors.append(cast(str, err.args[0])) + if errors: + raise HomeAssistantError( + "\n".join([f"{len(errors)} error(s):", *errors]) + ) + + # If an endpoint is provided, we assume the user wants to call the CC API on + # that endpoint for all target nodes + if (endpoint := service.data.get(const.ATTR_ENDPOINT)) is not None: + await _async_invoke_cc_api( + {node.endpoints[endpoint] for node in service.data[const.ATTR_NODES]} + ) + return + + # If no endpoint is provided, we target endpoint 0 for all device and area + # nodes and we target the endpoint of the primary value for all entities + # specified. + endpoints: set[Endpoint] = set() + for area_id in service.data.get(ATTR_AREA_ID, []): + for node in async_get_nodes_from_area_id( + self._hass, area_id, self._ent_reg, self._dev_reg + ): + endpoints.add(node.endpoints[0]) + + for device_id in service.data.get(ATTR_DEVICE_ID, []): + try: + node = async_get_node_from_device_id( + self._hass, device_id, self._dev_reg + ) + except ValueError as err: + _LOGGER.warning(err.args[0]) + continue + endpoints.add(node.endpoints[0]) + + for entity_id in service.data.get(ATTR_ENTITY_ID, []): + if ( + not (entity_entry := self._ent_reg.async_get(entity_id)) + or entity_entry.platform != const.DOMAIN + ): + _LOGGER.warning( + "Skipping entity %s as it is not a valid %s entity", + entity_id, + const.DOMAIN, + ) + continue + node = async_get_node_from_entity_id( + self._hass, entity_id, self._ent_reg, self._dev_reg + ) + if ( + value_id := get_value_id_from_unique_id(entity_entry.unique_id) + ) is None: + _LOGGER.warning("Skipping entity %s as it has no value ID", entity_id) + continue + + endpoints.add(node.endpoints[node.values[value_id].endpoint]) + + await _async_invoke_cc_api(endpoints) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 206af776a61..eccd46745a3 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -252,3 +252,39 @@ reset_meter: required: false selector: text: + +invoke_cc_api: + name: Invoke a Command Class API on a node (Advanced) + description: Allows for calling a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` service and require direct calls to the Command Class API. + target: + entity: + integration: zwave_js + fields: + command_class: + name: Command Class + description: The ID of the command class that you want to issue a command to. + example: 132 + required: true + selector: + text: + endpoint: + name: Endpoint + description: The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted. + example: 1 + required: false + selector: + text: + method_name: + name: Method Name + description: The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods. + example: setInterval + required: true + selector: + text: + parameters: + name: Parameters + description: A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters. + example: [1, 1] + required: true + selector: + object: diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 09189ad9230..67f3320b5f7 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest import voluptuous as vol -from zwave_js_server.exceptions import SetValueFailed +from zwave_js_server.exceptions import FailedZWaveCommand from homeassistant.components.group import Group from homeassistant.components.zwave_js.const import ( @@ -12,7 +12,10 @@ from homeassistant.components.zwave_js.const import ( ATTR_CONFIG_PARAMETER, ATTR_CONFIG_PARAMETER_BITMASK, ATTR_CONFIG_VALUE, + ATTR_ENDPOINT, + ATTR_METHOD_NAME, ATTR_OPTIONS, + ATTR_PARAMETERS, ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_REFRESH_ALL_VALUES, @@ -20,6 +23,7 @@ from homeassistant.components.zwave_js.const import ( ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + SERVICE_INVOKE_CC_API, SERVICE_MULTICAST_SET_VALUE, SERVICE_PING, SERVICE_REFRESH_VALUE, @@ -28,6 +32,7 @@ from homeassistant.components.zwave_js.const import ( ) from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.area_registry import async_get as async_get_area_reg from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, @@ -982,7 +987,7 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): # Test that when a command fails we raise an exception client.async_send_command.return_value = {"success": False} - with pytest.raises(SetValueFailed): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, @@ -1335,7 +1340,7 @@ async def test_multicast_set_value( # Test that when a command fails we raise an exception client.async_send_command.return_value = {"success": False} - with pytest.raises(SetValueFailed): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_MULTICAST_SET_VALUE, @@ -1608,3 +1613,163 @@ async def test_ping( {}, blocking=True, ) + + +async def test_invoke_cc_api( + hass, + client, + climate_danfoss_lc_13, + climate_radio_thermostat_ct100_plus_different_endpoints, + integration, +): + """Test invoke_cc_api service.""" + dev_reg = async_get_dev_reg(hass) + device_radio_thermostat = dev_reg.async_get_device( + {get_device_id(client, climate_radio_thermostat_ct100_plus_different_endpoints)} + ) + assert device_radio_thermostat + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + + # Test successful invoke_cc_api call with a static endpoint + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.return_value = {"response": True} + + await hass.services.async_call( + DOMAIN, + SERVICE_INVOKE_CC_API, + { + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + device_danfoss.id, + ], + ATTR_COMMAND_CLASS: 132, + ATTR_ENDPOINT: 0, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + + # Test successful invoke_cc_api call without an endpoint (include area) + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.return_value = {"response": True} + + await hass.services.async_call( + DOMAIN, + SERVICE_INVOKE_CC_API, + { + ATTR_AREA_ID: area.id, + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + "fake_device_id", + ], + ATTR_ENTITY_ID: [ + "sensor.not_real", + "select.living_connect_z_thermostat_local_protection_state", + "sensor.living_connect_z_thermostat_node_status", + ], + ATTR_COMMAND_CLASS: 132, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock() + + # Test failed invoke_cc_api call on one node + client.async_send_command.return_value = {"response": True} + client.async_send_command_no_wait.side_effect = FailedZWaveCommand( + "test", 12, "test" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_INVOKE_CC_API, + { + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + device_danfoss.id, + ], + ATTR_COMMAND_CLASS: 132, + ATTR_ENDPOINT: 0, + ATTR_METHOD_NAME: "someMethod", + ATTR_PARAMETERS: [1, 2], + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["commandClass"] == 132 + assert args["endpoint"] == 0 + assert args["methodName"] == "someMethod" + assert args["args"] == [1, 2] + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + client.async_send_command_no_wait.reset_mock()