Improve Z-Wave heat alarm binary sensors discovery (#177486)

This commit is contained in:
Martin Hjelmare
2026-08-05 18:13:34 +02:00
committed by GitHub
parent 03b6c98f5a
commit f9fa99ca45
6 changed files with 2600 additions and 34 deletions
@@ -10,6 +10,7 @@ from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY
from zwave_js_server.const.command_class.notification import (
CC_SPECIFIC_NOTIFICATION_TYPE,
AccessControlNotificationEvent,
HeatAlarmNotificationEvent,
NotificationEvent,
NotificationType,
PowerManagementNotificationEvent,
@@ -76,6 +77,17 @@ NOTIFICATION_WEATHER = "16"
NOTIFICATION_IRRIGATION = "17"
NOTIFICATION_GAS = "18"
# Generic device classes where heat notifications represent primary
# functionality rather than diagnostic.
# https://github.com/zwave-js/backlog/issues/119#issuecomment-3096168116
HEAT_PRIMARY_DEVICE_CLASSES: set[str | int] = {
"Appliance",
"Notification Sensor",
"Thermostat",
"Multilevel Sensor",
"Alarm Sensor",
}
# Deprecated/legacy synthetic Access Control door state notification
# event IDs that don't exist in zwave-js-server
ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632
@@ -188,12 +200,6 @@ LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset(
# The catch all description should not have a device class and be marked as diagnostic.
#
# The following notifications have been moved to diagnostic:
# Smoke Alarm
# - Alarm silenced
# - Replacement required
# - Replacement required, End-of-life
# - Maintenance required, planned periodic inspection
# - Maintenance required, dust in device
# CO Alarm
# - Carbon monoxide test
# - Replacement required
@@ -206,16 +212,6 @@ LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset(
# - Replacement required, End-of-life
# - Alarm silenced
# - Maintenance required, planned periodic inspection
# Heat Alarm
# - Rapid temperature rise (location provided)
# - Rapid temperature rise
# - Rapid temperature fall (location provided)
# - Rapid temperature fall
# - Heat alarm test
# - Alarm silenced
# - Replacement required, End-of-life
# - Maintenance required, dust in device
# - Maintenance required, planned periodic inspection
# Water Alarm
# - Replace water filter
@@ -227,6 +223,7 @@ LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset(
MIGRATED_NOTIFICATION_TYPES = {
NotificationType.SMOKE_ALARM,
NotificationType.ACCESS_CONTROL,
NotificationType.HEAT_ALARM,
NotificationType.POWER_MANAGEMENT,
}
@@ -267,24 +264,6 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
key=NOTIFICATION_CARBON_DIOXIDE,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat)
key=NOTIFICATION_HEAT,
states={1, 2, 5, 6},
device_class=BinarySensorDeviceClass.HEAT,
),
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - State ID's 8, A, B
key=NOTIFICATION_HEAT,
states={8, 10, 11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - All other State Id's
key=NOTIFICATION_HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A
key=NOTIFICATION_WATER,
@@ -1597,4 +1576,129 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={
CommandClass.NOTIFICATION,
},
type={ValueType.NUMBER},
any_available_states_keys={
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED,
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HEAT_ALARM)
},
),
device_class_generic=HEAT_PRIMARY_DEVICE_CLASSES,
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - overheat/underheat detected (primary)
key=NOTIFICATION_HEAT,
states={
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED,
},
device_class=BinarySensorDeviceClass.HEAT,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={
CommandClass.NOTIFICATION,
},
type={ValueType.NUMBER},
any_available_states_keys={
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED,
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HEAT_ALARM)
},
),
not_device_class_generic=HEAT_PRIMARY_DEVICE_CLASSES,
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - overheat/underheat detected (diagnostic)
key=NOTIFICATION_HEAT,
states={
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED,
},
device_class=BinarySensorDeviceClass.HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={
CommandClass.NOTIFICATION,
},
type={ValueType.NUMBER},
any_available_states_keys={
HeatAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
HeatAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
HeatAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HEAT_ALARM)
},
),
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - replacement/dust/inspection
key=NOTIFICATION_HEAT,
states={
HeatAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
HeatAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
HeatAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={
CommandClass.NOTIFICATION,
},
type={ValueType.NUMBER},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.HEAT_ALARM)
},
),
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - All other State Id's
# Rapid rise and fall are events, and not states.
key=NOTIFICATION_HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
not_states={
HeatAlarmNotificationEvent.IDLE,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_OVERHEAT_DETECTED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED_LOCATION_PROVIDED,
HeatAlarmNotificationEvent.HEAT_SENSOR_STATUS_UNDERHEAT_DETECTED,
HeatAlarmNotificationEvent.MAINTENANCE_STATUS_REPLACEMENT_REQUIRED_END_OF_LIFE,
HeatAlarmNotificationEvent.DUST_IN_DEVICE_STATUS_MAINTENANCE_REQUIRED_DUST_IN_DEVICE,
HeatAlarmNotificationEvent.PERIODIC_INSPECTION_STATUS_MAINTENANCE_REQUIRED_PLANNED_PERIODIC_INSPECTION,
},
),
entity_class=ZWaveNotificationBinarySensor,
),
]
@@ -110,6 +110,8 @@ class ZWaveDiscoverySchema:
firmware_version_range: FirmwareVersionRange | None = None
# [optional] the node's generic device class must match ANY of these values
device_class_generic: set[str] | None = None
# [optional] the node's or endpoint's generic device class must NOT match ANY of these values
not_device_class_generic: set[str] | None = None
# [optional] the node's specific device class must match ANY of these values
device_class_specific: set[str] | None = None
# [optional] additional values that ALL need to be present
@@ -1323,6 +1325,29 @@ def async_discover_single_value(
):
continue
# check not_device_class_generic
# Skip the schema if the endpoint's or the node's generic device class
# matches any of the excluded values.
if schema.not_device_class_generic and (
(
(endpoint := value.endpoint) is not None
and (node_endpoint := value.node.endpoints.get(endpoint)) is not None
and (device_class := node_endpoint.device_class) is not None
and any(
device_class.generic.label == val
for val in schema.not_device_class_generic
)
)
or (
(device_class := value.node.device_class) is not None
and any(
device_class.generic.label == val
for val in schema.not_device_class_generic
)
)
):
continue
# check device_class_specific
# If the value has an endpoint but it is missing on the node
# we can't match the endpoint device class to the schema device class.
@@ -191,6 +191,8 @@ class NewZWaveDiscoverySchema:
device_class_basic: set[str | int] | None = None
# [optional] the node's generic device class must match ANY of these values
device_class_generic: set[str | int] | None = None
# [optional] the node's or endpoint's generic device class must NOT match ANY of these values
not_device_class_generic: set[str | int] | None = None
# [optional] the node's specific device class must match ANY of these values
device_class_specific: set[str | int] | None = None
# [optional] additional values that ALL need to be present
+18
View File
@@ -212,6 +212,14 @@ def ring_keypad_state_fixture() -> dict[str, Any]:
return load_json_object_fixture("ring_keypad_state.json", DOMAIN)
@pytest.fixture(name="zooz_zac36_titan_valve_actuator_state")
def zooz_zac36_titan_valve_actuator_state_fixture() -> dict[str, Any]:
"""Load the Zooz ZAC36 Titan valve actuator node state fixture data."""
return load_json_object_fixture(
"zooz_zac36_titan_valve_actuator_state.json", DOMAIN
)
@pytest.fixture(name="nortek_thermostat_state", scope="package")
def nortek_thermostat_state_fixture() -> dict[str, Any]:
"""Load the nortek thermostat node state fixture data."""
@@ -978,6 +986,16 @@ def ring_keypad_fixture(client: MagicMock, ring_keypad_state: NodeDataType) -> N
return node
@pytest.fixture(name="zooz_zac36_titan_valve_actuator")
def zooz_zac36_titan_valve_actuator_fixture(
client: MagicMock, zooz_zac36_titan_valve_actuator_state: NodeDataType
) -> Node:
"""Mock a Zooz ZAC36 Titan valve actuator node."""
node = Node(client, zooz_zac36_titan_valve_actuator_state)
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="integration")
async def integration_fixture(
hass: HomeAssistant,
File diff suppressed because it is too large Load Diff
@@ -380,6 +380,45 @@ async def test_power_management_mains_disconnected_sensor(
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
@pytest.mark.usefixtures("zooz_zac36_titan_valve_actuator", "integration")
@pytest.mark.parametrize(
"entity_id",
[
"binary_sensor.titan_water_valve_actuator_overheat_detected",
"binary_sensor.titan_water_valve_actuator_underheat_detected",
],
)
async def test_heat_notification_sensor_diagnostic(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_id: str,
) -> None:
"""Test overheat/underheat is diagnostic for non-heat-relevant device classes."""
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.HEAT
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
@pytest.mark.usefixtures("climate_heatit_z_trm6", "integration")
async def test_heat_notification_sensor_primary(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test overheat/underheat is primary for heat-relevant generic device classes."""
entity_id = "binary_sensor.floor_thermostat_overheat_detected"
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.HEAT
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is None
@pytest.mark.usefixtures("wallmote_central_scene", "integration")
async def test_power_management_battery_charging_sensor(
hass: HomeAssistant,