diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8d118fd89f5..a89d20d8384 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -321,6 +321,9 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" + # Every node including the controller will have at least one sensor + await self.driver_events.async_setup_platform(Platform.SENSOR) + # Remove stale entities that may exist from a previous interview when an # interview is started. base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) @@ -337,7 +340,6 @@ class ControllerEvents: # No need for a ping button or node status sensor for controller nodes if not node.is_controller_node: # Create a node status sensor for each device - await self.driver_events.async_setup_platform(Platform.SENSOR) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_node_status_sensor", @@ -352,6 +354,13 @@ class ControllerEvents: node, ) + # Create statistics sensors for each device + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self.config_entry.entry_id}_add_statistics_sensors", + node, + ) + LOGGER.debug("Node added: %s", node.node_id) # Listen for ready node events, both new and re-interview. diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 1a292cb17ef..074bfa212db 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -11,8 +11,11 @@ from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) +from zwave_js_server.model.controller import Controller +from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.node.statistics import NodeStatisticsDataType from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType from zwave_js_server.util.command_class.meter import get_meter_type @@ -36,6 +39,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform @@ -272,6 +276,134 @@ ENTITY_DESCRIPTION_KEY_MAP = { } +# Controller statistics descriptions +ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ + SensorEntityDescription( + "messagesTX", + name="Successful messages (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesRX", + name="Successful messages (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedTX", + name="Messages dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedRX", + name="Messages dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "NAK", + name="Messages not accepted", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutCallback", + name="Timed out callbacks", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.average", + name="Average background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.current", + name="Current background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.average", + name="Average background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.current", + name="Current background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.average", + name="Average background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.current", + name="Current background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Node statistics descriptions +ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ + SensorEntityDescription( + "commandsRX", + name="Successful commands (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsTX", + name="Successful commands (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedRX", + name="Commands dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedTX", + name="Commands dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "rtt", + name="Round Trip Time", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "rssi", + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + def get_entity_description( data: NumericSensorDataTemplateData, ) -> SensorEntityDescription: @@ -347,6 +479,27 @@ async def async_setup_entry( assert driver is not None # Driver is ready before platforms are loaded. async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)]) + @callback + def async_add_statistics_sensors(node: ZwaveNode) -> None: + """Add statistics sensors.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + async_add_entities( + [ + ZWaveStatisticsSensor( + config_entry, + driver, + driver.controller if driver.controller.own_node == node else node, + entity_description, + ) + for entity_description in ( + ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST + if driver.controller.own_node == node + else ENTITY_DESCRIPTION_NODE_STATISTICS_LIST + ) + ] + ) + config_entry.async_on_unload( async_dispatcher_connect( hass, @@ -363,6 +516,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_statistics_sensors", + async_add_statistics_sensors, + ) + ) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_RESET_METER, @@ -625,3 +786,90 @@ class ZWaveNodeStatusSensor(SensorEntity): ) self._attr_native_value: str = self.node.status.name.lower() self.async_write_ha_state() + + +class ZWaveStatisticsSensor(SensorEntity): + """Representation of a node/controller statistics sensor.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + driver: Driver, + statistics_src: ZwaveNode | Controller, + description: SensorEntityDescription, + ) -> None: + """Initialize a Z-Wave statistics entity.""" + self.entity_description = description + self.config_entry = config_entry + self.statistics_src = statistics_src + node = ( + statistics_src.own_node + if isinstance(statistics_src, Controller) + else statistics_src + ) + assert node + + # Entity class attributes + self._base_unique_id = get_valueless_base_unique_id(driver, node) + self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}" + # device may not be precreated in main handler yet + self._attr_device_info = get_device_info(driver, node) + + async def async_poll_value(self, _: bool) -> None: + """Poll a value.""" + raise ValueError( + "There is no value to refresh for this entity so the zwave_js.refresh_value" + " service won't work for it" + ) + + def _get_data_from_statistics( + self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType + ) -> int | None: + """Get the data from the statistics dict.""" + if "." not in self.entity_description.key: + return cast(int | None, statistics.get(self.entity_description.key)) + + # If key contains dots, we need to traverse the dict to get to the right value + for key in self.entity_description.key.split("."): + if key not in statistics: + return None + statistics = statistics[key] # type: ignore[literal-required] + return cast(int, statistics) + + @callback + def statistics_updated(self, event_data: dict) -> None: + """Call when statistics updated event is received.""" + self._attr_native_value = self._get_data_from_statistics( + event_data["statistics"] + ) + self.async_write_ha_state() + + 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.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + self.async_on_remove( + self.statistics_src.on("statistics updated", self.statistics_updated) + ) + + # Set initial state + self._attr_native_value = self._get_data_from_statistics( + self.statistics_src.statistics.data + ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 3191bbfcea5..af23fbef32e 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -653,7 +653,9 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) - client.driver = Driver(client, controller_state, log_config_state) + client.driver = Driver( + client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) + ) node = Node(client, copy.deepcopy(controller_node_state)) client.driver.controller.nodes[node.node_id] = node diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json index 54f37d389dd..68bb0f03af8 100644 --- a/tests/components/zwave_js/fixtures/zp3111-5_state.json +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -690,8 +690,8 @@ "interviewStage": "Complete", "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", "statistics": { - "commandsTX": 39, - "commandsRX": 38, + "commandsTX": 0, + "commandsRX": 0, "commandsDroppedRX": 0, "commandsDroppedTX": 0, "timeoutResponse": 0 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 2b1e16a6c12..a33ee75661c 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) == 62 + assert len(entity_entries) == 91 # 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) == 38 + assert len(entity_entries) == 60 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 783dd14f234..46fe7ee8df3 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -565,3 +565,168 @@ async def test_unit_change(hass: HomeAssistant, zp3111, client, integration) -> assert state.state == "100.0" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + + +CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" +# controller statistics with initial state of 0 +CONTROLLER_STATISTICS_SUFFIXES = { + "successful_messages_tx": 1, + "successful_messages_rx": 2, + "messages_dropped_tx": 3, + "messages_dropped_rx": 4, + "messages_not_accepted": 5, + "collisions": 6, + "missing_acks": 7, + "timed_out_responses": 8, + "timed_out_callbacks": 9, +} +# controller statistics with initial state of unknown +CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { + "current_background_rssi_channel_0": -1, + "average_background_rssi_channel_0": -2, + "current_background_rssi_channel_1": -3, + "average_background_rssi_channel_1": -4, + "current_background_rssi_channel_2": STATE_UNKNOWN, + "average_background_rssi_channel_2": STATE_UNKNOWN, +} +NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" +# node statistics with initial state of 0 +NODE_STATISTICS_SUFFIXES = { + "successful_commands_tx": 1, + "successful_commands_rx": 2, + "commands_dropped_tx": 3, + "commands_dropped_rx": 4, + "timed_out_responses": 5, +} +# node statistics with initial state of unknown +NODE_STATISTICS_SUFFIXES_UNKNOWN = { + "round_trip_time": 6, + "rssi": 7, +} + + +async def test_statistics_sensors( + hass: HomeAssistant, zp3111, client, integration +) -> None: + """Test statistics sensors.""" + ent_reg = er.async_get(hass) + + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + ent_reg.async_update_entity(entry.entity_id, **{"disabled_by": None}) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + for prefix, suffixes, initial_state in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES, "0"), + ( + CONTROLLER_STATISTICS_ENTITY_PREFIX, + CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES, "0"), + ( + NODE_STATISTICS_ENTITY_PREFIX, + NODE_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert not entry.disabled + assert entry.disabled_by is None + + state = hass.states.get(entry.entity_id) + assert state + assert state.state == initial_state + + # Fire statistics updated for controller + event = Event( + "statistics updated", + { + "source": "controller", + "event": "statistics updated", + "statistics": { + "messagesTX": 1, + "messagesRX": 2, + "messagesDroppedTX": 3, + "messagesDroppedRX": 4, + "NAK": 5, + "CAN": 6, + "timeoutACK": 7, + "timeoutResponse": 8, + "timeoutCallback": 9, + "backgroundRSSI": { + "channel0": { + "current": -1, + "average": -2, + }, + "channel1": { + "current": -3, + "average": -4, + }, + "timestamp": 1681967176510, + }, + }, + }, + ) + client.driver.controller.receive_event(event) + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + }, + }, + ) + zp3111.receive_event(event) + + # Check that states match the statistics from the updates + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key, val in suffixes.items(): + state = hass.states.get(f"{prefix}{suffix_key}") + assert state + assert state.state == str(val)