From 0feda9ce63aa04a3aa31c8d34f2a671d8c3e6610 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 2 Feb 2021 10:06:09 +0100 Subject: [PATCH] Fix sensor discovery for zwave_js integration (#45834) Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> --- .../components/zwave_js/binary_sensor.py | 173 +++++++----------- .../components/zwave_js/discovery.py | 112 ++++++------ homeassistant/components/zwave_js/entity.py | 3 + homeassistant/components/zwave_js/sensor.py | 56 ++++-- tests/components/zwave_js/common.py | 3 +- tests/components/zwave_js/test_sensor.py | 36 +++- 6 files changed, 204 insertions(+), 179 deletions(-) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 42394fe127c..f17d893e371 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_LOCK, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, - DEVICE_CLASS_POWER, DEVICE_CLASS_PROBLEM, DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, @@ -57,201 +56,144 @@ class NotificationSensorMapping(TypedDict, total=False): """Represent a notification sensor mapping dict type.""" type: int # required - states: List[int] # required + states: List[str] device_class: str enabled: bool # Mappings for Notification sensors +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json NOTIFICATION_SENSOR_MAPPINGS: List[NotificationSensorMapping] = [ { - # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - # Assuming here that Value 1 and 2 are not present at the same time + # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected "type": NOTIFICATION_SMOKE_ALARM, - "states": [1, 2], + "states": ["1", "2"], "device_class": DEVICE_CLASS_SMOKE, }, { # NotificationType 1: Smoke Alarm - All other State Id's - # Create as disabled sensors "type": NOTIFICATION_SMOKE_ALARM, - "states": [3, 4, 5, 6, 7, 8], - "device_class": DEVICE_CLASS_SMOKE, - "enabled": False, + "device_class": DEVICE_CLASS_PROBLEM, }, { # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 "type": NOTIFICATION_CARBON_MONOOXIDE, - "states": [1, 2], + "states": ["1", "2"], "device_class": DEVICE_CLASS_GAS, }, { # NotificationType 2: Carbon Monoxide - All other State Id's "type": NOTIFICATION_CARBON_MONOOXIDE, - "states": [4, 5, 7], - "device_class": DEVICE_CLASS_GAS, - "enabled": False, + "device_class": DEVICE_CLASS_PROBLEM, }, { # NotificationType 3: Carbon Dioxide - State Id's 1 and 2 "type": NOTIFICATION_CARBON_DIOXIDE, - "states": [1, 2], + "states": ["1", "2"], "device_class": DEVICE_CLASS_GAS, }, { # NotificationType 3: Carbon Dioxide - All other State Id's "type": NOTIFICATION_CARBON_DIOXIDE, - "states": [4, 5, 7], - "device_class": DEVICE_CLASS_GAS, - "enabled": False, + "device_class": DEVICE_CLASS_PROBLEM, }, { # NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat) "type": NOTIFICATION_HEAT, - "states": [1, 2, 5, 6], + "states": ["1", "2", "5", "6"], "device_class": DEVICE_CLASS_HEAT, }, { # NotificationType 4: Heat - All other State Id's "type": NOTIFICATION_HEAT, - "states": [3, 4, 8, 10, 11], - "device_class": DEVICE_CLASS_HEAT, - "enabled": False, + "device_class": DEVICE_CLASS_PROBLEM, }, { # NotificationType 5: Water - State Id's 1, 2, 3, 4 "type": NOTIFICATION_WATER, - "states": [1, 2, 3, 4], + "states": ["1", "2", "3", "4"], "device_class": DEVICE_CLASS_MOISTURE, }, { # NotificationType 5: Water - All other State Id's "type": NOTIFICATION_WATER, - "states": [5], - "device_class": DEVICE_CLASS_MOISTURE, - "enabled": False, + "device_class": DEVICE_CLASS_PROBLEM, }, { # NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock) "type": NOTIFICATION_ACCESS_CONTROL, - "states": [1, 2, 3, 4], + "states": ["1", "2", "3", "4"], "device_class": DEVICE_CLASS_LOCK, }, { - # NotificationType 6: Access Control - State Id 22 (door/window open) + # NotificationType 6: Access Control - State Id 16 (door/window open) "type": NOTIFICATION_ACCESS_CONTROL, - "states": [22], + "states": ["22"], "device_class": DEVICE_CLASS_DOOR, }, + { + # NotificationType 6: Access Control - State Id 17 (door/window closed) + "type": NOTIFICATION_ACCESS_CONTROL, + "states": ["23"], + "enabled": False, + }, { # NotificationType 7: Home Security - State Id's 1, 2 (intrusion) - # Assuming that value 1 and 2 are not present at the same time "type": NOTIFICATION_HOME_SECURITY, - "states": [1, 2], + "states": ["1", "2"], "device_class": DEVICE_CLASS_SAFETY, }, { # NotificationType 7: Home Security - State Id's 3, 4, 9 (tampering) "type": NOTIFICATION_HOME_SECURITY, - "states": [3, 4, 9], + "states": ["3", "4", "9"], "device_class": DEVICE_CLASS_SAFETY, }, { # NotificationType 7: Home Security - State Id's 5, 6 (glass breakage) - # Assuming that value 5 and 6 are not present at the same time "type": NOTIFICATION_HOME_SECURITY, - "states": [5, 6], + "states": ["5", "6"], "device_class": DEVICE_CLASS_SAFETY, }, { # NotificationType 7: Home Security - State Id's 7, 8 (motion) "type": NOTIFICATION_HOME_SECURITY, - "states": [7, 8], + "states": ["7", "8"], "device_class": DEVICE_CLASS_MOTION, }, - { - # NotificationType 8: Power management - Values 1...9 - "type": NOTIFICATION_POWER_MANAGEMENT, - "states": [1, 2, 3, 4, 5, 6, 7, 8, 9], - "device_class": DEVICE_CLASS_POWER, - "enabled": False, - }, - { - # NotificationType 8: Power management - Values 10...15 - # Battery values (mutually exclusive) - "type": NOTIFICATION_POWER_MANAGEMENT, - "states": [10, 11, 12, 13, 14, 15], - "device_class": DEVICE_CLASS_BATTERY, - "enabled": False, - }, { # NotificationType 9: System - State Id's 1, 2, 6, 7 "type": NOTIFICATION_SYSTEM, - "states": [1, 2, 6, 7], + "states": ["1", "2", "6", "7"], "device_class": DEVICE_CLASS_PROBLEM, - "enabled": False, }, { # NotificationType 10: Emergency - State Id's 1, 2, 3 "type": NOTIFICATION_EMERGENCY, - "states": [1, 2, 3], + "states": ["1", "2", "3"], "device_class": DEVICE_CLASS_PROBLEM, }, - { - # NotificationType 11: Clock - State Id's 1, 2 - "type": NOTIFICATION_CLOCK, - "states": [1, 2], - "enabled": False, - }, - { - # NotificationType 12: Appliance - All State Id's - "type": NOTIFICATION_APPLIANCE, - "states": list(range(1, 22)), - }, - { - # NotificationType 13: Home Health - State Id's 1,2,3,4,5 - "type": NOTIFICATION_APPLIANCE, - "states": [1, 2, 3, 4, 5], - }, { # NotificationType 14: Siren "type": NOTIFICATION_SIREN, - "states": [1], + "states": ["1"], "device_class": DEVICE_CLASS_SOUND, }, - { - # NotificationType 15: Water valve - # ignore non-boolean values - "type": NOTIFICATION_WATER_VALVE, - "states": [3, 4], - "device_class": DEVICE_CLASS_PROBLEM, - }, - { - # NotificationType 16: Weather - "type": NOTIFICATION_WEATHER, - "states": [1, 2], - "device_class": DEVICE_CLASS_PROBLEM, - }, - { - # NotificationType 17: Irrigation - # ignore non-boolean values - "type": NOTIFICATION_IRRIGATION, - "states": [1, 2, 3, 4, 5], - }, { # NotificationType 18: Gas "type": NOTIFICATION_GAS, - "states": [1, 2, 3, 4], + "states": ["1", "2", "3", "4"], "device_class": DEVICE_CLASS_GAS, }, { # NotificationType 18: Gas "type": NOTIFICATION_GAS, - "states": [6], + "states": ["6"], "device_class": DEVICE_CLASS_PROBLEM, }, ] + PROPERTY_DOOR_STATUS = "doorStatus" @@ -284,10 +226,17 @@ async def async_setup_entry( @callback def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave Binary Sensor.""" - entities: List[ZWaveBaseEntity] = [] + entities: List[BinarySensorEntity] = [] if info.platform_hint == "notification": - entities.append(ZWaveNotificationBinarySensor(config_entry, client, info)) + # Get all sensors from Notification CC states + for state_key in info.primary_value.metadata.states: + # ignore idle key (0) + if state_key == "0": + continue + entities.append( + ZWaveNotificationBinarySensor(config_entry, client, info, state_key) + ) elif info.platform_hint == "property": entities.append(ZWavePropertyBinarySensor(config_entry, client, info)) else: @@ -335,58 +284,60 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from Notification CommandClass.""" def __init__( - self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + state_key: str, ) -> None: """Initialize a ZWaveNotificationBinarySensor entity.""" super().__init__(config_entry, client, info) + self.state_key = state_key # check if we have a custom mapping for this value self._mapping_info = self._get_sensor_mapping() @property def is_on(self) -> bool: """Return if the sensor is on or off.""" - if self._mapping_info: - return self.info.primary_value.value in self._mapping_info["states"] - return bool(self.info.primary_value.value != 0) + return int(self.info.primary_value.value) == int(self.state_key) @property def name(self) -> str: """Return default name from device name and value name combination.""" node_name = self.info.node.name or self.info.node.device_config.description - property_name = self.info.primary_value.property_name - property_key_name = self.info.primary_value.property_key_name - return f"{node_name}: {property_name}: {property_key_name}" + value_name = self.info.primary_value.property_name + state_label = self.info.primary_value.metadata.states[self.state_key] + return f"{node_name}: {value_name} - {state_label}" @property def device_class(self) -> Optional[str]: """Return device class.""" return self._mapping_info.get("device_class") + @property + def unique_id(self) -> str: + """Return unique id for this entity.""" + return f"{super().unique_id}.{self.state_key}" + @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" - # We hide some more advanced sensors by default to not overwhelm users if not self._mapping_info: - # consider value for which we do not have a mapping as advanced. - return False + return True return self._mapping_info.get("enabled", True) @callback def _get_sensor_mapping(self) -> NotificationSensorMapping: """Try to get a device specific mapping for this sensor.""" for mapping in NOTIFICATION_SENSOR_MAPPINGS: - if mapping["type"] != int( - self.info.primary_value.metadata.cc_specific["notificationType"] + if ( + mapping["type"] + != self.info.primary_value.metadata.cc_specific["notificationType"] ): continue - for state_key in self.info.primary_value.metadata.states: - # make sure the key is int - state_key = int(state_key) - if state_key not in mapping["states"]: - continue + if not mapping.get("states") or self.state_key in mapping["states"]: # match found - mapping_info = mapping.copy() - return mapping_info + return mapping return {} diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 1fdd8e12fd6..0720c28aceb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -143,10 +143,8 @@ DISCOVERY_SCHEMAS = [ platform="sensor", hint="string_sensor", command_class={ - CommandClass.ALARM, CommandClass.SENSOR_ALARM, CommandClass.INDICATOR, - CommandClass.NOTIFICATION, }, type={"string"}, ), @@ -157,14 +155,30 @@ DISCOVERY_SCHEMAS = [ command_class={ CommandClass.SENSOR_MULTILEVEL, CommandClass.METER, - CommandClass.ALARM, CommandClass.SENSOR_ALARM, CommandClass.INDICATOR, CommandClass.BATTERY, + }, + type={"number"}, + ), + # special list sensors (Notification CC) + ZWaveDiscoverySchema( + platform="sensor", + hint="list_sensor", + command_class={ CommandClass.NOTIFICATION, + }, + type={"number"}, + ), + # sensor for basic CC + ZWaveDiscoverySchema( + platform="sensor", + hint="numeric_sensor", + command_class={ CommandClass.BASIC, }, type={"number"}, + property={"currentValue"}, ), # binary switches ZWaveDiscoverySchema( @@ -204,54 +218,44 @@ DISCOVERY_SCHEMAS = [ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None, None]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): - disc_val = async_discover_value(value) - if disc_val: - yield disc_val - - -@callback -def async_discover_value(value: ZwaveValue) -> Optional[ZwaveDiscoveryInfo]: - """Run discovery on Z-Wave value and return ZwaveDiscoveryInfo if match found.""" - for schema in DISCOVERY_SCHEMAS: - # check device_class_basic - if ( - schema.device_class_basic is not None - and value.node.device_class.basic not in schema.device_class_basic - ): - continue - # check device_class_generic - if ( - schema.device_class_generic is not None - and value.node.device_class.generic not in schema.device_class_generic - ): - continue - # check device_class_specific - if ( - schema.device_class_specific is not None - and value.node.device_class.specific not in schema.device_class_specific - ): - continue - # check command_class - if ( - schema.command_class is not None - and value.command_class not in schema.command_class - ): - continue - # check endpoint - if schema.endpoint is not None and value.endpoint not in schema.endpoint: - continue - # check property - if schema.property is not None and value.property_ not in schema.property: - continue - # check metadata_type - if schema.type is not None and value.metadata.type not in schema.type: - continue - # all checks passed, this value belongs to an entity - return ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - platform=schema.platform, - platform_hint=schema.hint, - ) - - return None + for schema in DISCOVERY_SCHEMAS: + # check device_class_basic + if ( + schema.device_class_basic is not None + and value.node.device_class.basic not in schema.device_class_basic + ): + continue + # check device_class_generic + if ( + schema.device_class_generic is not None + and value.node.device_class.generic not in schema.device_class_generic + ): + continue + # check device_class_specific + if ( + schema.device_class_specific is not None + and value.node.device_class.specific not in schema.device_class_specific + ): + continue + # check command_class + if ( + schema.command_class is not None + and value.command_class not in schema.command_class + ): + continue + # check endpoint + if schema.endpoint is not None and value.endpoint not in schema.endpoint: + continue + # check property + if schema.property is not None and value.property_ not in schema.property: + continue + # check metadata_type + if schema.type is not None and value.metadata.type not in schema.type: + continue + # all checks passed, this value belongs to an entity + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + platform=schema.platform, + platform_hint=schema.hint, + ) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 9626ae9a888..285824ef2f1 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -79,6 +79,9 @@ class ZWaveBaseEntity(Entity): or self.info.primary_value.property_key_name or self.info.primary_value.property_name ) + # append endpoint if > 1 + if self.info.primary_value.endpoint > 1: + value_name += f" ({self.info.primary_value.endpoint})" return f"{node_name}: {value_name}" @property diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index ff48790b5f3..d5c34742c49 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -9,6 +9,7 @@ from zwave_js_server.const import CommandClass from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DOMAIN as SENSOR_DOMAIN, ) @@ -39,6 +40,8 @@ async def async_setup_entry( entities.append(ZWaveStringSensor(config_entry, client, info)) elif info.platform_hint == "numeric_sensor": entities.append(ZWaveNumericSensor(config_entry, client, info)) + elif info.platform_hint == "list_sensor": + entities.append(ZWaveListSensor(config_entry, client, info)) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -67,11 +70,15 @@ class ZwaveSensorBase(ZWaveBaseEntity): if self.info.primary_value.command_class == CommandClass.BATTERY: return DEVICE_CLASS_BATTERY if self.info.primary_value.command_class == CommandClass.METER: - if self.info.primary_value.property_key_name == "kWh_Consumed": + if self.info.primary_value.metadata.unit == "kWh": return DEVICE_CLASS_ENERGY return DEVICE_CLASS_POWER - if self.info.primary_value.property_ == "Air temperature": + if "temperature" in self.info.primary_value.property_.lower(): return DEVICE_CLASS_TEMPERATURE + if self.info.primary_value.metadata.unit == "W": + return DEVICE_CLASS_POWER + if self.info.primary_value.metadata.unit == "Lux": + return DEVICE_CLASS_ILLUMINANCE return None @property @@ -133,17 +140,42 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) @property - def device_state_attributes(self) -> Optional[Dict[str, str]]: - """Return the device specific state attributes.""" + def name(self) -> str: + """Return default name from device name and value name combination.""" + if self.info.primary_value.command_class == CommandClass.BASIC: + node_name = self.info.node.name or self.info.node.device_config.description + label = self.info.primary_value.command_class_name + return f"{node_name}: {label}" + return super().name + + +class ZWaveListSensor(ZwaveSensorBase): + """Representation of a Z-Wave Numeric sensor with multiple states.""" + + @property + def state(self) -> Optional[str]: + """Return state of the sensor.""" + if self.info.primary_value.value is None: + return None if ( - self.info.primary_value.value is None - or not self.info.primary_value.metadata.states + not str(self.info.primary_value.value) + in self.info.primary_value.metadata.states ): return None - # add the value's label as property for multi-value (list) items - label = self.info.primary_value.metadata.states.get( - self.info.primary_value.value - ) or self.info.primary_value.metadata.states.get( - str(self.info.primary_value.value) + return str( + self.info.primary_value.metadata.states[str(self.info.primary_value.value)] ) - return {"label": label} + + @property + def device_state_attributes(self) -> Optional[Dict[str, str]]: + """Return the device specific state attributes.""" + # add the value's int value as property for multi-value (list) items + return {"value": self.info.primary_value.value} + + @property + def name(self) -> str: + """Return default name from device name and value name combination.""" + node_name = self.info.node.name or self.info.node.device_config.description + prop_name = self.info.primary_value.property_name + prop_key_name = self.info.primary_value.property_key_name + return f"{node_name}: {prop_name} - {prop_key_name}" diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 399b009f4c2..63ec9013fa3 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -7,8 +7,9 @@ LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = ( - "binary_sensor.multisensor_6_home_security_motion_sensor_status" + "binary_sensor.multisensor_6_home_security_motion_detection" ) +NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 284d2e1a84f..bd6fb9f2569 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -7,8 +7,17 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, ) +from homeassistant.helpers.entity_registry import ( + DISABLED_INTEGRATION, + async_get_registry, +) -from .common import AIR_TEMPERATURE_SENSOR, ENERGY_SENSOR, POWER_SENSOR +from .common import ( + AIR_TEMPERATURE_SENSOR, + ENERGY_SENSOR, + NOTIFICATION_MOTION_SENSOR, + POWER_SENSOR, +) async def test_numeric_sensor(hass, multisensor_6, integration): @@ -36,3 +45,28 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.16" assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY + + +async def test_disabled_notification_sensor(hass, multisensor_6, integration): + """Test sensor is created from Notification CC and is disabled.""" + ent_reg = await async_get_registry(hass) + entity_entry = ent_reg.async_get(NOTIFICATION_MOTION_SENSOR) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == DISABLED_INTEGRATION + + # Test enabling entity + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(NOTIFICATION_MOTION_SENSOR) + assert state.state == "Motion detection" + assert state.attributes["value"] == 8