Get zwave_js statistics data from model (#120281)

* Get zwave_js statistics data from model

* Add migration logic

* Update comment

* revert change to forward entry
This commit is contained in:
Raman Gupta 2024-09-04 02:16:56 -04:00 committed by GitHub
parent af1af6f391
commit 7788685340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 185 additions and 80 deletions

View File

@ -353,7 +353,7 @@ class ControllerEvents:
self.discovered_value_ids: dict[str, set[str]] = defaultdict(set)
self.driver_events = driver_events
self.dev_reg = driver_events.dev_reg
self.registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(
self.registered_unique_ids: dict[str, dict[Platform, set[str]]] = defaultdict(
lambda: defaultdict(set)
)
self.node_events = NodeEvents(hass, self)

View File

@ -6,20 +6,16 @@ from dataclasses import dataclass
import logging
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node
from zwave_js_server.model.value import Value as ZwaveValue
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.entity_registry import (
EntityRegistry,
RegistryEntry,
async_entries_for_device,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .helpers import get_unique_id
from .helpers import get_unique_id, get_valueless_base_unique_id
_LOGGER = logging.getLogger(__name__)
@ -62,10 +58,10 @@ class ValueID:
@callback
def async_migrate_old_entity(
hass: HomeAssistant,
ent_reg: EntityRegistry,
ent_reg: er.EntityRegistry,
registered_unique_ids: set[str],
platform: str,
device: DeviceEntry,
platform: Platform,
device: dr.DeviceEntry,
unique_id: str,
) -> None:
"""Migrate existing entity if current one can't be found and an old one exists."""
@ -77,8 +73,8 @@ def async_migrate_old_entity(
# Look for existing entities in the registry that could be the same value but on
# a different endpoint
existing_entity_entries: list[RegistryEntry] = []
for entry in async_entries_for_device(ent_reg, device.id):
existing_entity_entries: list[er.RegistryEntry] = []
for entry in er.async_entries_for_device(ent_reg, device.id):
# If entity is not in the domain for this discovery info or entity has already
# been processed, skip it
if entry.domain != platform or entry.unique_id in registered_unique_ids:
@ -109,10 +105,15 @@ def async_migrate_old_entity(
@callback
def async_migrate_unique_id(
ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str
ent_reg: er.EntityRegistry,
platform: Platform,
old_unique_id: str,
new_unique_id: str,
) -> None:
"""Check if entity with old unique ID exists, and if so migrate it to new ID."""
if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
if not (entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id)):
return
_LOGGER.debug(
"Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
entity_id,
@ -135,9 +136,9 @@ def async_migrate_unique_id(
@callback
def async_migrate_discovered_value(
hass: HomeAssistant,
ent_reg: EntityRegistry,
ent_reg: er.EntityRegistry,
registered_unique_ids: set[str],
device: DeviceEntry,
device: dr.DeviceEntry,
driver: Driver,
disc_info: ZwaveDiscoveryInfo,
) -> None:
@ -160,7 +161,7 @@ def async_migrate_discovered_value(
]
if (
disc_info.platform == "binary_sensor"
disc_info.platform == Platform.BINARY_SENSOR
and disc_info.platform_hint == "notification"
):
for state_key in disc_info.primary_value.metadata.states:
@ -211,6 +212,24 @@ def async_migrate_discovered_value(
registered_unique_ids.add(new_unique_id)
@callback
def async_migrate_statistics_sensors(
hass: HomeAssistant, driver: Driver, node: Node, key_map: dict[str, str]
) -> None:
"""Migrate statistics sensors to new unique IDs.
- Migrate camel case keys in unique IDs to snake keys.
"""
ent_reg = er.async_get(hass)
base_unique_id = f"{get_valueless_base_unique_id(driver, node)}.statistics"
for new_key, old_key in key_map.items():
if new_key == old_key:
continue
old_unique_id = f"{base_unique_id}_{old_key}"
new_unique_id = f"{base_unique_id}_{new_key}"
async_migrate_unique_id(ent_reg, Platform.SENSOR, old_unique_id, new_unique_id)
@callback
def get_old_value_ids(value: ZwaveValue) -> list[str]:
"""Get old value IDs so we can migrate entity unique ID."""

View File

@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import voluptuous as vol
@ -16,10 +15,10 @@ from zwave_js_server.const.command_class.meter import (
)
from zwave_js_server.exceptions import BaseZwaveJSServerError
from zwave_js_server.model.controller import Controller
from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType
from zwave_js_server.model.controller.statistics import ControllerStatistics
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.node.statistics import NodeStatistics
from zwave_js_server.util.command_class.meter import get_meter_type
from homeassistant.components.sensor import (
@ -90,6 +89,7 @@ from .discovery_data_template import (
)
from .entity import ZWaveBaseEntity
from .helpers import get_device_info, get_valueless_base_unique_id
from .migrate import async_migrate_statistics_sensors
PARALLEL_UPDATES = 0
@ -328,152 +328,172 @@ ENTITY_DESCRIPTION_KEY_MAP = {
}
def convert_dict_of_dicts(
statistics: ControllerStatisticsDataType | NodeStatisticsDataType, key: str
def convert_nested_attr(
statistics: ControllerStatistics | NodeStatistics, key: str
) -> Any:
"""Convert a dictionary of dictionaries to a value."""
keys = key.split(".")
return statistics.get(keys[0], {}).get(keys[1], {}).get(keys[2]) # type: ignore[attr-defined]
"""Convert a string that represents a nested attr to a value."""
data = statistics
for _key in key.split("."):
if data is None:
return None # type: ignore[unreachable]
data = getattr(data, _key)
return data
@dataclass(frozen=True, kw_only=True)
class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription):
"""Class to represent a Z-Wave JS statistics sensor entity description."""
convert: Callable[
[ControllerStatisticsDataType | NodeStatisticsDataType, str], Any
] = lambda statistics, key: statistics.get(key)
convert: Callable[[ControllerStatistics | NodeStatistics, str], Any] = getattr
entity_registry_enabled_default: bool = False
# Controller statistics descriptions
ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [
ZWaveJSStatisticsSensorEntityDescription(
key="messagesTX",
key="messages_tx",
translation_key="successful_messages",
translation_placeholders={"direction": "TX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="messagesRX",
key="messages_rx",
translation_key="successful_messages",
translation_placeholders={"direction": "RX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="messagesDroppedTX",
key="messages_dropped_tx",
translation_key="messages_dropped",
translation_placeholders={"direction": "TX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="messagesDroppedRX",
key="messages_dropped_rx",
translation_key="messages_dropped",
translation_placeholders={"direction": "RX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="NAK", translation_key="nak", state_class=SensorStateClass.TOTAL
key="nak", translation_key="nak", state_class=SensorStateClass.TOTAL
),
ZWaveJSStatisticsSensorEntityDescription(
key="CAN", translation_key="can", state_class=SensorStateClass.TOTAL
key="can", translation_key="can", state_class=SensorStateClass.TOTAL
),
ZWaveJSStatisticsSensorEntityDescription(
key="timeoutACK",
key="timeout_ack",
translation_key="timeout_ack",
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="timeoutResponse",
key="timeout_response",
translation_key="timeout_response",
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="timeoutCallback",
key="timeout_callback",
translation_key="timeout_callback",
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel0.average",
key="background_rssi.channel_0.average",
translation_key="average_background_rssi",
translation_placeholders={"channel": "0"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel0.current",
key="background_rssi.channel_0.current",
translation_key="current_background_rssi",
translation_placeholders={"channel": "0"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel1.average",
key="background_rssi.channel_1.average",
translation_key="average_background_rssi",
translation_placeholders={"channel": "1"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel1.current",
key="background_rssi.channel_1.current",
translation_key="current_background_rssi",
translation_placeholders={"channel": "1"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel2.average",
key="background_rssi.channel_2.average",
translation_key="average_background_rssi",
translation_placeholders={"channel": "2"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
ZWaveJSStatisticsSensorEntityDescription(
key="backgroundRSSI.channel2.current",
key="background_rssi.channel_2.current",
translation_key="current_background_rssi",
translation_placeholders={"channel": "2"},
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
convert=convert_dict_of_dicts,
convert=convert_nested_attr,
),
]
CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = {
"messages_tx": "messagesTX",
"messages_rx": "messagesRX",
"messages_dropped_tx": "messagesDroppedTX",
"messages_dropped_rx": "messagesDroppedRX",
"nak": "NAK",
"can": "CAN",
"timeout_ack": "timeoutAck",
"timeout_response": "timeoutResponse",
"timeout_callback": "timeoutCallback",
"background_rssi.channel_0.average": "backgroundRSSI.channel0.average",
"background_rssi.channel_0.current": "backgroundRSSI.channel0.current",
"background_rssi.channel_1.average": "backgroundRSSI.channel1.average",
"background_rssi.channel_1.current": "backgroundRSSI.channel1.current",
"background_rssi.channel_2.average": "backgroundRSSI.channel2.average",
"background_rssi.channel_2.current": "backgroundRSSI.channel2.current",
}
# Node statistics descriptions
ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [
ZWaveJSStatisticsSensorEntityDescription(
key="commandsRX",
key="commands_rx",
translation_key="successful_commands",
translation_placeholders={"direction": "RX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="commandsTX",
key="commands_tx",
translation_key="successful_commands",
translation_placeholders={"direction": "TX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="commandsDroppedRX",
key="commands_dropped_rx",
translation_key="commands_dropped",
translation_placeholders={"direction": "RX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="commandsDroppedTX",
key="commands_dropped_tx",
translation_key="commands_dropped",
translation_placeholders={"direction": "TX"},
state_class=SensorStateClass.TOTAL,
),
ZWaveJSStatisticsSensorEntityDescription(
key="timeoutResponse",
key="timeout_response",
translation_key="timeout_response",
state_class=SensorStateClass.TOTAL,
),
@ -492,20 +512,24 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [
state_class=SensorStateClass.MEASUREMENT,
),
ZWaveJSStatisticsSensorEntityDescription(
key="lastSeen",
key="last_seen",
translation_key="last_seen",
device_class=SensorDeviceClass.TIMESTAMP,
convert=(
lambda statistics, key: (
datetime.fromisoformat(dt) # type: ignore[arg-type]
if (dt := statistics.get(key))
else None
)
),
entity_registry_enabled_default=True,
),
]
NODE_STATISTICS_KEY_MAP: dict[str, str] = {
"commands_rx": "commandsRX",
"commands_tx": "commandsTX",
"commands_dropped_rx": "commandsDroppedRX",
"commands_dropped_tx": "commandsDroppedTX",
"timeout_response": "timeoutResponse",
"rtt": "rtt",
"rssi": "rssi",
"last_seen": "lastSeen",
}
def get_entity_description(
data: NumericSensorDataTemplateData,
@ -588,6 +612,14 @@ async def async_setup_entry(
@callback
def async_add_statistics_sensors(node: ZwaveNode) -> None:
"""Add statistics sensors."""
async_migrate_statistics_sensors(
hass,
driver,
node,
CONTROLLER_STATISTICS_KEY_MAP
if driver.controller.own_node == node
else NODE_STATISTICS_KEY_MAP,
)
async_add_entities(
[
ZWaveStatisticsSensor(
@ -1001,7 +1033,7 @@ class ZWaveStatisticsSensor(SensorEntity):
def statistics_updated(self, event_data: dict) -> None:
"""Call when statistics updated event is received."""
self._attr_native_value = self.entity_description.convert(
event_data["statistics"], self.entity_description.key
event_data["statistics_updated"], self.entity_description.key
)
self.async_write_ha_state()
@ -1027,5 +1059,5 @@ class ZWaveStatisticsSensor(SensorEntity):
# Set initial state
self._attr_native_value = self.entity_description.convert(
self.statistics_src.statistics.data, self.entity_description.key
self.statistics_src.statistics, self.entity_description.key
)

View File

@ -23,6 +23,10 @@ from homeassistant.components.zwave_js.const import (
SERVICE_RESET_METER,
)
from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id
from homeassistant.components.zwave_js.sensor import (
CONTROLLER_STATISTICS_KEY_MAP,
NODE_STATISTICS_KEY_MAP,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@ -55,6 +59,8 @@ from .common import (
VOLTAGE_SENSOR,
)
from tests.common import MockConfigEntry
async def test_numeric_sensor(
hass: HomeAssistant,
@ -756,6 +762,54 @@ NODE_STATISTICS_SUFFIXES_UNKNOWN = {
}
async def test_statistics_sensors_migration(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
zp3111_state,
client,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test statistics migration sensor."""
node = Node(client, copy.deepcopy(zp3111_state))
client.driver.controller.nodes[node.node_id] = node
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
entry.add_to_hass(hass)
controller_base_unique_id = f"{client.driver.controller.home_id}.1.statistics"
node_base_unique_id = f"{client.driver.controller.home_id}.22.statistics"
# Create entity registry records for the old statistics keys
for base_unique_id, key_map in (
(controller_base_unique_id, CONTROLLER_STATISTICS_KEY_MAP),
(node_base_unique_id, NODE_STATISTICS_KEY_MAP),
):
# old key
for key in key_map.values():
entity_registry.async_get_or_create(
"sensor", DOMAIN, f"{base_unique_id}_{key}"
)
# Set up integration
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Validate that entity unique ID's have changed
for base_unique_id, key_map in (
(controller_base_unique_id, CONTROLLER_STATISTICS_KEY_MAP),
(node_base_unique_id, NODE_STATISTICS_KEY_MAP),
):
for new_key, old_key in key_map.items():
# If the key has changed, the old entity should not exist
if new_key != old_key:
assert not entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{base_unique_id}_{old_key}"
)
assert entity_registry.async_get_entity_id(
"sensor", DOMAIN, f"{base_unique_id}_{new_key}"
)
async def test_statistics_sensors_no_last_seen(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,