1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
AlCalzone
2026-03-30 19:17:05 +02:00
committed by GitHub
parent ca2099b165
commit 501b4e6efb
4 changed files with 826 additions and 159 deletions

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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
)

View File

@@ -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 = {