diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index 2db82d38d62..e743284abdd 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -5,7 +5,7 @@ from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -13,6 +13,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DOMAIN, LOGGER +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 0 @@ -26,6 +28,17 @@ async def async_setup_entry( """Set up Z-Wave button from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + @callback + def async_add_button(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave Button.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [] + if info.platform_hint == "notification idle": + entities.append(ZWaveNotificationIdleButton(config_entry, driver, info)) + + async_add_entities(entities) + @callback def async_add_ping_button_entity(node: ZwaveNode) -> None: """Add ping button entity.""" @@ -41,6 +54,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{BUTTON_DOMAIN}", + async_add_button, + ) + ) + class ZWaveNodePingButton(ButtonEntity): """Representation of a ping button entity.""" @@ -88,3 +109,25 @@ class ZWaveNodePingButton(ButtonEntity): async def async_press(self) -> None: """Press the button.""" self.hass.async_create_task(self.node.async_ping()) + + +class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): + """Button to idle Notification CC values.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveNotificationIdleButton entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = self.generate_name( + include_value_name=True, name_prefix="Idle" + ) + self._attr_unique_id = f"{self._attr_unique_id}.notification_idle" + + async def async_press(self) -> None: + """Press the button.""" + await self.info.node.async_manually_idle_notification_value( + self.info.primary_value + ) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 36295a64558..b3255a76f7e 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -147,6 +147,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_key_name: set[str | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | None = None + # [optional] the value's states map must include ANY of these key/value pairs + any_available_states: set[tuple[int, str]] | None = None @dataclass @@ -897,6 +899,17 @@ DISCOVERY_SCHEMAS = [ type={ValueType.NUMBER}, ), ), + # button + # Notification CC idle + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + hint="notification idle", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + type={ValueType.NUMBER}, + any_available_states={(0, "idle")}, + ), + ), ] @@ -1072,6 +1085,16 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False + # check available states + if ( + schema.any_available_states is not None + and value.metadata.states is not None + and not any( + str(key) in value.metadata.states and value.metadata.states[str(key)] == val + for key, val in schema.any_available_states + ) + ): + return False return True diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 82a12981d4d..9e2c5187218 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -60,3 +60,35 @@ async def test_ping_entity( ) is None ) + + +async def test_notification_idle_button( + hass: HomeAssistant, client, multisensor_6, integration +) -> None: + """Test Notification idle button.""" + node = multisensor_6 + state = hass.states.get("button.multisensor_6_idle_cover_status") + assert state + assert state.state == "unknown" + assert state.attributes["friendly_name"] == "Multisensor 6 Idle Cover status" + + # Test successful idle call + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: "button.multisensor_6_idle_cover_status", + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert args["command"] == "node.manually_idle_notification_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Cover status", + } diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 62513c3ad87..22cbe87d0b4 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -963,7 +963,7 @@ async def test_removed_device( # 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) == 31 + assert len(entity_entries) == 36 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -975,7 +975,7 @@ async def test_removed_device( device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id) assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) - assert len(entity_entries) == 18 + assert len(entity_entries) == 23 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None @@ -1363,6 +1363,7 @@ async def test_disabled_entity_on_value_removed( # re-enable this default-disabled entity sensor_cover_entity = "sensor.4_in_1_sensor_cover_status" + idle_cover_status_button_entity = "button.4_in_1_sensor_idle_cover_status" er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) await hass.async_block_till_done() @@ -1379,6 +1380,10 @@ async def test_disabled_entity_on_value_removed( assert state assert state.state != STATE_UNAVAILABLE + state = hass.states.get(idle_cover_status_button_entity) + assert state + assert state.state != STATE_UNAVAILABLE + # check for expected entities binary_cover_entity = "binary_sensor.4_in_1_sensor_tampering_product_cover_removed" state = hass.states.get(binary_cover_entity) @@ -1472,6 +1477,10 @@ async def test_disabled_entity_on_value_removed( assert state assert state.state == STATE_UNAVAILABLE + state = hass.states.get(idle_cover_status_button_entity) + assert state + assert state.state == STATE_UNAVAILABLE + # existing entities and the entities with removed values should be unavailable new_unavailable_entities = { state.entity_id @@ -1480,6 +1489,11 @@ async def test_disabled_entity_on_value_removed( } assert ( unavailable_entities - | {battery_level_entity, binary_cover_entity, sensor_cover_entity} + | { + battery_level_entity, + binary_cover_entity, + sensor_cover_entity, + idle_cover_status_button_entity, + } == new_unavailable_entities )