diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index beebe2cc3f8..3d5d7ab7601 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.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, @@ -178,6 +179,19 @@ async def async_setup_entry( # noqa: C901 if disc_info.assumed_state: value_updates_disc_info.append(disc_info) + # 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) + ) + await platform_setup_tasks[SENSOR_DOMAIN] + + # Create a node status sensor for each device + async_dispatcher_send( + hass, f"{DOMAIN}_{entry.entry_id}_add_node_status_sensor", node + ) + # add listener for value updated events if necessary if value_updates_disc_info: unsubscribe_callbacks.append( diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 40e28999a1a..b3c7db25116 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -6,6 +6,7 @@ from typing import cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue from homeassistant.components.sensor import ( @@ -31,6 +32,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .helpers import get_device_id LOGGER = logging.getLogger(__name__) @@ -66,6 +68,11 @@ async def async_setup_entry( async_add_entities(entities) + @callback + def async_add_node_status_sensor(node: ZwaveNode) -> None: + """Add node status sensor.""" + async_add_entities([ZWaveNodeStatusSensor(config_entry, client, node)]) + hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( async_dispatcher_connect( hass, @@ -74,6 +81,14 @@ async def async_setup_entry( ) ) + hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_node_status_sensor", + async_add_node_status_sensor, + ) + ) + class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): """Basic Representation of a Z-Wave sensor.""" @@ -295,3 +310,61 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): return None # add the value's int value as property for multi-value (list) items return {"value": self.info.primary_value.value} + + +class ZWaveNodeStatusSensor(SensorEntity): + """Representation of a node status sensor.""" + + _attr_should_poll = False + _attr_entity_registry_enabled_default = False + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, node: ZwaveNode + ) -> None: + """Initialize a generic Z-Wave device entity.""" + self.config_entry = config_entry + self.client = client + self.node = node + name: str = ( + self.node.name + or self.node.device_config.description + or f"Node {self.node.node_id}" + ) + # 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" + ) + # device is precreated in main handler + self._attr_device_info = { + "identifiers": {get_device_id(self.client, self.node)}, + } + self._attr_state: str = node.status.name.lower() + + async def async_poll_value(self, _: bool) -> None: + """Poll a value.""" + raise ValueError("There is no value to poll for this entity") + + def _status_changed(self, _: dict) -> None: + """Call when status event is received.""" + self._attr_state = self.node.status.name.lower() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + # Add value_changed callbacks. + for evt in ("wake up", "sleep", "dead", "alive"): + self.async_on_remove(self.node.on(evt, self._status_changed)) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return entity availability.""" + return self.client.connected and bool(self.node.ready) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 67d5a416a91..840d1b15b4d 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -916,7 +916,7 @@ 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) == 24 + assert len(entity_entries) == 26 # Remove a node and reload the entry old_node = nodes.pop(13) @@ -928,7 +928,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) == 15 + assert len(entity_entries) == 16 assert dev_reg.async_get_device({get_device_id(client, old_node)}) is None diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index afd3ae1a984..fc6d274235d 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS sensor platform.""" +from zwave_js_server.event import Event + from homeassistant.const import ( DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, @@ -85,3 +87,47 @@ async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration) entity_entry = ent_reg.async_get(ID_LOCK_CONFIG_PARAMETER_SENSOR) assert entity_entry assert entity_entry.disabled + + +async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): + """Test node status sensor is created and gets updated on node state changes.""" + NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node = lock_id_lock_as_id150 + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert not updated_entry.disabled + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + # Test transitions work + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + assert hass.states.get(NODE_STATUS_ENTITY).state == "dead" + + event = Event( + "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} + ) + node.receive_event(event) + assert hass.states.get(NODE_STATUS_ENTITY).state == "awake" + + event = Event( + "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} + ) + node.receive_event(event) + assert hass.states.get(NODE_STATUS_ENTITY).state == "asleep" + + event = Event( + "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} + ) + node.receive_event(event) + assert hass.states.get(NODE_STATUS_ENTITY).state == "alive"