Compare commits

..

21 Commits

Author SHA1 Message Date
Mike Degatano
47781b9aa6 Remove code notary related unsupported reasons 2026-03-12 20:59:08 +00:00
Arie Catsman
e14d88ff55 Bump pyenphase to 2.4.6 (#165402) 2026-03-12 20:06:49 +00:00
Erwin Douna
d04efbfe48 Add platinum badge to Portainer (#165048)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-03-12 19:30:31 +01:00
AlCalzone
3f35cd5cd2 Remove Z-Wave Installer panel (#165388)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: AlCalzone <17641229+AlCalzone@users.noreply.github.com>
2026-03-12 17:30:28 +01:00
AlCalzone
86ffd58665 Instruct AI to add type annotations to tests (#165386)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 17:10:30 +01:00
prana-dev-official
6206392b28 Bump prana-local-api to 0.12.0 (#165394) 2026-03-12 17:05:26 +01:00
dvdinth
b7c36c707f Add IntelliClima Sensor platform (#163901)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-12 16:33:34 +01:00
Joakim Sørensen
973c32b99d Add latency results if available to the support package (#165377) 2026-03-12 10:44:08 +01:00
Erik Montnemery
951775bea6 Add window triggers (#165230) 2026-03-12 10:18:42 +01:00
Artur Pragacz
0f2dbdf4f4 Fix logging of unavailable entities in entity call (#165370) 2026-03-12 09:53:30 +01:00
Jan-Philipp Benecke
443ff7efe1 Bump aiowebdav2 to 0.6.2 (#165353) 2026-03-12 08:17:41 +01:00
Jeef
0ee6b954df Bump intellifire4py to 4.4.0 (#165356) 2026-03-12 08:15:48 +01:00
Norbert Rittel
5681acf0e1 Sentence-case "API token" and "username/password" in growatt (#165368) 2026-03-12 07:49:35 +01:00
Andres Ruiz
a94458b8bc Bump waterfurnace version v1.6.2 (#165348) 2026-03-12 07:49:12 +01:00
Josef Zweck
f3c38ba2d3 Add "cleaning_up" stage to backup (#165349) 2026-03-12 07:28:17 +01:00
Jan Bouwhuis
c1acd1d860 Allow an MQTT entity to show as a group (#152270)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 22:25:28 +01:00
chli1
f4748aa63d fix #163316: FRITZ!SmartHome integration not showing boost status on … (#164574) 2026-03-11 22:19:43 +01:00
Brett Adams
31f4f618cc Fix duplicate energy remaining sensors in Tessie (#165102) 2026-03-11 21:39:35 +01: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
75 changed files with 2760 additions and 723 deletions

View File

@@ -18,6 +18,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
When writing or modifying tests, ensure all test function parameters have type annotations.
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

View File

@@ -15,6 +15,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
When writing or modifying tests, ensure all test function parameters have type annotations.
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

2
CODEOWNERS generated
View File

@@ -1905,6 +1905,8 @@ build.json @home-assistant/supervisor
/tests/components/wiffi/ @mampfes
/homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core
/tests/components/window/ @home-assistant/core
/homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek

View File

@@ -245,6 +245,7 @@ DEFAULT_INTEGRATIONS = {
"garage_door",
"gate",
"humidity",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

View File

@@ -161,6 +161,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"window",
}

View File

@@ -144,6 +144,7 @@ class CreateBackupStage(StrEnum):
ADDONS = "addons"
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
DOCKER_CONFIG = "docker_config"
CLEANING_UP = "cleaning_up"
FINISHING_FILE = "finishing_file"
FOLDERS = "folders"
HOME_ASSISTANT = "home_assistant"
@@ -1290,6 +1291,13 @@ class BackupManager:
)
# delete old backups more numerous than copies
# try this regardless of agent errors above
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.CLEANING_UP,
state=CreateBackupState.IN_PROGRESS,
)
)
await delete_backups_exceeding_configured_count(self)
finally:

View File

@@ -516,6 +516,8 @@ class DownloadSupportPackageView(HomeAssistantView):
hass_info: dict[str, Any],
domains_info: dict[str, dict[str, str]],
) -> str:
cloud = hass.data[DATA_CLOUD]
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
@@ -572,6 +574,15 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n"
)
# Add stored latency response if available
if locations := cloud.remote.latency_by_location:
markdown += "## Latency by location\n\n"
markdown += "Location | Latency (ms)\n"
markdown += "--- | ---\n"
for location in sorted(locations):
markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n"
markdown += "\n"
# Add installed packages section
try:
installed_packages = await async_get_installed_packages()

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

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.5"],
"requirements": ["pyenphase==2.4.6"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -179,7 +179,9 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return PRESET_HOLIDAY
if self.data.summer_active:
return PRESET_SUMMER
if self.data.target_temperature == ON_API_TEMPERATURE:
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
self.data, "boost_active", False
):
return PRESET_BOOST
if self.data.target_temperature == self.data.comfort_temperature:
return PRESET_COMFORT

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

@@ -38,16 +38,16 @@
"token_auth": {
"data": {
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API Token"
"token": "API token"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},
"user": {
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"menu_options": {
"password_auth": "Username & Password",
"token_auth": "API Token (MIN/TLX only)"
"password_auth": "Username/password",
"token_auth": "API token (MIN/TLX only)"
},
"title": "Choose authentication method"
}

View File

@@ -225,10 +225,6 @@
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Connectivity check disabled"
},
"unsupported_content_trust": {
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Content-trust check disabled"
},
"unsupported_dbus": {
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
"title": "Unsupported system - D-Bus issues"
@@ -281,10 +277,6 @@
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Unsupported software"
},
"unsupported_source_mods": {
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor source modifications"
},
"unsupported_supervisor_version": {
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
"title": "Unsupported system - Supervisor version"

View File

@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import LOGGER
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
PLATFORMS = [Platform.FAN, Platform.SELECT]
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(

View File

@@ -0,0 +1,101 @@
"""Sensor platform for IntelliClima VMC."""
from collections.abc import Callable
from dataclasses import dataclass
from pyintelliclima.intelliclima_types import IntelliClimaECO
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
from .entity import IntelliClimaECOEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IntelliClimaSensorEntityDescription(SensorEntityDescription):
"""Describes a sensor entity."""
value_fn: Callable[[IntelliClimaECO], int | float | str | None]
INTELLICLIMA_SENSORS: tuple[IntelliClimaSensorEntityDescription, ...] = (
IntelliClimaSensorEntityDescription(
key="temperature",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda device_data: float(device_data.tamb),
),
IntelliClimaSensorEntityDescription(
key="humidity",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda device_data: float(device_data.rh),
),
IntelliClimaSensorEntityDescription(
key="voc",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda device_data: float(device_data.voc_state),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: IntelliClimaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a IntelliClima Sensors."""
coordinator = entry.runtime_data
entities: list[IntelliClimaSensor] = [
IntelliClimaSensor(
coordinator=coordinator, device=ecocomfort2, description=description
)
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
for description in INTELLICLIMA_SENSORS
]
async_add_entities(entities)
class IntelliClimaSensor(IntelliClimaECOEntity, SensorEntity):
"""Extends IntelliClimaEntity with Sensor specific logic."""
entity_description: IntelliClimaSensorEntityDescription
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
description: IntelliClimaSensorEntityDescription,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
@property
def native_value(self) -> int | float | str | None:
"""Use this to get the correct value."""
return self.entity_description.value_fn(self._device_data)

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
"requirements": ["intellifire4py==4.3.1"]
"requirements": ["intellifire4py==4.4.0"]
}

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

@@ -72,6 +72,7 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update",
"g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic",

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_GROUP,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
}
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -109,6 +109,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -48,6 +48,7 @@ from homeassistant.helpers.event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import (
@@ -78,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -133,6 +135,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -460,7 +463,7 @@ def async_setup_entity_entry_helper(
class MqttAttributesMixin(Entity):
"""Mixin used for platforms that support JSON attributes."""
"""Mixin used for platforms that support JSON attributes and group entities."""
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
@@ -468,10 +471,13 @@ class MqttAttributesMixin(Entity):
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
group: IntegrationSpecificGroup | None
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
"""Initialize the JSON attributes and handle group entities."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self.group = IntegrationSpecificGroup(self, config[CONF_GROUP])
self._attributes_config = config
async def async_added_to_hass(self) -> None:
@@ -482,6 +488,16 @@ class MqttAttributesMixin(Entity):
def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None:
"""Handle updated discovery message."""
if CONF_GROUP in config:
if self.group is not None:
self.group.member_unique_ids = config[CONF_GROUP]
else:
_LOGGER.info(
"Group member update received for entity %s, "
"but this entity was not initialized with the `group` option. "
"Reload the MQTT integration or restart Home Assistant to activate"
)
self._attributes_config = config
self._attributes_prepare_subscribe_topics()
@@ -543,7 +559,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload)
else:
if isinstance(json_dict, dict):
filtered_dict = {
filtered_dict: dict[str, Any] = {
k: v
for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/portainer",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "platinum",
"requirements": ["pyportainer==1.0.33"]
}

View File

@@ -5,7 +5,7 @@ from typing import Any
from prana_local_api_client.exceptions import PranaApiCommunicationError
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
from prana_local_api_client.prana_api_client import PranaLocalApiClient
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult

View File

@@ -12,7 +12,7 @@ from prana_local_api_client.exceptions import (
)
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
from prana_local_api_client.models.prana_state import PranaState
from prana_local_api_client.prana_api_client import PranaLocalApiClient
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["prana-api-client==0.10.0"],
"requirements": ["prana-api-client==0.12.0"],
"zeroconf": [
{
"type": "_prana._tcp.local."

View File

@@ -217,9 +217,6 @@
"energy_left": {
"default": "mdi:battery"
},
"energy_remaining": {
"default": "mdi:battery-medium"
},
"generator_power": {
"default": "mdi:generator-stationary"
},

View File

@@ -299,14 +299,6 @@ BATTERY_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="energy_remaining",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY_STORAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="lifetime_energy_used",
state_class=SensorStateClass.TOTAL_INCREASING,

View File

@@ -458,9 +458,6 @@
"energy_left": {
"name": "Energy left"
},
"energy_remaining": {
"name": "Energy remaining"
},
"generator_energy_exported": {
"name": "Generator exported"
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["waterfurnace"],
"quality_scale": "legacy",
"requirements": ["waterfurnace==1.5.1"]
"requirements": ["waterfurnace==1.6.2"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiowebdav2"],
"quality_scale": "bronze",
"requirements": ["aiowebdav2==0.6.1"]
"requirements": ["aiowebdav2==0.6.2"]
}

View File

@@ -0,0 +1,17 @@
"""Integration for window triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "window"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"closed": {
"trigger": "mdi:window-closed"
},
"opened": {
"trigger": "mdi:window-open"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "window",
"name": "Window",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/window",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted windows to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Window",
"triggers": {
"closed": {
"description": "Triggers after one or more windows close.",
"fields": {
"behavior": {
"description": "[%key:component::window::common::trigger_behavior_description%]",
"name": "[%key:component::window::common::trigger_behavior_name%]"
}
},
"name": "Window closed"
},
"opened": {
"description": "Triggers after one or more windows open.",
"fields": {
"behavior": {
"description": "[%key:component::window::common::trigger_behavior_description%]",
"name": "[%key:component::window::common::trigger_behavior_name%]"
}
},
"name": "Window opened"
}
}
}

View File

@@ -0,0 +1,36 @@
"""Provides triggers for windows."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
DEVICE_CLASSES_WINDOW: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.WINDOW,
COVER_DOMAIN: CoverDeviceClass.WINDOW,
}
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_WINDOW,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_WINDOW,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for windows."""
return TRIGGERS

View File

@@ -0,0 +1,29 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
closed:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: window
- domain: cover
device_class: window
opened:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: window
- domain: cover
device_class: window

View File

@@ -9,7 +9,6 @@ import logging
from typing import Any
from awesomeversion import AwesomeVersion
import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import CommandClass, RemoveNodeReason
from zwave_js_server.exceptions import (
@@ -94,7 +93,6 @@ from .const import (
CONF_ADDON_S2_UNAUTHENTICATED_KEY,
CONF_ADDON_SOCKET,
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
CONF_INTEGRATION_CREATED_ADDON,
CONF_KEEP_OLD_DEVICES,
CONF_LR_S2_ACCESS_CONTROL_KEY,
@@ -138,16 +136,8 @@ from .services import async_setup_services
CONNECT_TIMEOUT = 10
DRIVER_READY_TIMEOUT = 60
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_INSTALLER_MODE, default=False): cv.boolean,
}
)
},
extra=vol.ALLOW_EXTRA,
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
MIN_CONTROLLER_FIRMWARE_SDK_VERSION = AwesomeVersion("6.50.0")
PLATFORMS = [
@@ -171,7 +161,6 @@ PLATFORMS = [
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Z-Wave JS component."""
hass.data[DOMAIN] = config.get(DOMAIN, {})
for entry in hass.config_entries.async_entries(DOMAIN):
if not isinstance(entry.unique_id, str):
hass.config_entries.async_update_entry(

View File

@@ -84,7 +84,6 @@ from .const import (
ATTR_PARAMETERS,
ATTR_WAIT_FOR_RESULT,
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
DOMAIN,
EVENT_DEVICE_ADDED_TO_REGISTRY,
LOGGER,
@@ -476,7 +475,6 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_hard_reset_controller)
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
websocket_api.async_register_command(hass, websocket_get_integration_settings)
websocket_api.async_register_command(hass, websocket_backup_nvm)
websocket_api.async_register_command(hass, websocket_restore_nvm)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@@ -2965,28 +2963,6 @@ async def websocket_invoke_cc_api(
)
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/get_integration_settings",
}
)
def websocket_get_integration_settings(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get Z-Wave JS integration wide configuration."""
connection.send_result(
msg[ID],
{
# list explicitly to avoid leaking other keys and to set default
CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{

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

@@ -25,7 +25,6 @@ CONF_ADDON_S2_UNAUTHENTICATED_KEY = "s2_unauthenticated_key"
CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key"
CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key"
CONF_ADDON_SOCKET = "socket"
CONF_INSTALLER_MODE = "installer_mode"
CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon"
CONF_KEEP_OLD_DEVICES = "keep_old_devices"
CONF_NETWORK_KEY = "network_key"
@@ -207,3 +206,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

@@ -782,6 +782,8 @@ async def entity_service_call(
all_referenced,
)
entity_candidates = [e for e in entity_candidates if e.available]
if not target_all_entities:
assert referenced is not None
# Only report on explicit referenced entities
@@ -792,9 +794,6 @@ async def entity_service_call(
entities: list[Entity] = []
for entity in entity_candidates:
if not entity.available:
continue
# Skip entities that don't have the required device class.
if (
entity_device_classes is not None

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

12
requirements_all.txt generated
View File

@@ -443,7 +443,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.6.1
aiowebdav2==0.6.2
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -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
@@ -1322,7 +1322,7 @@ inkbird-ble==1.1.1
insteon-frontend-home-assistant==0.6.1
# homeassistant.components.intellifire
intellifire4py==4.3.1
intellifire4py==4.4.0
# homeassistant.components.iometer
iometer==0.4.0
@@ -1794,7 +1794,7 @@ poolsense==0.0.8
powerfox==2.1.1
# homeassistant.components.prana
prana-api-client==0.10.0
prana-api-client==0.12.0
# homeassistant.components.reddit
praw==7.5.0
@@ -2071,7 +2071,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.5
pyenphase==2.4.6
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -3247,7 +3247,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.5.1
waterfurnace==1.6.2
# homeassistant.components.watergate
watergate-local-api==2025.1.0

View File

@@ -428,7 +428,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.6.1
aiowebdav2==0.6.2
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -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
@@ -1171,7 +1171,7 @@ inkbird-ble==1.1.1
insteon-frontend-home-assistant==0.6.1
# homeassistant.components.intellifire
intellifire4py==4.3.1
intellifire4py==4.4.0
# homeassistant.components.iometer
iometer==0.4.0
@@ -1552,7 +1552,7 @@ poolsense==0.0.8
powerfox==2.1.1
# homeassistant.components.prana
prana-api-client==0.10.0
prana-api-client==0.12.0
# homeassistant.components.reddit
praw==7.5.0
@@ -1775,7 +1775,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.5
pyenphase==2.4.6
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -2735,7 +2735,7 @@ wallbox==0.9.0
watchdog==6.0.0
# homeassistant.components.waterfurnace
waterfurnace==1.5.1
waterfurnace==1.6.2
# homeassistant.components.watergate
watergate-local-api==2025.1.0

View File

@@ -123,6 +123,7 @@ NO_IOT_CLASS = [
"web_rtc",
"webhook",
"websocket_api",
"window",
"zone",
]

View File

@@ -2157,6 +2157,7 @@ NO_QUALITY_SCALE = [
"web_rtc",
"webhook",
"websocket_api",
"window",
"zone",
]

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

@@ -604,6 +604,14 @@ async def test_initiate_backup(
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
@@ -854,6 +862,14 @@ async def test_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": "upload_failed",
@@ -3549,6 +3565,14 @@ async def test_initiate_backup_per_agent_encryption(
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
"stage": CreateBackupStage.CLEANING_UP,
"state": CreateBackupState.IN_PROGRESS,
}
result = await ws_client.receive_json()
assert result["event"] == {
"manager_state": BackupManagerState.CREATE_BACKUP,
"reason": None,
@@ -3783,7 +3807,7 @@ async def test_upload_progress_event(
result = await ws_client.receive_json()
assert result["event"]["stage"] == CreateBackupStage.UPLOAD_TO_AGENTS
# Collect all upload progress events until the final state event
# Collect all upload progress events until the finishing backup stage event
progress_events = []
result = await ws_client.receive_json()
while "uploaded_bytes" in result["event"]:
@@ -3801,6 +3825,9 @@ async def test_upload_progress_event(
assert len(local_progress) == 1
assert local_progress[0]["uploaded_bytes"] == local_progress[0]["total_bytes"]
assert result["event"]["stage"] == CreateBackupStage.CLEANING_UP
result = await ws_client.receive_json()
assert result["event"]["state"] == CreateBackupState.COMPLETED
result = await ws_client.receive_json()

View File

@@ -66,6 +66,7 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]:
certificate_status=None,
instance_domain=None,
is_connected=False,
latency_by_location={},
)
mock_cloud.auth = MagicMock(spec=CognitoAuth)
mock_cloud.iot = MagicMock(

View File

@@ -87,6 +87,13 @@
</details>
## Latency by location
Location | Latency (ms)
--- | ---
Earth | 13.37
Moon | N/A
## Installed packages
<details><summary>Installed packages</summary>

View File

@@ -1907,6 +1907,10 @@ async def test_download_support_package(
cloud.remote.snitun_server = "us-west-1"
cloud.remote.certificate_status = CertificateStatus.READY
cloud.remote.latency_by_location = {
"Earth": {"avg": 13.37},
"Moon": {"avg": None},
}
cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00")
await cloud.client.async_system_message({"region": "xx-earth-616"})

View File

@@ -114,6 +114,7 @@ class FritzDeviceClimateMock(FritzEntityBaseMock):
has_thermostat = True
has_blind = False
holiday_active = False
boost_active = False
lock = "fake_locked"
present = True
summer_active = False

View File

@@ -442,7 +442,7 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_ECO
# test boost preset
# test boost preset by special temp
device.target_temperature = 127 # special temp from the api
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
@@ -453,6 +453,18 @@ async def test_preset_mode_update(hass: HomeAssistant, fritz: Mock) -> None:
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
# test boost preset by boost_active
device.target_temperature = 21
device.boost_active = True
next_update = dt_util.utcnow() + timedelta(seconds=200)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(ENTITY_ID)
assert fritz().update_devices.call_count == 5
assert state
assert state.attributes[ATTR_PRESET_MODE] == PRESET_BOOST
async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None:
"""Test adding new discovered devices during runtime."""

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

@@ -984,6 +984,14 @@ async def test_reader_writer_create(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1101,6 +1109,14 @@ async def test_reader_writer_create_addon_folder_error(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1221,6 +1237,14 @@ async def test_reader_writer_create_report_progress(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1286,6 +1310,14 @@ async def test_reader_writer_create_job_done(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1552,6 +1584,14 @@ async def test_reader_writer_create_per_agent_encryption(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
@@ -1728,10 +1768,51 @@ async def test_reader_writer_create_missing_reference_error(
@pytest.mark.usefixtures("hassio_client", "setup_backup_integration")
@pytest.mark.parametrize("exception", [SupervisorError("Boom!"), Exception("Boom!")])
@pytest.mark.parametrize(
("method", "download_call_count", "remove_call_count"),
[("download_backup", 1, 1), ("remove_backup", 1, 1)],
(
"exception",
"method",
"download_call_count",
"remove_call_count",
"expected_events_before_failed",
),
[
(
SupervisorError("Boom!"),
"download_backup",
1,
1,
[],
),
(
Exception("Boom!"),
"download_backup",
1,
1,
[
{
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
],
),
(
SupervisorError("Boom!"),
"remove_backup",
1,
1,
[],
),
(
Exception("Boom!"),
"remove_backup",
1,
1,
[],
),
],
)
async def test_reader_writer_create_download_remove_error(
hass: HomeAssistant,
@@ -1741,6 +1822,7 @@ async def test_reader_writer_create_download_remove_error(
method: str,
download_call_count: int,
remove_call_count: int,
expected_events_before_failed: list[dict[str, str]],
) -> None:
"""Test download and remove error when generating a backup."""
client = await hass_ws_client(hass)
@@ -1807,6 +1889,9 @@ async def test_reader_writer_create_download_remove_error(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
for expected_event in expected_events_before_failed:
assert response["event"] == expected_event
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": "upload_failed",
@@ -1974,6 +2059,14 @@ async def test_reader_writer_create_remote_backup(
response = await client.receive_json()
while "uploaded_bytes" in response["event"]:
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,
"stage": "cleaning_up",
"state": "in_progress",
}
response = await client.receive_json()
assert response["event"] == {
"manager_state": "create_backup",
"reason": None,

View File

@@ -0,0 +1,205 @@
# serializer version: 1
# name: test_all_sensor_entities.6
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'bluetooth',
'00:11:22:33:44:55',
),
tuple(
'mac',
'00:11:22:33:44:55',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'intelliclima',
'56789',
),
}),
'labels': set({
}),
'manufacturer': 'Fantini Cosmi',
'model': 'ECOCOMFORT 2.0',
'model_id': None,
'name': 'Test VMC',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '11223344',
'sw_version': '0.6.8',
'via_device_id': None,
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_humidity-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.test_vmc_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'intelliclima',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '56789_humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Test VMC Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_vmc_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '65.0',
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_temperature-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.test_vmc_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'intelliclima',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '56789_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Test VMC Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_vmc_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16.2',
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_volatile_organic_compounds_parts-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.test_vmc_volatile_organic_compounds_parts',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Volatile organic compounds parts',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: 'volatile_organic_compounds_parts'>,
'original_icon': None,
'original_name': 'Volatile organic compounds parts',
'platform': 'intelliclima',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '56789_voc',
'unit_of_measurement': 'ppm',
})
# ---
# name: test_all_sensor_entities[sensor.test_vmc_volatile_organic_compounds_parts-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'volatile_organic_compounds_parts',
'friendly_name': 'Test VMC Volatile organic compounds parts',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.test_vmc_volatile_organic_compounds_parts',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '89.0',
})
# ---

View File

@@ -0,0 +1,58 @@
"""Test IntelliClima Sensors."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
async def setup_intelliclima_sensor_only(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_cloud_interface: AsyncMock,
) -> AsyncGenerator[None]:
"""Set up IntelliClima integration with only the sensor platform."""
with (
patch("homeassistant.components.intelliclima.PLATFORMS", [Platform.SENSOR]),
):
await setup_integration(hass, mock_config_entry)
# Let tests run against this initialized state
yield
async def test_all_sensor_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
mock_cloud_interface: AsyncMock,
) -> None:
"""Test all entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
# There should be exactly three sensor entities
sensor_entries = [
entry
for entry in entity_registry.entities.values()
if entry.platform == "intelliclima" and entry.domain == SENSOR_DOMAIN
]
assert len(sensor_entries) == 3
entity_entry = sensor_entries[0]
# Device should exist and match snapshot
assert entity_entry.device_id
assert (device_entry := device_registry.async_get(entity_entry.device_id))
assert device_entry == snapshot

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

@@ -82,6 +82,7 @@ light:
"""
import copy
import json
from typing import Any
from unittest.mock import call, patch
@@ -100,6 +101,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.json import json_loads
from .common import (
@@ -169,6 +171,70 @@ COLOR_MODES_CONFIG = {
}
}
GROUP_MEMBER_1_TOPIC = "homeassistant/light/member_1/config"
GROUP_MEMBER_2_TOPIC = "homeassistant/light/member_2/config"
GROUP_MEMBER_3_TOPIC = "homeassistant/light/member_3/config"
GROUP_TOPIC = "homeassistant/light/group/config"
GROUP_DISCOVERY_MEMBER_1_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member1",
"unique_id": "very_unique_member1",
"name": "member1",
"default_entity_id": "light.member1",
}
)
GROUP_DISCOVERY_MEMBER_2_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member2",
"unique_id": "very_unique_member2",
"name": "member2",
"default_entity_id": "light.member2",
}
)
GROUP_DISCOVERY_MEMBER_3_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member3",
"unique_id": "very_unique_member3",
"name": "member3",
"default_entity_id": "light.member3",
}
)
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-group",
"state_topic": "test-state-topic-group",
"unique_id": "very_unique_group",
"name": "group",
"default_entity_id": "light.group",
"group": ["very_unique_member1", "very_unique_member2"],
}
)
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-group",
"state_topic": "test-state-topic-group",
"unique_id": "very_unique_group",
"name": "group",
"default_entity_id": "light.group",
"group": ["very_unique_member1", "very_unique_member2", "very_unique_member3"],
}
)
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_NO_GROUP = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-group",
"state_topic": "test-state-topic-group",
"unique_id": "very_unique_group",
"name": "group",
"default_entity_id": "light.group",
}
)
class JsonValidator:
"""Helper to compare JSON."""
@@ -1859,6 +1925,144 @@ async def test_white_scale(
assert state.attributes.get("brightness") == 129
async def test_light_group_discovery_members_before_group(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the discovery of a light group and linked entity IDs.
The members are discovered first, so they are known in the entity registry.
"""
await mqtt_mock_entry()
# Discover light group members
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
await hass.async_block_till_done()
# Discover group
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == [
"light.member1",
"light.member2",
]
# Now create and discover a new member
async_fire_mqtt_message(hass, GROUP_MEMBER_3_TOPIC, GROUP_DISCOVERY_MEMBER_3_CONFIG)
await hass.async_block_till_done()
# Update the group discovery
async_fire_mqtt_message(
hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED
)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
assert hass.states.get("light.member3") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == [
"light.member1",
"light.member2",
"light.member3",
]
async def test_light_group_discovery_group_before_members(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the discovery of a light group and linked entity IDs.
The group is discovered first, so the group members are
not (all) known yet in the entity registry.
The entity property should be updated as soon as member entities
are discovered, updated or removed.
"""
await mqtt_mock_entry()
# Discover group
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
await hass.async_block_till_done()
# Discover light group members
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == [
"light.member1",
"light.member2",
]
# Remove member 1
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, "")
await hass.async_block_till_done()
assert hass.states.get("light.member1") is None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == ["light.member2"]
# Rename member 2
entity_registry.async_update_entity(
"light.member2", new_entity_id="light.member2_updated"
)
await hass.async_block_till_done()
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == ["light.member2_updated"]
async def test_update_discovery_with_members_without_init(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the discovery update of a light group and linked entity IDs."""
await mqtt_mock_entry()
# Discover light group members
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
await hass.async_block_till_done()
# Discover group without members
async_fire_mqtt_message(
hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_NO_GROUP
)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") is None
# Update the discovery with group members
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
await hass.async_block_till_done()
assert "Group member update received for entity" in caplog.text
@pytest.mark.parametrize(
"hass_config",
[
@@ -2040,7 +2244,7 @@ async def test_custom_availability_payload(
)
async def test_setting_attribute_via_mqtt_json_message(
async def test_setting_attribute_via_mqtt_json_message_single_light(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the setting of attribute via MQTT with JSON payload."""
@@ -2049,6 +2253,54 @@ async def test_setting_attribute_via_mqtt_json_message(
)
@pytest.mark.parametrize(
"hass_config",
[
help_custom_config(
light.DOMAIN,
DEFAULT_CONFIG,
(
{
"unique_id": "very_unique_member_1",
"name": "Part 1",
"default_entity_id": "light.member_1",
},
{
"unique_id": "very_unique_member_2",
"name": "Part 2",
"default_entity_id": "light.member_2",
},
{
"unique_id": "very_unique_group",
"name": "My group",
"default_entity_id": "light.my_group",
"json_attributes_topic": "attr-topic",
"group": [
"very_unique_member_1",
"very_unique_member_2",
"member_3_not_exists",
],
},
),
)
],
)
async def test_setting_attribute_via_mqtt_json_message_light_group(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:
"""Test the setting of attribute via MQTT with JSON payload."""
await mqtt_mock_entry()
async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
state = hass.states.get("light.my_group")
assert state and state.attributes.get("val") == "100"
assert state.attributes.get("group_entities") == [
"light.member_1",
"light.member_2",
]
async def test_setting_blocked_attribute_via_mqtt_json_message(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:

View File

@@ -3082,63 +3082,6 @@
'state': '46.92',
})
# ---
# name: test_sensors[sensor.test_energy_remaining_2-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': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_energy_remaining_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Energy remaining',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY_STORAGE: 'energy_storage'>,
'original_icon': None,
'original_name': 'Energy remaining',
'platform': 'tessie',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'energy_remaining',
'unique_id': 'VINVINVIN-energy_remaining',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_sensors[sensor.test_energy_remaining_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy_storage',
'friendly_name': 'Test Energy remaining',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_energy_remaining_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '55.2',
})
# ---
# name: test_sensors[sensor.test_inside_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -0,0 +1 @@
"""Tests for the window integration."""

View File

@@ -0,0 +1,646 @@
"""Test window trigger."""
from typing import Any
import pytest
from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple binary sensor entities associated with different targets."""
return await target_entities(hass, "binary_sensor")
@pytest.fixture
async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple cover entities associated with different targets."""
return await target_entities(hass, "cover")
@pytest.mark.parametrize(
"trigger_key",
[
"window.opened",
"window.closed",
],
)
async def test_window_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the window triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_binary_sensor_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires for binary_sensor entities with device_class window."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
],
other_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
other_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_cover_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires for cover entities with device_class window."""
other_entity_ids = set(target_covers["included"]) - {entity_id}
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
for eid in target_covers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_binary_sensor_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires on the first binary_sensor state change."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("binary_sensor"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[STATE_ON],
other_states=[STATE_OFF],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[STATE_OFF],
other_states=[STATE_ON],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_binary_sensor_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires when the last binary_sensor changes state."""
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
for eid in target_binary_sensors["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
],
other_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
other_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_cover_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires on the first cover state change."""
other_entity_ids = set(target_covers["included"]) - {entity_id}
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
for eid in target_covers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="window.opened",
target_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
],
other_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="window.closed",
target_states=[
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
],
other_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
(CoverState.CLOSING, {ATTR_IS_CLOSED: False}),
],
extra_invalid_states=[
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
(CoverState.OPEN, {}),
],
additional_attributes={ATTR_DEVICE_CLASS: "window"},
trigger_from_none=False,
),
],
)
async def test_window_trigger_cover_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: dict[str, list[str]],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test window trigger fires when the last cover changes state."""
other_entity_ids = set(target_covers["included"]) - {entity_id}
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
for eid in target_covers["included"]:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
for eid in excluded_entity_ids:
set_or_remove_state(hass, eid, states[0]["excluded"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
excluded_state = state["excluded"]
included_state = state["included"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
for excluded_entity_id in excluded_entity_ids:
set_or_remove_state(hass, excluded_entity_id, excluded_state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
(
"trigger_key",
"binary_sensor_initial",
"binary_sensor_target",
"cover_initial",
"cover_initial_is_closed",
"cover_target",
"cover_target_is_closed",
),
[
(
"window.opened",
STATE_OFF,
STATE_ON,
CoverState.CLOSED,
True,
CoverState.OPEN,
False,
),
(
"window.closed",
STATE_ON,
STATE_OFF,
CoverState.OPEN,
False,
CoverState.CLOSED,
True,
),
],
)
async def test_window_trigger_excludes_non_window_device_class(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
binary_sensor_initial: str,
binary_sensor_target: str,
cover_initial: str,
cover_initial_is_closed: bool,
cover_target: str,
cover_target_is_closed: bool,
) -> None:
"""Test window trigger does not fire for entities without device_class window."""
entity_id_window = "binary_sensor.test_window"
entity_id_door = "binary_sensor.test_door"
entity_id_cover_window = "cover.test_window"
entity_id_cover_door = "cover.test_door"
# Set initial states
hass.states.async_set(
entity_id_window, binary_sensor_initial, {ATTR_DEVICE_CLASS: "window"}
)
hass.states.async_set(
entity_id_door, binary_sensor_initial, {ATTR_DEVICE_CLASS: "door"}
)
hass.states.async_set(
entity_id_cover_window,
cover_initial,
{ATTR_DEVICE_CLASS: "window", ATTR_IS_CLOSED: cover_initial_is_closed},
)
hass.states.async_set(
entity_id_cover_door,
cover_initial,
{ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_initial_is_closed},
)
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger_key,
{},
{
CONF_ENTITY_ID: [
entity_id_window,
entity_id_door,
entity_id_cover_window,
entity_id_cover_door,
]
},
)
# Window binary_sensor changes - should trigger
hass.states.async_set(
entity_id_window, binary_sensor_target, {ATTR_DEVICE_CLASS: "window"}
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_window
service_calls.clear()
# Door binary_sensor changes - should NOT trigger (wrong device class)
hass.states.async_set(
entity_id_door, binary_sensor_target, {ATTR_DEVICE_CLASS: "door"}
)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Cover window changes - should trigger
hass.states.async_set(
entity_id_cover_window,
cover_target,
{ATTR_DEVICE_CLASS: "window", ATTR_IS_CLOSED: cover_target_is_closed},
)
await hass.async_block_till_done()
assert len(service_calls) == 1
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_cover_window
service_calls.clear()
# Door cover changes - should NOT trigger (wrong device class)
hass.states.async_set(
entity_id_cover_door,
cover_target,
{ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_target_is_closed},
)
await hass.async_block_till_done()
assert len(service_calls) == 0

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

@@ -94,13 +94,11 @@ from homeassistant.components.zwave_js.const import (
ATTR_PARAMETERS,
ATTR_WAIT_FOR_RESULT,
CONF_DATA_COLLECTION_OPTED_IN,
CONF_INSTALLER_MODE,
DOMAIN,
)
from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, MockUser
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -5397,36 +5395,6 @@ async def test_invoke_cc_api(
assert msg["error"] == {"code": "NotFoundError", "message": ""}
@pytest.mark.parametrize(
("config", "installer_mode"), [({}, False), ({CONF_INSTALLER_MODE: True}, True)]
)
async def test_get_integration_settings(
config: dict[str, Any],
installer_mode: bool,
hass: HomeAssistant,
client: MagicMock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test that the get_integration_settings WS API call works."""
ws_client = await hass_ws_client(hass)
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: config})
await hass.async_block_till_done()
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/get_integration_settings",
}
)
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"] == {
CONF_INSTALLER_MODE: installer_mode,
}
async def test_backup_nvm(
hass: HomeAssistant,
integration,

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

View File

@@ -2411,6 +2411,28 @@ async def test_entity_service_call_warn_referenced(
) in caplog.text
async def test_entity_service_call_warn_unavailable(
hass: HomeAssistant,
mock_entities: dict[str, MockEntity],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that explicitly referenced unavailable entities are logged."""
mock_entities["light.kitchen"] = MockEntity(
entity_id="light.kitchen", available=False
)
call = ServiceCall(
hass,
"test_domain",
"test_service",
{"entity_id": ["light.kitchen"]},
)
await service.entity_service_call(hass, mock_entities, "", call)
assert (
"Referenced entities light.kitchen are missing or not currently available"
) in caplog.text
async def test_async_extract_entities_warn_referenced(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:

View File

@@ -98,6 +98,7 @@
'weather',
'web_rtc',
'websocket_api',
'window',
'zone',
})
# ---
@@ -199,6 +200,7 @@
'weather',
'web_rtc',
'websocket_api',
'window',
'zone',
})
# ---