From 152dbfd2fe7e1e0562000c9f703b67810028a837 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 14 Feb 2022 15:38:22 -0500 Subject: [PATCH] Add button entity to ping zwave_js node (#66129) * Add button entity to ping zwave_js node * Fix docstring * Fix docstrings * Fix and simplify tests * Fix name * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/services.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * review comments * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/button.py Co-authored-by: Martin Hjelmare * Remove self.client line * Add callback to remove entity * Add extra dispatch signal on replacement * Combine signals for valueless entities Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 41 +++++------ homeassistant/components/zwave_js/button.py | 73 +++++++++++++++++++ homeassistant/components/zwave_js/helpers.py | 5 ++ homeassistant/components/zwave_js/sensor.py | 9 +-- homeassistant/components/zwave_js/services.py | 7 +- tests/components/zwave_js/conftest.py | 12 --- tests/components/zwave_js/test_button.py | 33 +++++++++ tests/components/zwave_js/test_init.py | 12 +-- 8 files changed, 147 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/zwave_js/button.py create mode 100644 tests/components/zwave_js/test_button.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 7fb785d429e..0e1c6445a9f 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -15,6 +15,7 @@ from zwave_js_server.model.notification import ( ) from zwave_js_server.model.value import Value, ValueNotification +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -93,6 +94,7 @@ from .helpers import ( get_device_id, get_device_id_ext, get_unique_id, + get_valueless_base_unique_id, ) from .migrate import async_migrate_discovered_value from .services import ZWaveServices @@ -171,11 +173,19 @@ async def async_setup_entry( # noqa: C901 entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) entry_hass_data[DATA_CLIENT] = client - entry_hass_data[DATA_PLATFORM_SETUP] = {} + platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] = {} registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) discovered_value_ids: dict[str, set[str]] = defaultdict(set) + async def async_setup_platform(platform: str) -> None: + """Set up platform if needed.""" + if platform not in platform_setup_tasks: + platform_setup_tasks[platform] = hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + await platform_setup_tasks[platform] + @callback def remove_device(device: device_registry.DeviceEntry) -> None: """Remove device from registry.""" @@ -202,13 +212,8 @@ async def async_setup_entry( # noqa: C901 disc_info, ) - platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] platform = disc_info.platform - if platform not in platform_setup_tasks: - platform_setup_tasks[platform] = hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) - await platform_setup_tasks[platform] + await async_setup_platform(platform) LOGGER.debug("Discovered entity: %s", disc_info) async_dispatcher_send( @@ -256,6 +261,12 @@ async def async_setup_entry( # noqa: C901 ) ) + # Create a ping button for each device + await async_setup_platform(BUTTON_DOMAIN) + async_dispatcher_send( + hass, f"{DOMAIN}_{entry.entry_id}_add_ping_button_entity", node + ) + # add listeners to handle new values that get added later for event in ("value added", "value updated", "metadata updated"): entry.async_on_unload( @@ -284,19 +295,7 @@ async def async_setup_entry( # noqa: C901 async def async_on_node_added(node: ZwaveNode) -> None: """Handle node added event.""" - platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP] - - # We need to set up the sensor platform if it hasn't already been setup in - # order to create the node status sensor - if SENSOR_DOMAIN not in platform_setup_tasks: - platform_setup_tasks[SENSOR_DOMAIN] = hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN) - ) - - # This guard ensures that concurrent runs of this function all await the - # platform setup task - if not platform_setup_tasks[SENSOR_DOMAIN].done(): - await platform_setup_tasks[SENSOR_DOMAIN] + await async_setup_platform(SENSOR_DOMAIN) # Create a node status sensor for each device async_dispatcher_send( @@ -358,7 +357,7 @@ async def async_setup_entry( # noqa: C901 async_dispatcher_send( hass, - f"{DOMAIN}_{client.driver.controller.home_id}.{node.node_id}.node_status_remove_entity", + f"{DOMAIN}_{get_valueless_base_unique_id(client, node)}_remove_entity", ) else: remove_device(device) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py new file mode 100644 index 00000000000..ef8572fedc3 --- /dev/null +++ b/homeassistant/components/zwave_js/button.py @@ -0,0 +1,73 @@ +"""Representation of Z-Wave buttons.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.node import Node as ZwaveNode + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_CLIENT, DOMAIN +from .helpers import get_device_id, get_valueless_base_unique_id + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave button from config entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_ping_button_entity(node: ZwaveNode) -> None: + """Add ping button entity.""" + async_add_entities([ZWaveNodePingButton(client, node)]) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_ping_button_entity", + async_add_ping_button_entity, + ) + ) + + +class ZWaveNodePingButton(ButtonEntity): + """Representation of a ping button entity.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, client: ZwaveClient, node: ZwaveNode) -> None: + """Initialize a ping Z-Wave device button entity.""" + self.node = node + name: str = ( + node.name or node.device_config.description or f"Node {node.node_id}" + ) + # Entity class attributes + self._attr_name = f"{name}: Ping" + self._base_unique_id = get_valueless_base_unique_id(client, node) + self._attr_unique_id = f"{self._base_unique_id}.ping" + # device is precreated in main handler + self._attr_device_info = DeviceInfo( + identifiers={get_device_id(client, node)}, + ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + + async def async_press(self) -> None: + """Press the button.""" + self.hass.async_create_task(self.node.async_ping()) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 3deb75cf761..2eb440cec90 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -55,6 +55,11 @@ def update_data_collection_preference( @callback +def get_valueless_base_unique_id(client: ZwaveClient, node: ZwaveNode) -> str: + """Return the base unique ID for an entity that is not based on a value.""" + return f"{client.driver.controller.home_id}.{node.node_id}" + + def get_unique_id(client: ZwaveClient, value_id: str) -> str: """Get unique ID from client and value ID.""" return f"{client.driver.controller.home_id}.{value_id}" diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 76cb6fd22e9..840c36b7fde 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -61,7 +61,7 @@ from .discovery_data_template import ( NumericSensorDataTemplateData, ) from .entity import ZWaveBaseEntity -from .helpers import get_device_id +from .helpers import get_device_id, get_valueless_base_unique_id LOGGER = logging.getLogger(__name__) @@ -477,9 +477,8 @@ class ZWaveNodeStatusSensor(SensorEntity): ) # Entity class attributes self._attr_name = f"{name}: Node Status" - self._attr_unique_id = ( - f"{self.client.driver.controller.home_id}.{node.node_id}.node_status" - ) + self._base_unique_id = get_valueless_base_unique_id(client, node) + self._attr_unique_id = f"{self._base_unique_id}.node_status" # device is precreated in main handler self._attr_device_info = DeviceInfo( identifiers={get_device_id(self.client, self.node)}, @@ -517,7 +516,7 @@ class ZWaveNodeStatusSensor(SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{DOMAIN}_{self.unique_id}_remove_entity", + f"{DOMAIN}_{self._base_unique_id}_remove_entity", self.async_remove, ) ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index ac3f233ba49..767516cc17c 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -457,7 +457,7 @@ class ZWaveServices: options = service.data.get(const.ATTR_OPTIONS) if not broadcast and len(nodes) == 1: - const.LOGGER.warning( + const.LOGGER.info( "Passing the zwave_js.multicast_set_value service call to the " "zwave_js.set_value service since only one node was targeted" ) @@ -520,5 +520,10 @@ class ZWaveServices: async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" # pylint: disable=no-self-use + const.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)) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 4f21f616ae1..d8fef11269c 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -737,18 +737,6 @@ def null_name_check_fixture(client, null_name_check_state): return node -@pytest.fixture(name="multiple_devices") -def multiple_devices_fixture( - client, climate_radio_thermostat_ct100_plus_state, lock_schlage_be469_state -): - """Mock a client with multiple devices.""" - node = Node(client, copy.deepcopy(climate_radio_thermostat_ct100_plus_state)) - client.driver.controller.nodes[node.node_id] = node - node = Node(client, copy.deepcopy(lock_schlage_be469_state)) - client.driver.controller.nodes[node.node_id] = node - return client.driver.controller.nodes - - @pytest.fixture(name="gdc_zw062") def motorized_barrier_cover_fixture(client, gdc_zw062_state): """Mock a motorized barrier node.""" diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py new file mode 100644 index 00000000000..deb95e5eef4 --- /dev/null +++ b/tests/components/zwave_js/test_button.py @@ -0,0 +1,33 @@ +"""Test the Z-Wave JS button entities.""" +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID + + +async def test_ping_entity( + hass, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + integration, +): + """Test ping entity.""" + client.async_send_command.return_value = {"responded": True} + + # Test successful ping call + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.z_wave_thermostat_ping", + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + 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 + ) + + client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 7e39b784533..d08c680dfe2 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -788,10 +788,10 @@ async def test_remove_entry( assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text -async def test_removed_device(hass, client, multiple_devices, integration): +async def test_removed_device( + hass, client, climate_radio_thermostat_ct100_plus, lock_schlage_be469, integration +): """Test that the device registry gets updated when a device gets removed.""" - nodes = multiple_devices - # Verify how many nodes are available assert len(client.driver.controller.nodes) == 2 @@ -803,10 +803,10 @@ async def test_removed_device(hass, client, multiple_devices, integration): # Check how many entities there are ent_reg = er.async_get(hass) entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 26 + assert len(entity_entries) == 28 # Remove a node and reload the entry - old_node = nodes.pop(13) + old_node = client.driver.controller.nodes.pop(13) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -815,7 +815,7 @@ async def test_removed_device(hass, client, multiple_devices, integration): device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 1 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 16 + assert len(entity_entries) == 17 assert dev_reg.async_get_device({get_device_id(client, old_node)}) is None