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 <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/services.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/button.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/button.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/button.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* review comments

* Update homeassistant/components/zwave_js/button.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/zwave_js/button.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* 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 <marhje52@gmail.com>
This commit is contained in:
Raman Gupta 2022-02-14 15:38:22 -05:00 committed by GitHub
parent 113c3149c4
commit 152dbfd2fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 147 additions and 45 deletions

View file

@ -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)

View file

@ -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())

View file

@ -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}"

View file

@ -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,
)
)

View file

@ -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))

View file

@ -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."""

View file

@ -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()

View file

@ -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