diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index c0df675a25d..9ec546be756 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -17,18 +17,28 @@ from zwave_js_server.const.command_class.notification import ( SmokeAlarmNotificationEvent, ) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -72,8 +82,7 @@ ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632 ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633 -# Numeric State values used by the "Opening state" notification variable. -# This is only needed temporarily until the legacy Access Control door state binary sensors are removed. +# Numeric State values used by the Opening state notification variable. class OpeningState(IntEnum): """Opening state values exposed by Access Control notifications.""" @@ -82,23 +91,23 @@ class OpeningState(IntEnum): TILTED = 2 -# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors. -def _legacy_is_closed(opening_state: OpeningState) -> bool: +# parse_opening_state helpers. +def _opening_state_is_closed(opening_state: OpeningState) -> bool: """Return if Opening state represents closed.""" return opening_state is OpeningState.CLOSED -def _legacy_is_open(opening_state: OpeningState) -> bool: +def _opening_state_is_open(opening_state: OpeningState) -> bool: """Return if Opening state represents open.""" return opening_state is OpeningState.OPEN -def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_open_or_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents open or tilted.""" return opening_state in (OpeningState.OPEN, OpeningState.TILTED) -def _legacy_is_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents tilted.""" return opening_state is OpeningState.TILTED @@ -127,12 +136,51 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @dataclass(frozen=True, kw_only=True) class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): - """Describe a legacy Access Control binary sensor that derives state from Opening state.""" + """Describe an Access Control binary sensor that derives state from Opening state.""" state_key: int parse_opening_state: Callable[[OpeningState], bool] +@dataclass(frozen=True, kw_only=True) +class LegacyDoorStateRepairDescription: + """Describe how a legacy door state entity should be migrated.""" + + issue_translation_key: str + replacement_state_key: OpeningState + + +LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS: dict[str, LegacyDoorStateRepairDescription] = { + "legacy_access_control_door_state_simple_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_regular": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_tilt": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), + "legacy_access_control_door_tilt_state_tilted": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), +} + +LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset( + { + description.issue_translation_key + for description in LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.values() + } +) + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -389,6 +437,9 @@ BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescripti } +# This can likely be removed once the legacy notification binary sensor +# discovery path is gone and Opening state is handled only by the dedicated +# discovery schemas below. @callback def is_valid_notification_binary_sensor( info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, @@ -396,13 +447,111 @@ def is_valid_notification_binary_sensor( """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: return False - # Access Control - Opening state is exposed as a single enum sensor instead - # of fanning out one binary sensor per state. + # Opening state is handled by dedicated discovery schemas if is_opening_state_notification_value(info.primary_value): return False return len(info.primary_value.metadata.states) > 1 +@callback +def _async_delete_legacy_entity_repairs(hass: HomeAssistant, entity_id: str) -> None: + """Delete all stale legacy door state repair issues for an entity.""" + for issue_key in LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS: + async_delete_issue(hass, DOMAIN, f"{issue_key}.{entity_id}") + + +@callback +def _async_check_legacy_entity_repair( + hass: HomeAssistant, + driver: Driver, + entity: ZWaveLegacyDoorStateBinarySensor, +) -> None: + """Schedule a repair issue check once HA has fully started.""" + + @callback + def _async_do_check(hass: HomeAssistant) -> None: + """Create or delete a repair issue for a deprecated legacy door state entity.""" + ent_reg = er.async_get(hass) + if entity.unique_id is None: + return + entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id + ) + if entity_id is None: + return + + repair_description = LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.get( + entity.entity_description.key + ) + if repair_description is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_entry = ent_reg.async_get(entity_id) + if entity_entry is None or entity_entry.disabled: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + if not entity_automations and not entity_scripts: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + opening_state_value = get_opening_state_notification_value( + entity.info.node, entity.info.primary_value.endpoint + ) + if opening_state_value is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + replacement_unique_id = ( + f"{driver.controller.home_id}.{opening_state_value.value_id}." + f"{repair_description.replacement_state_key}" + ) + replacement_entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, replacement_unique_id + ) + if replacement_entity_id is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + items = [] + for domain, entity_ids in ( + ("automation", entity_automations), + ("script", entity_scripts), + ): + for eid in entity_ids: + item = ent_reg.async_get(eid) + if item: + items.append( + f"- [{item.name or item.original_name or eid}]" + f"(/config/{domain}/edit/{item.unique_id})" + ) + else: + items.append(f"- {eid}") + + async_create_issue( + hass, + DOMAIN, + f"{repair_description.issue_translation_key}.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key=repair_description.issue_translation_key, + translation_placeholders={ + "entity_id": entity_id, + "entity_name": ( + entity_entry.name or entity_entry.original_name or entity_id + ), + "replacement_entity_id": replacement_entity_id, + "items": "\n".join(items), + }, + ) + + async_at_started(hass, _async_do_check) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -442,13 +591,21 @@ async def async_setup_entry( and info.entity_class is ZWaveBooleanBinarySensor ): entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveOpeningStateBinarySensor + and isinstance( + info.entity_description, OpeningStateZWaveJSEntityDescription + ) + ): + entities.append(ZWaveOpeningStateBinarySensor(config_entry, driver, info)) elif ( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entities.append( - ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - ) + entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + entities.append(entity) + _async_check_legacy_entity_repair(hass, driver, entity) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": @@ -632,6 +789,69 @@ class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): return None +class ZWaveOpeningStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): + """Representation of a binary sensor derived from Opening state.""" + + entity_description: OpeningStateZWaveJSEntityDescription + _known_states: set[str] + + def __init__( + self, + config_entry: ZwaveJSConfigEntry, + driver: Driver, + info: NewZwaveDiscoveryInfo, + ) -> None: + """Initialize an Opening state binary sensor entity.""" + super().__init__(config_entry, driver, info) + self._known_states = set(info.primary_value.metadata.states or ()) + self._attr_unique_id = ( + f"{self._attr_unique_id}.{self.entity_description.state_key}" + ) + + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if metadata states require adding the Tilt entity.""" + return ( + # Open and Tilt entities share the same underlying Opening state value. + # Only let the main Open entity trigger rediscovery when Tilt first + # appears so we can add the missing sibling without recreating the + # main entity and losing its registry customizations. + str(OpeningState.TILTED) not in self._known_states + and str(OpeningState.TILTED) + in set(self.info.primary_value.metadata.states or ()) + and self.entity_description.state_key == OpeningState.OPEN + ) + + async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None: + """Trigger re-discovery while preserving the main Opening state entity.""" + assert self.device_entry is not None + controller_events = ( + self.config_entry.runtime_data.driver_events.controller_events + ) + + # Unlike the base implementation, keep this entity in place so its + # registry entry and user customizations survive metadata rediscovery. + controller_events.discovered_value_ids[self.device_entry.id].discard( + value.value_id + ) + node_events = controller_events.node_events + value_updates_disc_info = node_events.value_updates_disc_info[ + value.node.node_id + ] + node_events.async_on_value_added(value_updates_disc_info, value) + + @property + def is_on(self) -> bool | None: + """Return if the sensor is on or off.""" + value = self.info.primary_value.value + if value is None: + return None + try: + return self.entity_description.parse_opening_state(OpeningState(int(value))) + except TypeError, ValueError: + return None + + class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" @@ -730,11 +950,54 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ ), entity_class=ZWaveNotificationBinarySensor, ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.TILTED}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + # Also derive the main binary sensor from the same value ID + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_tilted", + name="Tilt", + state_key=OpeningState.TILTED, + parse_opening_state=_opening_state_is_tilted, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_open", + state_key=OpeningState.OPEN, + parse_opening_state=_opening_state_is_open_or_tilted, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), # ------------------------------------------------------------------- # DEPRECATED legacy Access Control door/window binary sensors. # These schemas exist only for backwards compatibility with users who # already have these entities registered. New integrations should use - # the Opening state enum sensor instead. Do not add new schemas here. + # the dedicated Opening state binary sensors instead. Do not add new + # schemas here. # All schemas below use ZWaveLegacyDoorStateBinarySensor and are # disabled by default (entity_registry_enabled_default=False). # ------------------------------------------------------------------- @@ -758,7 +1021,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open_or_tilted, + parse_opening_state=_opening_state_is_open_or_tilted, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -784,7 +1047,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -809,7 +1072,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -835,7 +1098,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -858,7 +1121,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_regular", name="Window/door is open in regular position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -881,7 +1144,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_tilt", name="Window/door is open in tilt position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -904,7 +1167,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_tilt_state_tilted", name="Window/door is tilted", state_key=OpeningState.OPEN, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index dbaefc4f1cf..cc933386d13 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,6 +303,14 @@ } }, "issues": { + "deprecated_legacy_door_open_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on when the door or window is open or tilted.", + "title": "Deprecation: {entity_name}" + }, + "deprecated_legacy_door_tilt_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on only when the door or window is tilted.", + "title": "Deprecation: {entity_name}" + }, "device_config_file_changed": { "fix_flow": { "abort": { diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index ad7db02950c..dd8001d4cfd 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -9,7 +9,12 @@ import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components import automation +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -20,7 +25,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import ( @@ -144,6 +151,22 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, return updated_state +def _set_opening_state_metadata_states( + node_state: dict[str, Any], states: dict[str, str] +) -> dict[str, Any]: + """Return a node state with updated Opening state metadata states.""" + updated_state = copy.deepcopy(node_state) + for value_data in updated_state["values"]: + if ( + value_data.get("commandClass") == 113 + and value_data.get("property") == "Access Control" + and value_data.get("propertyKey") == "Opening state" + ): + value_data["metadata"]["states"] = states + break + return updated_state + + @pytest.fixture def platforms() -> list[str]: """Fixture to specify platforms to test.""" @@ -418,12 +441,12 @@ async def test_property_sensor_door_status( assert state.state == STATE_UNKNOWN -async def test_opening_state_notification_does_not_create_binary_sensors( +async def test_opening_state_creates_open_binary_sensor( hass: HomeAssistant, client, hoppe_ehandle_connectsense_state, ) -> None: - """Test Opening state does not fan out into per-state binary sensors.""" + """Test Opening state creates the Open binary sensor.""" # The eHandle fixture has a Binary Sensor CC value for tilt, which we # want to ignore in the assertion below state = copy.deepcopy(hoppe_ehandle_connectsense_state) @@ -440,7 +463,12 @@ async def test_opening_state_notification_does_not_create_binary_sensors( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert not hass.states.async_all("binary_sensor") + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + assert hass.states.get("binary_sensor.ehandle_connectsense_tilt") is None async def test_opening_state_disables_legacy_window_door_notification_sensors( @@ -476,7 +504,7 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( } or ( entry.original_name == "Window/door is tilted" - and entry.original_device_class != BinarySensorDeviceClass.WINDOW + and entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION ) ) ] @@ -488,6 +516,162 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors( ) assert all(hass.states.get(entry.entity_id) is None for entry in legacy_entries) + open_state = hass.states.get("binary_sensor.ehandle_connectsense") + assert open_state is not None + assert open_state.state == STATE_OFF + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + + +async def test_opening_state_binary_sensors_with_tilted( + hass: HomeAssistant, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test Opening state creates Open and Tilt binary sensors when supported.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + + open_state = hass.states.get(open_entity_id) + tilted_state = hass.states.get(tilted_entity_id) + assert open_state is not None + assert tilted_state is not None + assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + assert ATTR_DEVICE_CLASS not in tilted_state.attributes + assert open_state.state == STATE_OFF + assert tilted_state.state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 1, + "prevValue": 0, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_OFF + + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "newValue": 2, + "prevValue": 1, + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id).state == STATE_ON + assert hass.states.get(tilted_entity_id).state == STATE_ON + + +async def test_opening_state_tilted_appears_via_metadata_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + hoppe_ehandle_connectsense_state, +) -> None: + """Test tilt binary sensor is added without recreating the main entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + open_entity_id = "binary_sensor.ehandle_connectsense" + tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt" + open_entry = entity_registry.async_get(open_entity_id) + assert open_entry is not None + + assert hass.states.get(open_entity_id) is not None + assert hass.states.get(tilted_entity_id) is None + + node.receive_event( + Event( + "metadata updated", + { + "source": "node", + "event": "metadata updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Access Control", + "propertyKey": "Opening state", + "propertyName": "Access Control", + "propertyKeyName": "Opening state", + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Opening state", + "ccSpecific": {"notificationType": 6}, + "min": 0, + "max": 255, + "states": { + "0": "Closed", + "1": "Open", + "2": "Tilted", + }, + "stateful": True, + "secret": False, + }, + }, + }, + ) + ) + await hass.async_block_till_done() + + assert hass.states.get(open_entity_id) is not None + tilted_state = hass.states.get(tilted_entity_id) + assert tilted_state is not None + assert entity_registry.async_get(open_entity_id) == open_entry + async def test_reenabled_legacy_door_state_entity_follows_opening_state( hass: HomeAssistant, @@ -983,3 +1167,347 @@ async def test_hoppe_ehandle_connectsense( assert entry.original_name == "Window/door is tilted" assert entry.original_device_class == BinarySensorDeviceClass.WINDOW assert entry.disabled_by is None, "Entity should be enabled by default" + + +async def test_legacy_door_open_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test an open-state legacy entity creates the open-state repair issue.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_open_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert issue.translation_placeholders["entity_name"] == "Window/door is open" + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_tilt_state_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test a tilt-state legacy entity creates the tilt-state repair issue.""" + node = Node( + client, + _set_opening_state_metadata_states( + hoppe_ehandle_connectsense_state, + {"0": "Closed", "1": "Open", "2": "Tilted"}, + ), + ) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.5633", + suggested_object_id="ehandle_connectsense_window_door_is_open_in_tilt_position", + original_name="Window/door is open in tilt position", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + assert issue is not None + assert issue.translation_key == "deprecated_legacy_door_tilt_state" + assert issue.translation_placeholders["entity_id"] == entity_id + assert ( + issue.translation_placeholders["entity_name"] + == "Window/door is open in tilt position" + ) + assert ( + issue.translation_placeholders["replacement_entity_id"] + == "binary_sensor.ehandle_connectsense_tilt" + ) + assert "test" in issue.translation_placeholders["items"] + + +async def test_legacy_door_open_state_no_repair_issue_when_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue is created when the legacy entity is disabled.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + disabled_by=er.RegistryEntryDisabler.INTEGRATION, + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_closed_door_state_does_not_create_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test closed-state legacy entities are excluded from repair issues.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.23", + suggested_object_id="ehandle_connectsense_window_door_is_closed", + original_name="Window/door is closed", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_hoppe_custom_tilt_sensor_no_repair_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test no repair issue for the custom Binary Sensor CC tilt entity.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-48-0-Tilt", + suggested_object_id="ehandle_connectsense_window_door_is_tilted", + original_name="Window/door is tilted", + ) + entity_id = entity_entry.entity_id + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "id": "test_automation", + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": { + "action": "automation.turn_on", + "target": {"entity_id": "automation.test_automation"}, + }, + } + }, + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}" + ) + is None + ) + + +async def test_legacy_door_open_state_stale_repair_issue_cleaned_up( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + client: MagicMock, + hoppe_ehandle_connectsense_state: NodeDataType, +) -> None: + """Test stale open-state repair issues are deleted when no references remain.""" + node = Node(client, hoppe_ehandle_connectsense_state) + client.driver.controller.nodes[node.node_id] = node + home_id = client.driver.controller.home_id + + entity_entry = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + f"{home_id}.20-113-0-Access Control-Door state.22", + suggested_object_id="ehandle_connectsense_window_door_is_open", + original_name="Window/door is open", + ) + entity_id = entity_entry.entity_id + + async_create_issue( + hass, + DOMAIN, + f"deprecated_legacy_door_open_state.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_legacy_door_open_state", + translation_placeholders={ + "entity_id": entity_id, + "entity_name": "Window/door is open", + "replacement_entity_id": "binary_sensor.ehandle_connectsense", + "items": "- [test](/config/automation/edit/test_automation)", + }, + ) + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is not None + ) + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + issue_registry.async_get_issue( + DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}" + ) + is None + ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e111d6aed91..e5b7d40f712 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -11,7 +11,6 @@ from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( - ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -895,137 +894,6 @@ async def test_new_sensor_invalid_scale( mock_schedule_reload.assert_called_once_with(integration.entry_id) -async def test_opening_state_sensor( - hass: HomeAssistant, - client, - hoppe_ehandle_connectsense_state, -) -> None: - """Test Opening state is exposed as an enum sensor.""" - node = Node(client, hoppe_ehandle_connectsense_state) - client.driver.controller.nodes[node.node_id] = node - - entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.ehandle_connectsense_opening_state") - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - assert state.attributes[ATTR_VALUE] == 0 - - # Make sure we're not accidentally creating enum sensors for legacy - # Door/Window notification variables. - legacy_sensor_ids = [ - "sensor.ehandle_connectsense_door_state", - "sensor.ehandle_connectsense_door_state_simple", - ] - for entity_id in legacy_sensor_ids: - assert hass.states.get(entity_id) is None - - -async def test_opening_state_sensor_metadata_options_change( - hass: HomeAssistant, - hoppe_ehandle_connectsense: Node, - integration: MockConfigEntry, -) -> None: - """Test Opening state sensor is rediscovered when metadata options change.""" - entity_id = "sensor.ehandle_connectsense_opening_state" - node = hoppe_ehandle_connectsense - - # Verify initial state with 2 options - state = hass.states.get(entity_id) - assert state - assert state.state == "Closed" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - # Simulate metadata update adding "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - "2": "Tilted", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 3 options - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open", "Tilted"] - - # Simulate metadata update removing "Tilted" state - event = Event( - "metadata updated", - { - "source": "node", - "event": "metadata updated", - "nodeId": node.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Access Control", - "propertyKey": "Opening state", - "propertyName": "Access Control", - "propertyKeyName": "Opening state", - "metadata": { - "type": "number", - "readable": True, - "writeable": False, - "label": "Opening state", - "ccSpecific": {"notificationType": 6}, - "min": 0, - "max": 255, - "states": { - "0": "Closed", - "1": "Open", - }, - "stateful": True, - "secret": False, - }, - }, - }, - ) - node.receive_event(event) - await hass.async_block_till_done() - - # Entity should be rediscovered with 2 options again - state = hass.states.get(entity_id) - assert state - assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"] - - CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" # controller statistics with initial state of 0 CONTROLLER_STATISTICS_SUFFIXES = {