Compare commits

..

4 Commits

Author SHA1 Message Date
Ville Skyttä
2294b7dfee Switch to actions/attest for build provenance
https://github.com/actions/attest-build-provenance#usage
> As of version 4, actions/attest-build-provenance is simply a wrapper
> on top of actions/attest.
2026-03-11 21:54:36 +02:00
Oluwatobi Mustapha
30aec4d2ab Migrate OAuth helper token request exception handling in Google Sheets (#165000)
Signed-off-by: Oluwatobi Mustapha <oluwatobimustapha539@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 20:33:26 +01:00
AlCalzone
335abd7002 Support new Z-Wave JS "Opening state" notification variable (#165236) 2026-03-11 20:13:54 +01:00
Joakim Sørensen
3b3f0e9240 Bump hass-nabucasa from 1.15.0 to 2.0.0 (#165335) 2026-03-11 20:02:28 +01:00
22 changed files with 1097 additions and 539 deletions

View File

@@ -614,7 +614,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
"single_config_entry": true
}

View File

@@ -7,7 +7,12 @@ import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
@@ -39,11 +44,11 @@ async def async_setup_entry(
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
except OAuth2TokenRequestError as err:
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err

View File

@@ -140,14 +140,6 @@
"pump_status": {
"default": "mdi:pump"
},
"setpoint_change_source": {
"default": "mdi:hand-back-right",
"state": {
"external": "mdi:webhook",
"manual": "mdi:hand-back-right",
"schedule": "mdi:calendar-clock"
}
},
"tank_percentage": {
"default": "mdi:water-boiler"
},

View File

@@ -183,13 +183,6 @@ EVSE_FAULT_STATE_MAP = {
clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other",
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantSpeed: "constant_speed",
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kConstantPressure: "constant_pressure",
@@ -1586,48 +1579,4 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
state_class=None,
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSourceTimestamp",
translation_key="setpoint_change_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
device_to_ha=matter_epoch_seconds_to_utc,
),
entity_class=MatterSensor,
required_attributes=(
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatSetpointChangeAmount",
translation_key="setpoint_change_amount",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
),
]

View File

@@ -555,20 +555,6 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_amount": {
"name": "Last change amount"
},
"setpoint_change_source": {
"name": "Last change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"setpoint_change_timestamp": {
"name": "Last change"
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -2,13 +2,16 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import IntEnum
from typing import TYPE_CHECKING, cast
from zwave_js_server.const import CommandClass
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,
NotificationEvent,
NotificationType,
SmokeAlarmNotificationEvent,
@@ -29,6 +32,10 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
from .helpers import (
get_opening_state_notification_value,
is_opening_state_notification_value,
)
from .models import (
NewZWaveDiscoverySchema,
ValueType,
@@ -59,6 +66,42 @@ NOTIFICATION_WEATHER = "16"
NOTIFICATION_IRRIGATION = "17"
NOTIFICATION_GAS = "18"
# 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
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.
class OpeningState(IntEnum):
"""Opening state values exposed by Access Control notifications."""
CLOSED = 0
OPEN = 1
TILTED = 2
# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors.
def _legacy_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:
"""Return if Opening state represents open."""
return opening_state is OpeningState.OPEN
def _legacy_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:
"""Return if Opening state represents tilted."""
return opening_state is OpeningState.TILTED
@dataclass(frozen=True, kw_only=True)
class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
@@ -82,6 +125,14 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
state_key: str
@dataclass(frozen=True, kw_only=True)
class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription):
"""Describe a legacy Access Control binary sensor that derives state from Opening state."""
state_key: int
parse_opening_state: Callable[[OpeningState], bool]
# Mappings for Notification sensors
# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx
#
@@ -127,6 +178,7 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
# to use the new discovery schema and we've removed the old discovery code.
MIGRATED_NOTIFICATION_TYPES = {
NotificationType.SMOKE_ALARM,
NotificationType.ACCESS_CONTROL,
}
NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = (
@@ -202,26 +254,6 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
key=NOTIFICATION_WATER,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
key=NOTIFICATION_ACCESS_CONTROL,
states={1, 2, 3, 4},
device_class=BinarySensorDeviceClass.LOCK,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 11 (Lock jammed)
key=NOTIFICATION_ACCESS_CONTROL,
states={11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id 22 (door/window open)
key=NOTIFICATION_ACCESS_CONTROL,
not_states={23},
states={22},
device_class=BinarySensorDeviceClass.DOOR,
),
NotificationZWaveJSEntityDescription(
# NotificationType 7: Home Security - State Id's 1, 2 (intrusion)
key=NOTIFICATION_HOME_SECURITY,
@@ -364,6 +396,10 @@ 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.
if is_opening_state_notification_value(info.primary_value):
return False
return len(info.primary_value.metadata.states) > 1
@@ -406,6 +442,13 @@ 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 ZWaveLegacyDoorStateBinarySensor
):
entities.append(
ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
)
elif isinstance(info, NewZwaveDiscoveryInfo):
pass # other entity classes are not migrated yet
elif info.platform_hint == "notification":
@@ -542,6 +585,51 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
return int(self.info.primary_value.value) == int(self.state_key)
class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
"""DEPRECATED: Legacy door state binary sensors.
These entities exist purely for backwards compatibility with users who had
door state binary sensors before the Opening state value was introduced.
They are disabled by default when the Opening state value is present and
should not be extended. State is derived from the Opening state notification
value using the parse_opening_state function defined on the entity description.
"""
entity_description: OpeningStateZWaveJSEntityDescription
def __init__(
self,
config_entry: ZwaveJSConfigEntry,
driver: Driver,
info: NewZwaveDiscoveryInfo,
) -> None:
"""Initialize a legacy Door state binary sensor entity."""
super().__init__(config_entry, driver, info)
opening_state_value = get_opening_state_notification_value(self.info.node)
assert opening_state_value is not None # guaranteed by required_values schema
self._opening_state_value_id = opening_state_value.value_id
self.watched_value_ids.add(opening_state_value.value_id)
self._attr_unique_id = (
f"{self._attr_unique_id}.{self.entity_description.state_key}"
)
@property
def is_on(self) -> bool | None:
"""Return if the sensor is on or off."""
value = self.info.node.values.get(self._opening_state_value_id)
if value is None:
return None
opening_state = value.value
if opening_state is None:
return None
try:
return self.entity_description.parse_opening_state(
OpeningState(int(opening_state))
)
except TypeError, ValueError:
return None
class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
"""Representation of a Z-Wave binary_sensor from a property."""
@@ -586,7 +674,392 @@ class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor):
)
OPENING_STATE_NOTIFICATION_SCHEMA = ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Opening state"},
type={ValueType.NUMBER},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
)
DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Lock state"},
type={ValueType.NUMBER},
any_available_states_keys={1, 2, 3, 4},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
key=NOTIFICATION_ACCESS_CONTROL,
states={1, 2, 3, 4},
device_class=BinarySensorDeviceClass.LOCK,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Lock state"},
type={ValueType.NUMBER},
any_available_states_keys={11},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 11 (Lock jammed)
key=NOTIFICATION_ACCESS_CONTROL,
states={11},
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
entity_class=ZWaveNotificationBinarySensor,
),
# -------------------------------------------------------------------
# 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.
# All schemas below use ZWaveLegacyDoorStateBinarySensor and are
# disabled by default (entity_registry_enabled_default=False).
# -------------------------------------------------------------------
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state (simple)"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
device_class=BinarySensorDeviceClass.DOOR,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state (simple)"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
device_class=BinarySensorDeviceClass.DOOR,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={ACCESS_CONTROL_DOOR_STATE_OPEN_TILT},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
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,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door tilt state"},
type={ValueType.NUMBER},
any_available_states_keys={OpeningState.OPEN},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
required_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=OpeningStateZWaveJSEntityDescription(
key="legacy_access_control_door_tilt_state_tilted",
name="Window/door is tilted",
state_key=OpeningState.OPEN,
parse_opening_state=_legacy_is_tilted,
entity_registry_enabled_default=False,
),
entity_class=ZWaveLegacyDoorStateBinarySensor,
),
# -------------------------------------------------------------------
# Access Control door/window binary sensors for devices that do NOT have the
# new "Opening state" notification value. These replace the old-style discovery
# that used NOTIFICATION_SENSOR_MAPPINGS.
#
# Each property_key uses two schemas so that only the "open" state entity gets
# device_class=DOOR, while the other state entities (e.g. "closed") do not.
# The first schema uses allow_multi=True so it does not consume the value, allowing
# the second schema to also match and create entities for the remaining states.
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state (simple)"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
key=NOTIFICATION_ACCESS_CONTROL,
states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
device_class=BinarySensorDeviceClass.DOOR,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state (simple)"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
entity_description=NotificationZWaveJSEntityDescription(
key=NOTIFICATION_ACCESS_CONTROL,
not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
key=NOTIFICATION_ACCESS_CONTROL,
states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
device_class=BinarySensorDeviceClass.DOOR,
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door state"},
type={ValueType.NUMBER},
any_available_states_keys={
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN
},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
entity_description=NotificationZWaveJSEntityDescription(
key=NOTIFICATION_ACCESS_CONTROL,
not_states={AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN},
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
property_key={"Door tilt state"},
type={ValueType.NUMBER},
any_available_states_keys={OpeningState.OPEN},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
absent_values=[OPENING_STATE_NOTIFICATION_SCHEMA],
entity_description=NotificationZWaveJSEntityDescription(
key=NOTIFICATION_ACCESS_CONTROL,
states={OpeningState.OPEN},
),
entity_class=ZWaveNotificationBinarySensor,
),
NewZWaveDiscoverySchema(
platform=Platform.BINARY_SENSOR,
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.NOTIFICATION},
property={"Access Control"},
type={ValueType.NUMBER},
any_available_cc_specific={
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
},
),
allow_multi=True,
entity_description=NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - All other notification values.
# not_states excludes states already handled by more specific schemas above,
# so this catch-all only fires for genuinely unhandled property keys
# (e.g. barrier, keypad, credential events).
key=NOTIFICATION_ACCESS_CONTROL,
entity_category=EntityCategory.DIAGNOSTIC,
not_states={
0,
# Lock state values (Lock state schemas consume the value when state 11 is
# available, but may not when state 11 is absent)
1,
2,
3,
4,
11,
# Door state (simple) / Door state values
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR,
ACCESS_CONTROL_DOOR_STATE_OPEN_TILT,
},
),
entity_class=ZWaveNotificationBinarySensor,
),
# -------------------------------------------------------------------
NewZWaveDiscoverySchema(
# Hoppe eHandle ConnectSense (0x0313:0x0701:0x0002) - window tilt sensor.
# The window tilt state is exposed as a binary sensor that is disabled by default

View File

@@ -207,3 +207,7 @@ COVER_TILT_PROPERTY_KEYS: set[str | int | None] = {
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE,
WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION,
}
# notification
NOTIFICATION_ACCESS_CONTROL_PROPERTY = "Access Control"
OPENING_STATE_PROPERTY_KEY = "Opening state"

View File

@@ -16,6 +16,10 @@ from zwave_js_server.const import (
ConfigurationValueType,
LogLevel,
)
from zwave_js_server.const.command_class.notification import (
CC_SPECIFIC_NOTIFICATION_TYPE,
NotificationType,
)
from zwave_js_server.model.controller import Controller, ProvisioningEntry
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.log_config import LogConfig
@@ -53,6 +57,8 @@ from .const import (
DOMAIN,
LIB_LOGGER,
LOGGER,
NOTIFICATION_ACCESS_CONTROL_PROPERTY,
OPENING_STATE_PROPERTY_KEY,
)
from .models import ZwaveJSConfigEntry
@@ -126,6 +132,37 @@ def get_value_of_zwave_value(value: ZwaveValue | None) -> Any | None:
return value.value if value else None
def _get_notification_type(value: ZwaveValue) -> int | None:
"""Return the notification type for a value, if available."""
return value.metadata.cc_specific.get(CC_SPECIFIC_NOTIFICATION_TYPE)
def is_opening_state_notification_value(value: ZwaveValue) -> bool:
"""Return if the value is the Access Control Opening state notification."""
if (
value.command_class != CommandClass.NOTIFICATION
or _get_notification_type(value) != NotificationType.ACCESS_CONTROL
):
return False
return (
value.property_ == NOTIFICATION_ACCESS_CONTROL_PROPERTY
and value.property_key == OPENING_STATE_PROPERTY_KEY
)
def get_opening_state_notification_value(node: ZwaveNode) -> ZwaveValue | None:
"""Return the Access Control Opening state value for a node."""
value_id = get_value_id_str(
node,
CommandClass.NOTIFICATION,
NOTIFICATION_ACCESS_CONTROL_PROPERTY,
None,
OPENING_STATE_PROPERTY_KEY,
)
return node.values.get(value_id)
async def async_enable_statistics(driver: Driver) -> None:
"""Enable statistics on the driver."""
await driver.async_enable_statistics("Home Assistant", HA_VERSION)

View File

@@ -859,13 +859,22 @@ class ZWaveListSensor(ZwaveSensor):
)
# Entity class attributes
# Notification sensors have the following name mapping (variables are property
# keys, name is property)
# Notification sensors use the notification event label as the name
# (property_key_name/metadata.label, falling back to property_name)
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
self._attr_name = self.generate_name(
alternate_value_name=self.info.primary_value.property_name,
additional_info=[self.info.primary_value.property_key_name],
)
if info.platform_hint == "notification":
self._attr_name = self.generate_name(
alternate_value_name=(
info.primary_value.property_key_name
or info.primary_value.metadata.label
or info.primary_value.property_name
)
)
else:
self._attr_name = self.generate_name(
alternate_value_name=info.primary_value.property_name,
additional_info=[info.primary_value.property_key_name],
)
if self.info.primary_value.metadata.states:
self._attr_device_class = SensorDeviceClass.ENUM
self._attr_options = list(info.primary_value.metadata.states.values())

