diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 520495d5071..beebe2cc3f8 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -362,7 +362,7 @@ async def async_setup_entry( # noqa: C901 entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = False entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = False - services = ZWaveServices(hass, ent_reg) + services = ZWaveServices(hass, ent_reg, dev_reg) services.async_register() # Set up websocket API diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 629cd222bd4..d7717922d10 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -44,6 +44,8 @@ ATTR_DATA_TYPE = "data_type" ATTR_WAIT_FOR_RESULT = "wait_for_result" # service constants +ATTR_NODES = "nodes" + SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS = "bulk_set_partial_config_parameters" @@ -56,5 +58,8 @@ SERVICE_REFRESH_VALUE = "refresh_value" ATTR_REFRESH_ALL_VALUES = "refresh_all_values" SERVICE_SET_VALUE = "set_value" +SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" + +ATTR_BROADCAST = "broadcast" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index beee7fefa30..81eae0fdc15 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -10,8 +10,14 @@ from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION 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 homeassistant.helpers.device_registry import ( + DeviceRegistry, + async_get as async_get_dev_reg, +) +from homeassistant.helpers.entity_registry import ( + EntityRegistry, + async_get as async_get_ent_reg, +) from .const import CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN @@ -60,13 +66,17 @@ def get_home_and_node_id_from_device_id(device_id: tuple[str, ...]) -> list[str] @callback -def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveNode: +def async_get_node_from_device_id( + hass: HomeAssistant, device_id: str, dev_reg: DeviceRegistry | None = None +) -> 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 dev_reg: + dev_reg = async_get_dev_reg(hass) + device_entry = dev_reg.async_get(device_id) if not device_entry: raise ValueError("Device ID is not valid") @@ -111,21 +121,25 @@ def async_get_node_from_device_id(hass: HomeAssistant, device_id: str) -> ZwaveN @callback -def async_get_node_from_entity_id(hass: HomeAssistant, entity_id: str) -> ZwaveNode: +def async_get_node_from_entity_id( + hass: HomeAssistant, + entity_id: str, + ent_reg: EntityRegistry | None = None, + dev_reg: DeviceRegistry | None = None, +) -> 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 ent_reg: + ent_reg = async_get_ent_reg(hass) + entity_entry = ent_reg.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") + if entity_entry is None or entity_entry.platform != DOMAIN: + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") # 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) + return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index c2ebe965fdd..65184abbd08 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -2,12 +2,15 @@ from __future__ import annotations import logging +from typing import Any 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.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 from zwave_js_server.util.node import ( async_bulk_set_partial_config_parameters, async_set_config_parameter, @@ -16,6 +19,7 @@ from zwave_js_server.util.node import ( 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 homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import EntityRegistry @@ -26,8 +30,8 @@ _LOGGER = logging.getLogger(__name__) def parameter_name_does_not_need_bitmask( - val: dict[str, int | str] -) -> dict[str, int | str]: + val: dict[str, int | str | list[str]] +) -> dict[str, int | str | list[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) @@ -39,6 +43,16 @@ def parameter_name_does_not_need_bitmask( return val +def broadcast_command(val: dict[str, Any]) -> dict[str, Any]: + """Validate that the service call is for a broadcast command.""" + if val.get(const.ATTR_BROADCAST): + return val + raise vol.Invalid( + "Either `broadcast` must be set to True or multiple devices/entities must be " + "specified" + ) + + # 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( @@ -55,14 +69,95 @@ BITMASK_SCHEMA = vol.All( class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" - def __init__(self, hass: HomeAssistant, ent_reg: EntityRegistry) -> None: + def __init__( + self, hass: HomeAssistant, ent_reg: EntityRegistry, dev_reg: DeviceRegistry + ) -> None: """Initialize with hass object.""" self._hass = hass self._ent_reg = ent_reg + self._dev_reg = dev_reg @callback def async_register(self) -> None: """Register all our services.""" + + @callback + def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: + """Get nodes set from service data.""" + nodes: set[ZwaveNode] = set() + try: + if ATTR_ENTITY_ID in val: + nodes |= { + async_get_node_from_entity_id( + self._hass, entity_id, self._ent_reg, self._dev_reg + ) + for entity_id in val[ATTR_ENTITY_ID] + } + val.pop(ATTR_ENTITY_ID) + if ATTR_DEVICE_ID in val: + nodes |= { + async_get_node_from_device_id( + self._hass, device_id, self._dev_reg + ) + for device_id in val[ATTR_DEVICE_ID] + } + val.pop(ATTR_DEVICE_ID) + except ValueError as err: + raise vol.Invalid(err.args[0]) from err + + val[const.ATTR_NODES] = nodes + return val + + @callback + def validate_multicast_nodes(val: dict[str, Any]) -> dict[str, Any]: + """Validate the input nodes for multicast.""" + nodes: set[ZwaveNode] = val[const.ATTR_NODES] + broadcast: bool = val[const.ATTR_BROADCAST] + + # User must specify a node if they are attempting a broadcast and have more + # than one zwave-js network. We know it's a broadcast if the nodes list is + # empty because of schema validation. + if ( + not nodes + and len(self._hass.config_entries.async_entries(const.DOMAIN)) > 1 + ): + raise vol.Invalid( + "You must include at least one entity or device in the service call" + ) + + # When multicasting, user must specify at least two nodes + if not broadcast and len(nodes) < 2: + raise vol.Invalid( + "To set a value on a single node, use the zwave_js.set_value service" + ) + + first_node = next((node for node in nodes), None) + + # If any nodes don't have matching home IDs, we can't run the command because + # we can't multicast across multiple networks + if first_node and any( + node.client.driver.controller.home_id + != first_node.client.driver.controller.home_id + for node in nodes + ): + raise vol.Invalid( + "Multicast commands only work on devices in the same network" + ) + + return val + + @callback + def validate_entities(val: dict[str, Any]) -> dict[str, Any]: + """Validate entities exist and are from the zwave_js platform.""" + 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: + raise vol.Invalid( + f"Entity {entity_id} is not a valid {const.DOMAIN} entity." + ) + + return val + self._hass.services.async_register( const.DOMAIN, const.SERVICE_SET_CONFIG_PARAMETER, @@ -86,6 +181,7 @@ class ZWaveServices: }, cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), parameter_name_does_not_need_bitmask, + get_nodes_from_service_data, ), ), ) @@ -112,6 +208,7 @@ class ZWaveServices: ), }, cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + get_nodes_from_service_data, ), ), ) @@ -121,10 +218,15 @@ class ZWaveServices: const.SERVICE_REFRESH_VALUE, self.async_poll_value, schema=vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(const.ATTR_REFRESH_ALL_VALUES, default=False): bool, - } + vol.All( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional( + const.ATTR_REFRESH_ALL_VALUES, default=False + ): bool, + }, + validate_entities, + ) ), ) @@ -153,23 +255,48 @@ class ZWaveServices: vol.Optional(const.ATTR_WAIT_FOR_RESULT): vol.Coerce(bool), }, cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + get_nodes_from_service_data, + ), + ), + ) + + self._hass.services.async_register( + const.DOMAIN, + const.SERVICE_MULTICAST_SET_VALUE, + self.async_multicast_set_value, + schema=vol.Schema( + vol.All( + { + vol.Optional(ATTR_DEVICE_ID): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_BROADCAST, default=False): cv.boolean, + vol.Required(const.ATTR_COMMAND_CLASS): vol.Coerce(int), + vol.Required(const.ATTR_PROPERTY): vol.Any( + vol.Coerce(int), str + ), + vol.Optional(const.ATTR_PROPERTY_KEY): vol.Any( + vol.Coerce(int), str + ), + vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(const.ATTR_VALUE): vol.Any( + bool, vol.Coerce(int), vol.Coerce(float), cv.string + ), + }, + vol.Any( + cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + broadcast_command, + ), + get_nodes_from_service_data, + validate_multicast_nodes, ), ), ) 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] - } + nodes = service.data[const.ATTR_NODES] 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] @@ -196,17 +323,7 @@ class ZWaveServices: self, service: ServiceCall ) -> None: """Bulk set multiple partial config values 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] - } + nodes = service.data[const.ATTR_NODES] property_ = service.data[const.ATTR_CONFIG_PARAMETER] new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -231,10 +348,7 @@ class ZWaveServices: """Poll value on a node.""" for entity_id in service.data[ATTR_ENTITY_ID]: entry = self._ent_reg.async_get(entity_id) - if entry is None or entry.platform != const.DOMAIN: - raise ValueError( - f"Entity {entity_id} is not a valid {const.DOMAIN} entity." - ) + assert entry # Schema validation would have failed if we can't do this async_dispatcher_send( self._hass, f"{const.DOMAIN}_{entry.unique_id}_poll_value", @@ -243,17 +357,7 @@ class ZWaveServices: async def async_set_value(self, service: ServiceCall) -> None: """Set a 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] - } + nodes = service.data[const.ATTR_NODES] command_class = service.data[const.ATTR_COMMAND_CLASS] property_ = service.data[const.ATTR_PROPERTY] property_key = service.data.get(const.ATTR_PROPERTY_KEY) @@ -280,3 +384,37 @@ class ZWaveServices: "https://zwave-js.github.io/node-zwave-js/#/api/node?id=setvalue " "for possible reasons" ) + + async def async_multicast_set_value(self, service: ServiceCall) -> None: + """Set a value via multicast to multiple nodes.""" + nodes = service.data[const.ATTR_NODES] + broadcast: bool = service.data[const.ATTR_BROADCAST] + + value = { + "commandClass": service.data[const.ATTR_COMMAND_CLASS], + "property": service.data[const.ATTR_PROPERTY], + "propertyKey": service.data.get(const.ATTR_PROPERTY_KEY), + "endpoint": service.data.get(const.ATTR_ENDPOINT), + } + new_value = service.data[const.ATTR_VALUE] + + # If there are no nodes, we can assume there is only one config entry due to + # schema validation and can use that to get the client, otherwise we can just + # get the client from the node. + client: ZwaveClient = None + first_node = next((node for node in nodes), None) + if first_node: + client = first_node.client + else: + entry_id = self._hass.config_entries.async_entries(const.DOMAIN)[0].entry_id + client = self._hass.data[const.DOMAIN][entry_id][const.DATA_CLIENT] + + success = await async_multicast_set_value( + client, + new_value, + {k: v for k, v in value.items() if v is not None}, + None if broadcast else list(nodes), + ) + + if success is False: + raise SetValueFailed("Unable to set value via multicast") diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 84877189298..16be02b7f1b 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -160,3 +160,53 @@ set_value: required: false selector: boolean: + +multicast_set_value: + name: Set a value on multiple Z-Wave devices via multicast (Advanced) + description: Allow for changing any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing. + target: + entity: + integration: zwave_js + fields: + broadcast: + name: Broadcast? + description: Whether command should be broadcast to all devices on the networrk. + example: true + required: false + selector: + boolean: + command_class: + name: Command Class + description: The ID of the command class for the value. + example: 117 + required: true + selector: + text: + endpoint: + name: Endpoint + description: The endpoint for the value. + example: 1 + required: false + selector: + text: + property: + name: Property + description: The ID of the property for the value. + example: currentValue + required: true + selector: + text: + property_key: + name: Property Key + description: The ID of the property key for the value + example: 1 + required: false + selector: + text: + value: + name: Value + description: The new value to set. + example: "ffbb99" + required: true + selector: + object: diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 3c08c49a36f..4f70543d3e2 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -1,9 +1,12 @@ """Test the Z-Wave JS services.""" +from unittest.mock import MagicMock, patch + import pytest import voluptuous as vol from zwave_js_server.exceptions import SetValueFailed from homeassistant.components.zwave_js.const import ( + ATTR_BROADCAST, ATTR_COMMAND_CLASS, ATTR_CONFIG_PARAMETER, ATTR_CONFIG_PARAMETER_BITMASK, @@ -14,6 +17,7 @@ from homeassistant.components.zwave_js.const import ( ATTR_WAIT_FOR_RESULT, DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + SERVICE_MULTICAST_SET_VALUE, SERVICE_REFRESH_VALUE, SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, @@ -212,8 +216,8 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): } assert args["value"] == 1 - # Test that an invalid entity ID raises a ValueError - with pytest.raises(ValueError): + # Test that an invalid entity ID raises a MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -225,8 +229,8 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): blocking=True, ) - # Test that an invalid device ID raises a ValueError - with pytest.raises(ValueError): + # Test that an invalid device ID raises a MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -259,8 +263,8 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): identifiers={("test", "test")}, ) - # Test that a non Z-Wave JS device raises a ValueError - with pytest.raises(ValueError): + # Test that a non Z-Wave JS device raises a MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -276,8 +280,8 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): 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): + # Test that a Z-Wave JS device with an invalid node ID raises a MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -297,8 +301,8 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): config_entry=non_zwave_js_config_entry, ) - # Test that a non Z-Wave JS entity raises a ValueError - with pytest.raises(ValueError): + # Test that a non Z-Wave JS entity raises a MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -530,8 +534,8 @@ async def test_poll_value( ) assert len(client.async_send_command.call_args_list) == 8 - # Test polling against an invalid entity raises ValueError - with pytest.raises(ValueError): + # Test polling against an invalid entity raises MultipleInvalid + with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( DOMAIN, SERVICE_REFRESH_VALUE, @@ -634,3 +638,155 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): }, blocking=True, ) + + +async def test_multicast_set_value( + hass, + client, + climate_danfoss_lc_13, + climate_radio_thermostat_ct100_plus_different_endpoints, + integration, +): + """Test multicast_set_value service.""" + # Test successful multicast call + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: [ + CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_RADIO_THERMOSTAT_ENTITY, + ], + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 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"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 117, + "property": "local", + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test successful broadcast call + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_BROADCAST: True, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 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"] == "broadcast_node.set_value" + assert args["valueId"] == { + "commandClass": 117, + "property": "local", + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + + # Test sending one node without broadcast fails + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + ) + + # Test no device, entity, or broadcast flag raises error + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + ) + + # Test that when a command fails we raise an exception + client.async_send_command.return_value = {"success": False} + + with pytest.raises(SetValueFailed): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: [ + CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_RADIO_THERMOSTAT_ENTITY, + ], + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + ) + + # Create a fake node with a different home ID from a real node and patch it into + # return of helper function to check the validation for two nodes having different + # home IDs + diff_network_node = MagicMock() + diff_network_node.client.driver.controller.home_id.return_value = "diff_home_id" + + with pytest.raises(vol.MultipleInvalid), patch( + "homeassistant.components.zwave_js.services.async_get_node_from_device_id", + return_value=diff_network_node, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: [ + CLIMATE_DANFOSS_LC13_ENTITY, + ], + ATTR_DEVICE_ID: "fake_device_id", + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + ) + + # Test that when there are multiple zwave_js config entries, service will fail + # without devices or entities + new_entry = MockConfigEntry(domain=DOMAIN) + new_entry.add_to_hass(hass) + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_BROADCAST: True, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: 2, + }, + blocking=True, + )