Skip to content

Commit 5900413

Browse files
Add zwave_js node_capabilities and invoke_cc_api websocket commands (#125327)
* Add zwave_js node_capabilities and invoke_cc_api websocket commands * Map isSecure to is_secure * Add tests * Add error handling * fix * Use to_dict function * Make response compatible with current expectations --------- Co-authored-by: Martin Hjelmare <[email protected]>
1 parent c2ceab7 commit 5900413

File tree

2 files changed

+246
-1
lines changed

2 files changed

+246
-1
lines changed

homeassistant/components/zwave_js/api.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
ControllerFirmwareUpdateResult,
4444
)
4545
from zwave_js_server.model.driver import Driver
46+
from zwave_js_server.model.endpoint import Endpoint
4647
from zwave_js_server.model.log_config import LogConfig
4748
from zwave_js_server.model.log_message import LogMessage
4849
from zwave_js_server.model.node import Node, NodeStatistics
@@ -75,6 +76,11 @@
7576

7677
from .config_validation import BITMASK_SCHEMA
7778
from .const import (
79+
ATTR_COMMAND_CLASS,
80+
ATTR_ENDPOINT,
81+
ATTR_METHOD_NAME,
82+
ATTR_PARAMETERS,
83+
ATTR_WAIT_FOR_RESULT,
7884
CONF_DATA_COLLECTION_OPTED_IN,
7985
DATA_CLIENT,
8086
EVENT_DEVICE_ADDED_TO_REGISTRY,
@@ -437,6 +443,8 @@ def async_register_api(hass: HomeAssistant) -> None:
437443
)
438444
websocket_api.async_register_command(hass, websocket_subscribe_node_statistics)
439445
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
446+
websocket_api.async_register_command(hass, websocket_node_capabilities)
447+
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
440448
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
441449

442450

@@ -2525,3 +2533,81 @@ def _handle_device_added(device: dr.DeviceEntry) -> None:
25252533
)
25262534
]
25272535
await driver.async_hard_reset()
2536+
2537+
2538+
@websocket_api.websocket_command(
2539+
{
2540+
vol.Required(TYPE): "zwave_js/node_capabilities",
2541+
vol.Required(DEVICE_ID): str,
2542+
}
2543+
)
2544+
@websocket_api.async_response
2545+
@async_handle_failed_command
2546+
@async_get_node
2547+
async def websocket_node_capabilities(
2548+
hass: HomeAssistant,
2549+
connection: ActiveConnection,
2550+
msg: dict[str, Any],
2551+
node: Node,
2552+
) -> None:
2553+
"""Get node endpoints with their support command classes."""
2554+
# consumers expect snake_case at the moment
2555+
# remove that addition when consumers are updated
2556+
connection.send_result(
2557+
msg[ID],
2558+
{
2559+
idx: [
2560+
command_class.to_dict() | {"is_secure": command_class.is_secure}
2561+
for command_class in endpoint.command_classes
2562+
]
2563+
for idx, endpoint in node.endpoints.items()
2564+
},
2565+
)
2566+
2567+
2568+
@websocket_api.require_admin
2569+
@websocket_api.websocket_command(
2570+
{
2571+
vol.Required(TYPE): "zwave_js/invoke_cc_api",
2572+
vol.Required(DEVICE_ID): str,
2573+
vol.Required(ATTR_COMMAND_CLASS): vol.All(
2574+
vol.Coerce(int), vol.Coerce(CommandClass)
2575+
),
2576+
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
2577+
vol.Required(ATTR_METHOD_NAME): cv.string,
2578+
vol.Required(ATTR_PARAMETERS): list,
2579+
vol.Optional(ATTR_WAIT_FOR_RESULT): cv.boolean,
2580+
}
2581+
)
2582+
@websocket_api.async_response
2583+
@async_handle_failed_command
2584+
@async_get_node
2585+
async def websocket_invoke_cc_api(
2586+
hass: HomeAssistant,
2587+
connection: ActiveConnection,
2588+
msg: dict[str, Any],
2589+
node: Node,
2590+
) -> None:
2591+
"""Call invokeCCAPI on the node or provided endpoint."""
2592+
command_class: CommandClass = msg[ATTR_COMMAND_CLASS]
2593+
method_name: str = msg[ATTR_METHOD_NAME]
2594+
parameters: list[Any] = msg[ATTR_PARAMETERS]
2595+
2596+
node_or_endpoint: Node | Endpoint = node
2597+
if (endpoint := msg.get(ATTR_ENDPOINT)) is not None:
2598+
node_or_endpoint = node.endpoints[endpoint]
2599+
2600+
try:
2601+
result = await node_or_endpoint.async_invoke_cc_api(
2602+
command_class,
2603+
method_name,
2604+
*parameters,
2605+
wait_for_result=msg.get(ATTR_WAIT_FOR_RESULT, False),
2606+
)
2607+
except BaseZwaveJSServerError as err:
2608+
connection.send_error(msg[ID], err.__class__.__name__, str(err))
2609+
else:
2610+
connection.send_result(
2611+
msg[ID],
2612+
result,
2613+
)

