diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 593d5ea4151..667d7a9de24 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -13,14 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -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 homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from .const import ( @@ -79,7 +72,7 @@ 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, dev_reg: DeviceRegistry | None = None + hass: HomeAssistant, device_id: str, dev_reg: dr.DeviceRegistry | None = None ) -> ZwaveNode: """ Get node from a device ID. @@ -87,7 +80,7 @@ def async_get_node_from_device_id( Raises ValueError if device is invalid or node can't be found. """ if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device_entry = dev_reg.async_get(device_id) if not device_entry: @@ -138,8 +131,8 @@ def async_get_node_from_device_id( def async_get_node_from_entity_id( hass: HomeAssistant, entity_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> ZwaveNode: """ Get node from an entity ID. @@ -147,7 +140,7 @@ def async_get_node_from_entity_id( Raises ValueError if entity is invalid. """ if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) entity_entry = ent_reg.async_get(entity_id) if entity_entry is None or entity_entry.platform != DOMAIN: @@ -159,6 +152,46 @@ def async_get_node_from_entity_id( return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg) +@callback +def async_get_nodes_from_area_id( + hass: HomeAssistant, + area_id: str, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, +) -> set[ZwaveNode]: + """Get nodes for all Z-Wave JS devices and entities that are in an area.""" + nodes: set[ZwaveNode] = set() + if ent_reg is None: + ent_reg = er.async_get(hass) + if dev_reg is None: + dev_reg = dr.async_get(hass) + # Add devices for all entities in an area that are Z-Wave JS entities + nodes.update( + { + async_get_node_from_device_id(hass, entity.device_id, dev_reg) + for entity in er.async_entries_for_area(ent_reg, area_id) + if entity.platform == DOMAIN and entity.device_id is not None + } + ) + # Add devices in an area that are Z-Wave JS devices + for device in dr.async_entries_for_area(dev_reg, area_id): + if next( + ( + config_entry_id + for config_entry_id in device.config_entries + if cast( + ConfigEntry, + hass.config_entries.async_get_entry(config_entry_id), + ).domain + == DOMAIN + ), + None, + ): + nodes.add(async_get_node_from_device_id(hass, device.id, dev_reg)) + + return nodes + + def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveValue: """Get a Z-Wave JS Value from a config.""" endpoint = None @@ -183,14 +216,14 @@ def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveVal def async_get_node_status_sensor_entity_id( hass: HomeAssistant, device_id: str, - ent_reg: EntityRegistry | None = None, - dev_reg: DeviceRegistry | None = None, + ent_reg: er.EntityRegistry | None = None, + dev_reg: dr.DeviceRegistry | None = None, ) -> str: """Get the node status sensor entity ID for a given Z-Wave JS device.""" if not ent_reg: - ent_reg = async_get_ent_reg(hass) + ent_reg = er.async_get(hass) if not dev_reg: - dev_reg = async_get_dev_reg(hass) + dev_reg = dr.async_get(hass) device = dev_reg.async_get(device_id) if not device: raise HomeAssistantError("Invalid Device ID provided") diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index a24f8461873..431f88a875d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -18,15 +18,18 @@ from zwave_js_server.util.node import ( ) from homeassistant.components.group import expand_entity_ids -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er 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 from . import const -from .helpers import async_get_node_from_device_id, async_get_node_from_entity_id +from .helpers import ( + async_get_node_from_device_id, + async_get_node_from_entity_id, + async_get_nodes_from_area_id, +) _LOGGER = logging.getLogger(__name__) @@ -81,7 +84,10 @@ class ZWaveServices: """Class that holds our services (Zwave Commands) that should be published to hass.""" def __init__( - self, hass: HomeAssistant, ent_reg: EntityRegistry, dev_reg: DeviceRegistry + self, + hass: HomeAssistant, + ent_reg: er.EntityRegistry, + dev_reg: dr.DeviceRegistry, ) -> None: """Initialize with hass object.""" self._hass = hass @@ -96,6 +102,7 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" nodes: set[ZwaveNode] = set() + # Convert all entity IDs to nodes for entity_id in expand_entity_ids(self._hass, val.pop(ATTR_ENTITY_ID, [])): try: nodes.add( @@ -105,6 +112,16 @@ class ZWaveServices: ) except ValueError as err: const.LOGGER.warning(err.args[0]) + + # Convert all area IDs to nodes + for area_id in val.pop(ATTR_AREA_ID, []): + nodes.update( + async_get_nodes_from_area_id( + self._hass, area_id, self._ent_reg, self._dev_reg + ) + ) + + # Convert all device IDs to nodes for device_id in val.pop(ATTR_DEVICE_ID, []): try: nodes.add( @@ -170,6 +187,9 @@ class ZWaveServices: 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] ), @@ -184,7 +204,9 @@ class ZWaveServices: vol.Coerce(int), BITMASK_SCHEMA, cv.string ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), parameter_name_does_not_need_bitmask, get_nodes_from_service_data, ), @@ -198,6 +220,9 @@ class ZWaveServices: 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] ), @@ -212,7 +237,9 @@ class ZWaveServices: }, ), }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -242,6 +269,9 @@ class ZWaveServices: 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] ), @@ -258,7 +288,9 @@ class ZWaveServices: vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), @@ -271,6 +303,9 @@ class ZWaveServices: 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] ), @@ -288,7 +323,9 @@ class ZWaveServices: vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, vol.Any( - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), broadcast_command, ), get_nodes_from_service_data, @@ -304,12 +341,17 @@ class ZWaveServices: 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, }, - cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), + cv.has_at_least_one_key( + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_AREA_ID + ), get_nodes_from_service_data, ), ), diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 275a2dbb403..0831d08b216 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -26,7 +26,9 @@ from homeassistant.components.zwave_js.const import ( SERVICE_SET_CONFIG_PARAMETER, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +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.helpers.area_registry import async_get as async_get_area_reg from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, @@ -226,6 +228,52 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + ent_reg.async_update_entity(entity_entry.entity_id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", + ATTR_CONFIG_VALUE: "Fahrenheit", + }, + blocking=True, + ) + + 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"] == "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_no_wait.reset_mock() + # Test setting parameter by property and bitmask await hass.services.async_call( DOMAIN, @@ -478,6 +526,33 @@ async def test_bulk_set_config_parameters(hass, client, multisensor_6, integrati client.async_send_command_no_wait.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, + { + ATTR_AREA_ID: area.id, + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_VALUE: 241, + }, + blocking=True, + ) + + 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"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "property": 102, + } + assert args["value"] == 241 + + client.async_send_command_no_wait.reset_mock() + await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -808,6 +883,47 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): client.async_send_command.reset_mock() + # Test using area ID + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 117, + ATTR_PROPERTY: "local", + ATTR_VALUE: "0x2", + ATTR_WAIT_FOR_RESULT: 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"] == 5 + assert args["valueId"] == { + "commandClassName": "Protection", + "commandClass": 117, + "endpoint": 0, + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Local protection state", + "states": {"0": "Unprotected", "2": "NoOperationPossible"}, + }, + "value": 0, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test groups get expanded assert await async_setup_component(hass, "group", {}) await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) @@ -888,6 +1004,8 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): } assert args["value"] == 2 + client.async_send_command.reset_mock() + # Test missing device and entities keys with pytest.raises(vol.MultipleInvalid): await hass.services.async_call( @@ -1017,6 +1135,49 @@ async def test_multicast_set_value( client.async_send_command.reset_mock() + # Test using area ID + dev_reg = async_get_dev_reg(hass) + device_eurotronic = dev_reg.async_get_device( + {get_device_id(client, climate_eurotronic_spirit_z)} + ) + assert device_eurotronic + device_danfoss = dev_reg.async_get_device( + {get_device_id(client, climate_danfoss_lc_13)} + ) + assert device_danfoss + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_eurotronic.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_AREA_ID: area.id, + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: "0x2", + }, + 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_eurotronic_spirit_z.node_id, + climate_danfoss_lc_13.node_id, + ] + assert args["valueId"] == { + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 2 + + client.async_send_command.reset_mock() + # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( @@ -1228,6 +1389,16 @@ async def test_ping( integration, ): """Test ping 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 + client.async_send_command.return_value = {"responded": True} # Test successful ping call @@ -1243,7 +1414,57 @@ async def test_ping( blocking=True, ) - # assert client.async_send_command.call_args_list is None + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with devices + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_DEVICE_ID: [ + device_radio_thermostat.id, + device_danfoss.id, + ], + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.ping" + assert ( + args["nodeId"] + == climate_radio_thermostat_ct100_plus_different_endpoints.node_id + ) + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.ping" + assert args["nodeId"] == climate_danfoss_lc_13.node_id + + client.async_send_command.reset_mock() + + # Test successful ping call with area + area_reg = async_get_area_reg(hass) + area = area_reg.async_get_or_create("test") + dev_reg.async_update_device(device_radio_thermostat.id, area_id=area.id) + dev_reg.async_update_device(device_danfoss.id, area_id=area.id) + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + {ATTR_AREA_ID: area.id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 2 args = client.async_send_command.call_args_list[0][0][0] assert args["command"] == "node.ping"