View File

@@ -37,7 +37,7 @@ fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.9.1
hass-nabucasa==1.15.0
hass-nabucasa==2.0.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260304.0

View File

@@ -51,7 +51,7 @@ dependencies = [
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==1.15.0",
"hass-nabucasa==2.0.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",

2
requirements.txt generated
View File

@@ -25,7 +25,7 @@ cronsim==2.7
cryptography==46.0.5
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
hass-nabucasa==1.15.0
hass-nabucasa==2.0.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.3.3

2
requirements_all.txt generated
View File

@@ -1176,7 +1176,7 @@ habluetooth==5.9.1
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.15.0
hass-nabucasa==2.0.0
# homeassistant.components.splunk
hass-splunk==0.1.4

View File

@@ -1046,7 +1046,7 @@ habluetooth==5.9.1
hanna-cloud==0.0.7
# homeassistant.components.cloud
hass-nabucasa==1.15.0
hass-nabucasa==2.0.0
# homeassistant.components.splunk
hass-splunk==0.1.4

View File

@@ -122,7 +122,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# pyblackbird > pyserial-asyncio
"pyblackbird": {"pyserial-asyncio"}
},
"cloud": {"hass-nabucasa": {"async-timeout"}, "snitun": {"async-timeout"}},
"cmus": {
# https://github.com/mtreinish/pycmus/issues/4
# pycmus > pbr > setuptools

View File

@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
import http
import time
from typing import Any
from unittest.mock import patch
from unittest.mock import Mock, patch
from freezegun import freeze_time
from gspread.exceptions import APIError
@@ -29,7 +29,12 @@ from homeassistant.components.google_sheets.services import (
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import (
HomeAssistantError,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
ServiceValidationError,
)
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -199,6 +204,64 @@ async def test_expired_token_refresh_failure(
assert entries[0].state is expected_state
async def test_setup_oauth_reauth_error(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test a token refresh reauth error puts the config entry in setup error state."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("client-id", "client-secret"),
DOMAIN,
)
with (
patch.object(config_entry, "async_start_reauth") as mock_async_start_reauth,
patch(
"homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestReauthError(
domain=DOMAIN, request_info=Mock()
),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_ERROR
mock_async_start_reauth.assert_called_once_with(hass)
async def test_setup_oauth_transient_error(
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test a token refresh transient error sets the config entry to retry setup."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("client-id", "client-secret"),
DOMAIN,
)
with patch(
"homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid",
side_effect=OAuth2TokenRequestTransientError(
domain=DOMAIN, request_info=Mock()
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("add_created_column_param", "expected_row"),
[

View File

@@ -4075,171 +4075,6 @@
'state': '100',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Eve Thermo 20ECD1701 Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Eve Thermo 20ECD1701 Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Eve Thermo 20ECD1701 Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.eve_thermo_20ecd1701_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[eve_thermo_v5][sensor.eve_thermo_20ecd1701_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -11105,171 +10940,6 @@
'state': '25',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Mock Thermostat Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-01-01T00:00:00+00:00',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Thermostat Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -233,100 +233,6 @@ async def test_eve_thermo_sensor(
assert state.state == "18.0"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeSource sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change_source"
# Initial state and options
state = hass.states.get(entity_id)
assert state
assert state.state == "manual"
assert state.attributes["options"] == ["manual", "schedule", "external"]
# Change to schedule
set_node_attribute(matter_node, 1, 513, 48, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "schedule"
# Change to external
set_node_attribute(matter_node, 1, 513, 48, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "external"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_timestamp(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeSourceTimestamp sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change"
# Initial is unknown per snapshot
state = hass.states.get(entity_id)
assert state
assert state.state == "unknown"
# Update to 2024-01-01 00:00:00+00:00 (Matter epoch seconds since 2000)
set_node_attribute(matter_node, 1, 513, 50, 757382400)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "2024-01-01T00:00:00+00:00"
# Set to zero should yield unknown
set_node_attribute(matter_node, 1, 513, 50, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"])
async def test_eve_thermo_v5_setpoint_change_amount(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Eve Thermo v5 SetpointChangeAmount sensor."""
entity_id = "sensor.eve_thermo_20ecd1701_last_change_amount"
# Initial per snapshot
state = hass.states.get(entity_id)
assert state
assert state.state == "0.0"
# Update to 2.0°C (200 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, 200)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "2.0"
# Update to -0.5°C (-50 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, -50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get(entity_id)
assert state
assert state.state == "-0.5"
@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"])
async def test_thermostat_outdoor(
hass: HomeAssistant,

View File

@@ -105,6 +105,33 @@
},
"value": false
},
{
"commandClass": 113,
"commandClassName": "Notification",
"property": "Access Control",
"propertyKey": "Opening state",
"propertyName": "Access Control",
"propertyKeyName": "Opening state",
"ccVersion": 8,
"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
},
"value": 0
},
{
"commandClass": 113,
"commandClassName": "Notification",

View File

@@ -1,6 +1,8 @@
"""Test the Z-Wave JS binary sensor platform."""
import copy
from datetime import timedelta
from typing import Any
import pytest
from zwave_js_server.event import Event
@@ -31,6 +33,94 @@ from .common import (
from tests.common import MockConfigEntry, async_fire_time_changed
def _add_door_tilt_state_value(node_state: dict[str, Any]) -> dict[str, Any]:
"""Return a node state with a Door tilt state notification value added."""
updated_state = copy.deepcopy(node_state)
updated_state["values"].append(
{
"commandClass": 113,
"commandClassName": "Notification",
"property": "Access Control",
"propertyKey": "Door tilt state",
"propertyName": "Access Control",
"propertyKeyName": "Door tilt state",
"ccVersion": 8,
"metadata": {
"type": "number",
"readable": True,
"writeable": False,
"label": "Door tilt state",
"ccSpecific": {"notificationType": 6},
"min": 0,
"max": 255,
"states": {
"0": "Window/door is not tilted",
"1": "Window/door is tilted",
},
"stateful": True,
"secret": False,
},
"value": 0,
}
)
return updated_state
def _add_barrier_status_value(node_state: dict[str, Any]) -> dict[str, Any]:
"""Return a node state with a Barrier status Access Control notification value added."""
updated_state = copy.deepcopy(node_state)
updated_state["values"].append(
{
"commandClass": 113,
"commandClassName": "Notification",
"property": "Access Control",
"propertyKey": "Barrier status",
"propertyName": "Access Control",
"propertyKeyName": "Barrier status",
"ccVersion": 8,
"metadata": {
"type": "number",
"readable": True,
"writeable": False,
"label": "Barrier status",
"ccSpecific": {"notificationType": 6},
"min": 0,
"max": 255,
"states": {
"0": "idle",
"64": "Barrier performing initialization process",
"72": "Barrier safety beam obstacle",
},
"stateful": True,
"secret": False,
},
"value": 0,
}
)
return updated_state
def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str, Any]:
"""Return a node state with Access Control lock state notification states 1-4."""
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") == "Lock state"
):
value_data["metadata"].setdefault("states", {}).update(
{
"1": "Manual lock operation",
"2": "Manual unlock operation",
"3": "RF lock operation",
"4": "RF unlock operation",
}
)
break
return updated_state
@pytest.fixture
def platforms() -> list[str]:
"""Fixture to specify platforms to test."""
@@ -305,6 +395,322 @@ async def test_property_sensor_door_status(
assert state.state == STATE_UNKNOWN
async def test_opening_state_notification_does_not_create_binary_sensors(
hass: HomeAssistant,
client,
hoppe_ehandle_connectsense_state,
) -> None:
"""Test Opening state does not fan out into per-state binary sensors."""
# 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)
state["values"] = [
v
for v in state["values"]
if v.get("commandClass") != 48 # Binary Sensor CC
]
node = Node(client, 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()
assert not hass.states.async_all("binary_sensor")
async def test_opening_state_disables_legacy_window_door_notification_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client,
hoppe_ehandle_connectsense_state,
) -> None:
"""Test Opening state disables legacy Access Control window/door sensors."""
node = Node(
client,
_add_door_tilt_state_value(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()
legacy_entries = [
entry
for entry in entity_registry.entities.values()
if entry.domain == "binary_sensor"
and entry.platform == "zwave_js"
and (
entry.original_name
in {
"Window/door is open",
"Window/door is closed",
"Window/door is open in regular position",
"Window/door is open in tilt position",
}
or (
entry.original_name == "Window/door is tilted"
and entry.original_device_class != BinarySensorDeviceClass.WINDOW
)
)
]
assert len(legacy_entries) == 7
assert all(
entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
for entry in legacy_entries
)
assert all(hass.states.get(entry.entity_id) is None for entry in legacy_entries)
async def test_reenabled_legacy_door_state_entity_follows_opening_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client,
hoppe_ehandle_connectsense_state,
) -> None:
"""Test a re-enabled legacy Door state entity derives state from Opening state."""
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()
legacy_entry = next(
entry
for entry in entity_registry.entities.values()
if entry.platform == "zwave_js"
and entry.original_name == "Window/door is open in tilt position"
)
entity_registry.async_update_entity(legacy_entry.entity_id, disabled_by=None)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
state = hass.states.get(legacy_entry.entity_id)
assert state
assert 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": 2,
"prevValue": 0,
"propertyName": "Access Control",
"propertyKeyName": "Opening state",
},
},
)
)
state = hass.states.get(legacy_entry.entity_id)
assert state
assert state.state == STATE_ON
async def test_legacy_door_state_entities_follow_opening_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client,
hoppe_ehandle_connectsense_state,
) -> None:
"""Test all legacy door state entities correctly derive state from Opening state."""
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()
# Re-enable all 6 legacy door state entities.
legacy_names = {
"Window/door is open",
"Window/door is closed",
"Window/door is open in regular position",
"Window/door is open in tilt position",
}
legacy_entries = [
e
for e in entity_registry.entities.values()
if e.domain == "binary_sensor"
and e.platform == "zwave_js"
and e.original_name in legacy_names
]
assert len(legacy_entries) == 6
for legacy_entry in legacy_entries:
entity_registry.async_update_entity(legacy_entry.entity_id, disabled_by=None)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
# With Opening state = 0 (Closed), all "open" entities should be OFF and
# all "closed" entities should be ON.
open_entries = [
e for e in legacy_entries if e.original_name == "Window/door is open"
]
closed_entries = [
e for e in legacy_entries if e.original_name == "Window/door is closed"
]
open_regular_entries = [
e
for e in legacy_entries
if e.original_name == "Window/door is open in regular position"
]
open_tilt_entries = [
e
for e in legacy_entries
if e.original_name == "Window/door is open in tilt position"
]
for e in open_entries + open_regular_entries + open_tilt_entries:
state = hass.states.get(e.entity_id)
assert state, f"{e.entity_id} should have a state"
assert state.state == STATE_OFF, (
f"{e.entity_id} ({e.original_name}) should be OFF when Opening state=Closed"
)
for e in closed_entries:
state = hass.states.get(e.entity_id)
assert state, f"{e.entity_id} should have a state"
assert state.state == STATE_ON, (
f"{e.entity_id} ({e.original_name}) should be ON when Opening state=Closed"
)
# Update Opening state to 1 (Open).
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()
# All "open" entities should now be ON, "closed" OFF, "tilt" OFF.
for e in open_entries + open_regular_entries:
state = hass.states.get(e.entity_id)
assert state, f"{e.entity_id} should have a state"
assert state.state == STATE_ON, (
f"{e.entity_id} ({e.original_name}) should be ON when Opening state=Open"
)
for e in closed_entries + open_tilt_entries:
state = hass.states.get(e.entity_id)
assert state, f"{e.entity_id} should have a state"
assert state.state == STATE_OFF, (
f"{e.entity_id} ({e.original_name}) should be OFF when Opening state=Open"
)
async def test_access_control_lock_state_notification_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client,
lock_august_asl03_state,
) -> None:
"""Test Access Control lock state notification sensors from new discovery schemas."""
node = Node(client, _add_lock_state_notification_states(lock_august_asl03_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()
lock_state_entities = [
state
for state in hass.states.async_all("binary_sensor")
if state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.LOCK
]
assert len(lock_state_entities) == 4
assert all(state.state == STATE_OFF for state in lock_state_entities)
jammed_entry = next(
entry
for entry in entity_registry.entities.values()
if entry.domain == "binary_sensor"
and entry.platform == "zwave_js"
and entry.original_name == "Lock jammed"
)
assert jammed_entry.original_device_class == BinarySensorDeviceClass.PROBLEM
assert jammed_entry.entity_category == EntityCategory.DIAGNOSTIC
jammed_state = hass.states.get(jammed_entry.entity_id)
assert jammed_state
assert jammed_state.state == STATE_OFF
async def test_access_control_catch_all_with_opening_state_present(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
client,
hoppe_ehandle_connectsense_state,
) -> None:
"""Test that unrelated Access Control values are discovered even when Opening state is present."""
node = Node(
client,
_add_barrier_status_value(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()
# The two non-idle barrier states should each become a diagnostic binary sensor
barrier_entries = [
reg_entry
for reg_entry in entity_registry.entities.values()
if reg_entry.domain == "binary_sensor"
and reg_entry.platform == "zwave_js"
and reg_entry.entity_category == EntityCategory.DIAGNOSTIC
and reg_entry.original_name
and "barrier" in reg_entry.original_name.lower()
]
assert len(barrier_entries) == 2, (
f"Expected 2 barrier status sensors, got {[e.original_name for e in barrier_entries]}"
)
for reg_entry in barrier_entries:
state = hass.states.get(reg_entry.entity_id)
assert state is not None
assert state.state == STATE_OFF
async def test_config_parameter_binary_sensor(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,

View File

@@ -10,6 +10,7 @@ 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,
@@ -777,6 +778,37 @@ async def test_unit_change(hass: HomeAssistant, zp3111, client, integration) ->
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
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
CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_"
# controller statistics with initial state of 0
CONTROLLER_STATISTICS_SUFFIXES = {