tests/components/zwave_js/test_api.py

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,19 @@
8181
VERSION,
8282
)
8383
from homeassistant.components.zwave_js.const import (
84+
ATTR_COMMAND_CLASS,
85+
ATTR_ENDPOINT,
86+
ATTR_METHOD_NAME,
87+
ATTR_PARAMETERS,
88+
ATTR_WAIT_FOR_RESULT,
8489
CONF_DATA_COLLECTION_OPTED_IN,
8590
DOMAIN,
8691
)
8792
from homeassistant.components.zwave_js.helpers import get_device_id
8893
from homeassistant.core import HomeAssistant
8994
from homeassistant.helpers import device_registry as dr
9095

91-
from tests.common import MockUser
96+
from tests.common import MockConfigEntry, MockUser
9297
from tests.typing import ClientSessionGenerator, WebSocketGenerator
9398

9499
CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller"
@@ -4828,3 +4833,157 @@ async def test_hard_reset_controller(
48284833

48294834
assert not msg["success"]
48304835
assert msg["error"]["code"] == ERR_NOT_FOUND
4836+
4837+
4838+
async def test_node_capabilities(
4839+
hass: HomeAssistant,
4840+
multisensor_6: Node,
4841+
integration: MockConfigEntry,
4842+
hass_ws_client: WebSocketGenerator,
4843+
) -> None:
4844+
"""Test the node_capabilities websocket command."""
4845+
entry = integration
4846+
ws_client = await hass_ws_client(hass)
4847+
4848+
node = multisensor_6
4849+
device = get_device(hass, node)
4850+
await ws_client.send_json_auto_id(
4851+
{
4852+
TYPE: "zwave_js/node_capabilities",
4853+
DEVICE_ID: device.id,
4854+
}
4855+
)
4856+
msg = await ws_client.receive_json()
4857+
assert msg["result"] == {
4858+
"0": [
4859+
{
4860+
"id": 113,
4861+
"name": "Notification",
4862+
"version": 8,
4863+
"isSecure": False,
4864+
"is_secure": False,
4865+
}
4866+
]
4867+
}
4868+
4869+
# Test getting non-existent node fails
4870+
await ws_client.send_json_auto_id(
4871+
{
4872+
TYPE: "zwave_js/node_status",
4873+
DEVICE_ID: "fake_device",
4874+
}
4875+
)
4876+
msg = await ws_client.receive_json()
4877+
assert not msg["success"]
4878+
assert msg["error"]["code"] == ERR_NOT_FOUND
4879+
4880+
# Test sending command with not loaded entry fails
4881+
await hass.config_entries.async_unload(entry.entry_id)
4882+
await hass.async_block_till_done()
4883+
4884+
await ws_client.send_json_auto_id(
4885+
{
4886+
TYPE: "zwave_js/node_status",
4887+
DEVICE_ID: device.id,
4888+
}
4889+
)
4890+
msg = await ws_client.receive_json()
4891+
4892+
assert not msg["success"]
4893+
assert msg["error"]["code"] == ERR_NOT_LOADED
4894+
4895+
4896+
async def test_invoke_cc_api(
4897+
hass: HomeAssistant,
4898+
client,
4899+
climate_radio_thermostat_ct100_plus_different_endpoints: Node,
4900+
integration: MockConfigEntry,
4901+
hass_ws_client: WebSocketGenerator,
4902+
) -> None:
4903+
"""Test the invoke_cc_api websocket command."""
4904+
ws_client = await hass_ws_client(hass)
4905+
4906+
device_radio_thermostat = get_device(
4907+
hass, climate_radio_thermostat_ct100_plus_different_endpoints
4908+
)
4909+
assert device_radio_thermostat
4910+
4911+
# Test successful invoke_cc_api call with a static endpoint
4912+
client.async_send_command.return_value = {"response": True}
4913+
client.async_send_command_no_wait.return_value = {"response": True}
4914+
4915+
# Test with wait_for_result=False (default)
4916+
await ws_client.send_json_auto_id(
4917+
{
4918+
TYPE: "zwave_js/invoke_cc_api",
4919+
DEVICE_ID: device_radio_thermostat.id,
4920+
ATTR_COMMAND_CLASS: 67,
4921+
ATTR_METHOD_NAME: "someMethod",
4922+
ATTR_PARAMETERS: [1, 2],
4923+
}
4924+
)
4925+
msg = await ws_client.receive_json()
4926+
assert msg["success"]
4927+
assert msg["result"] is None # We did not specify wait_for_result=True
4928+
4929+
await hass.async_block_till_done()
4930+
4931+
assert len(client.async_send_command_no_wait.call_args_list) == 1
4932+
args = client.async_send_command_no_wait.call_args[0][0]
4933+
assert args == {
4934+
"command": "endpoint.invoke_cc_api",
4935+
"nodeId": 26,
4936+
"endpoint": 0,
4937+
"commandClass": 67,
4938+
"methodName": "someMethod",
4939+
"args": [1, 2],
4940+
}
4941+
4942+
client.async_send_command_no_wait.reset_mock()
4943+
4944+
# Test with wait_for_result=True
4945+
await ws_client.send_json_auto_id(
4946+
{
4947+
TYPE: "zwave_js/invoke_cc_api",
4948+
DEVICE_ID: device_radio_thermostat.id,
4949+
ATTR_COMMAND_CLASS: 67,
4950+
ATTR_ENDPOINT: 0,
4951+
ATTR_METHOD_NAME: "someMethod",
4952+
ATTR_PARAMETERS: [1, 2],
4953+
ATTR_WAIT_FOR_RESULT: True,
4954+
}
4955+
)
4956+
msg = await ws_client.receive_json()
4957+
assert msg["success"]
4958+
assert msg["result"] is True
4959+
4960+
await hass.async_block_till_done()
4961+
4962+
assert len(client.async_send_command.call_args_list) == 1
4963+
args = client.async_send_command.call_args[0][0]
4964+
assert args == {
4965+
"command": "endpoint.invoke_cc_api",
4966+
"nodeId": 26,
4967+
"endpoint": 0,
4968+
"commandClass": 67,
4969+
"methodName": "someMethod",
4970+
"args": [1, 2],
4971+
}
4972+
4973+
client.async_send_command.side_effect = NotFoundError
4974+
4975+
# Ensure an error is returned
4976+
await ws_client.send_json_auto_id(
4977+
{
4978+
TYPE: "zwave_js/invoke_cc_api",
4979+
DEVICE_ID: device_radio_thermostat.id,
4980+
ATTR_COMMAND_CLASS: 67,
4981+
ATTR_ENDPOINT: 0,
4982+
ATTR_METHOD_NAME: "someMethod",
4983+
ATTR_PARAMETERS: [1, 2],
4984+
ATTR_WAIT_FOR_RESULT: True,
4985+
}
4986+
)
4987+
msg = await ws_client.receive_json()
4988+
assert not msg["success"]
4989+
assert msg["error"] == {"code": "NotFoundError", "message": ""}

0 commit comments

Comments
 (0)