Compare commits

...

45 Commits

Author SHA1 Message Date
Franck Nijhof ad99929178 Bump version to 2026.6.0b4 2026-06-03 15:09:31 +00:00
Bram Kragten d2672050cf Update frontend to 20260527.4 (#172907) 2026-06-03 15:08:41 +00:00
Sören 74fd636aa6 Add Avea Bluetooth reachability diagnostics (#172898) 2026-06-03 15:08:39 +00:00
Erik Montnemery b4f8fce912 Don't log condition errors when executing WS test_condition (#172897) 2026-06-03 15:08:37 +00:00
Michael Hansen 78a97f99dc Bump intents to 2026.6.1 (#172842) 2026-06-03 15:07:07 +00:00
Franck Nijhof 5d0565f007 Bump version to 2026.6.0b3 2026-06-03 10:03:15 +00:00
Erik Montnemery 083af9ccc7 Add zone occupancy conditions (#172896) 2026-06-03 10:02:17 +00:00
Erik Montnemery 6c87284dee Catch errors when setting up condition in WS subscribe_condition (#172895) 2026-06-03 10:02:15 +00:00
Paulus Schoutsen 0e0b29d16e Regenerate mdi_icons.py for frontend 20260527.3 (#172887) 2026-06-03 10:02:13 +00:00
Bram Kragten 8e493d84f1 Bump frontend to 20260527.3 (#172873) 2026-06-03 10:00:42 +00:00
Joost Lekkerkerker 4e2bc610e3 Bump pySmartThings to 4.0.0 (#172858) 2026-06-03 09:59:38 +00:00
jameson_uk 82d83feda4 Bump aioamazondevices to 14.0.0 (#172857) 2026-06-03 09:59:36 +00:00
Petro31 265fe6d338 Add translations for template device trackers in_zones option (#172850) 2026-06-03 09:59:34 +00:00
Wendelin bb8036f2c8 Automation choose: Add optional note to options (#172837) 2026-06-03 09:59:32 +00:00
Erik Montnemery 387b84ec7b Prevent log spam when WS subscribe_condition is active (#172832) 2026-06-03 09:59:30 +00:00
zhangluofeng 24037fcfa3 Don't create switch entity for switch device type in XThings Cloud (#172828) 2026-06-03 09:59:28 +00:00
Erik Montnemery 994b210588 Make the renamed trigger behavior options backwards compatible (#172822) 2026-06-03 09:59:26 +00:00
Franck Nijhof db6f1426ec Fix SwitchBot Blind Tilt KeyError on idle BLE advertisements (#172816) 2026-06-03 09:59:24 +00:00
Erik Montnemery 8ce5ba2ba4 Add zone conditions in / not in zone (#172810) 2026-06-03 09:59:22 +00:00
Matthias Alphart b176fb2113 Update knx-frontend to 2026.6.1.213802 (#172806) 2026-06-03 09:59:20 +00:00
Pete Sage ada8a98f87 Log warning on unsupported announce media formats for Sonos (#172614)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-03 09:59:18 +00:00
Heikki Henriksen 763d9879bf prusalink: guard non-string original in config_flow workaround (#172375) 2026-06-03 09:59:16 +00:00
Pete Sage 7bbd0ea472 Replace usages of datetime.now(UTC) with dt_util for Sonos (#172737) 2026-06-03 09:53:03 +00:00
jameson_uk 60f458a372 alexa devices - media player code quality (#172650) 2026-06-03 09:43:11 +00:00
Erik Montnemery 05eada2569 Add zone triggers occupancy detected/cleared (#172438) 2026-06-03 09:35:43 +00:00
Erik Montnemery d2abd7f6ca Add zone entered left triggers (#172412) 2026-06-03 09:35:41 +00:00
Franck Nijhof af08e5e7d0 Bump version to 2026.6.0b2 2026-06-01 21:05:58 +00:00
Franck Nijhof b03d87dc21 Cancel iCloud polling timer on config entry unload (#172793) 2026-06-01 21:05:46 +00:00
Tom d8a9ea1d9d Fix ProxmoxVE missing unused token data (#172782) 2026-06-01 21:05:44 +00:00
J. Nick Koston 5ff07fcc49 Explain why a Snooz device could not be found (#172780) 2026-06-01 21:05:42 +00:00
J. Nick Koston 6f59bb0661 Explain why an LD2410 BLE device could not be found (#172779) 2026-06-01 21:05:40 +00:00
J. Nick Koston c82d32bbae Explain why a Husqvarna Automower BLE device could not be connected to (#172774) 2026-06-01 21:05:38 +00:00
Ingo Fischer 4fbc363965 Filter stale replayed BLE advertisements in Matter BLE proxy (#172773)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:05:36 +00:00
J. Nick Koston 8622f0f4de Explain why an eQ-3 Bluetooth device could not be found (#172770) 2026-06-01 21:05:34 +00:00
J. Nick Koston b49a6b89b6 Bump habluetooth to 6.8.1 (#172768) 2026-06-01 21:05:32 +00:00
J. Nick Koston 0bfd4c44bb Explain why a LED BLE device could not be found (#172764) 2026-06-01 21:05:30 +00:00
J. Nick Koston c09216650f Explain why an INKBIRD device could not be found (#172762) 2026-06-01 21:05:28 +00:00
J. Nick Koston 6057d32636 Explain why a Yale Access Bluetooth device could not be found (#172761) 2026-06-01 21:05:26 +00:00
Bram Kragten 51c9d0c6e5 Bump frontend to 20260527.2 (#172759)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-01 21:05:24 +00:00
J. Nick Koston 323304664e Explain why an Airthings BLE device could not be found (#172758) 2026-06-01 21:05:22 +00:00
A. Gideonse 3dda7d9848 Fix binary sensor defaults for Indevolt (#172714) 2026-06-01 21:05:20 +00:00
A. Gideonse 5e56d74257 Bump indevolt-api to 1.8.3 (#172683) 2026-06-01 21:05:18 +00:00
Thijs W. e5f9c7892a Fix get_play_status function call in frontier silicon (#172705) 2026-06-01 21:01:29 +00:00
Michael a0d713a4a7 Use proper user-agent to fetch feeds (#172655) 2026-06-01 21:01:27 +00:00
jameson_uk 84f4f876b1 media_player platform fixes for Alexa Devices (#172611) 2026-06-01 21:01:25 +00:00
94 changed files with 2933 additions and 289 deletions
@@ -8,6 +8,7 @@ from bleak.backends.device import BLEDevice
from bleak_retry_connector import close_stale_connections_by_address
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -63,7 +64,16 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Airthings device with address {address}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
self.hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
self.ble_device = ble_device
@@ -54,5 +54,10 @@
"name": "Radon longterm level"
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Airthings device with address {address}: {reason}"
}
}
}
@@ -204,7 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
await self.api.sync_media_state()
try:
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.2"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -1,8 +1,7 @@
"""Media player platform for Alexa Devices."""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Final
from typing import Any
from aioamazondevices.structures import (
AmazonMediaControls,
@@ -38,18 +37,6 @@ STANDARD_SUPPORTED_FEATURES = (
)
@dataclass(frozen=True, kw_only=True)
class AmazonDevicesMediaPlayerEntityDescription(MediaPlayerEntityDescription):
"""Describes an Alexa Devices media player entity."""
MEDIA_PLAYERS: Final = (
AmazonDevicesMediaPlayerEntityDescription(
key="media",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
@@ -69,9 +56,10 @@ async def async_setup_entry(
continue
known_devices.add(serial_num)
new_entities.extend(
AlexaDevicesMediaPlayer(coordinator, serial_num, description)
for description in MEDIA_PLAYERS
new_entities.append(
AlexaDevicesMediaPlayer(
coordinator, serial_num, MediaPlayerEntityDescription(key="media")
)
)
if new_entities:
@@ -85,8 +73,6 @@ async def async_setup_entry(
class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
"""Representation of an Alexa device media player."""
entity_description: AmazonDevicesMediaPlayerEntityDescription
_attr_name = None # Uses the device name
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_volume_step = 0.05
@@ -95,7 +81,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self,
coordinator: AmazonDevicesCoordinator,
serial_num: str,
description: AmazonDevicesMediaPlayerEntityDescription,
description: MediaPlayerEntityDescription,
) -> None:
"""Initialize."""
self._prev_volume: int | None = None
@@ -156,9 +142,11 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def is_volume_muted(self) -> bool | None:
"""Return True if the volume is muted."""
if not self.volume_state:
if not self.volume_state or self.volume_state.volume is None:
return None
return self.volume_state.volume == 0
# is_muted is True when Alexa has muted the device
# volume == 0 is where we have muted by setting volume to 0
return self.volume_state.is_muted or self.volume_state.volume == 0
@property
def media_title(self) -> str | None:
@@ -212,7 +200,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
@property
def media_content_type(self) -> MediaType | None:
"""Content type — tells HA what kind of media is playing."""
if self.state in [MediaPlayerState.PLAYING, MediaPlayerState.PAUSED]:
if self.state in (MediaPlayerState.PLAYING, MediaPlayerState.PAUSED):
return MediaType.MUSIC
return None
@@ -225,7 +213,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
**kwargs: Any,
) -> None:
"""Play a piece of media."""
await self.async_call_alexa_music(media_id, media_type)
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
@@ -259,12 +248,20 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
return
if mute:
self._prev_volume = self.volume_state.volume
target_volume = 0
else:
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(0)
return
if self.volume_state.is_muted and self._prev_volume is None:
# is muted by Alexa which we can see but not control
# when muted this way, volume is still set
# changing volume will unmute
# if HA set volume to 0 then Alexa muted we just default to 30%
self._prev_volume = self.volume_state.volume or 30
if self._prev_volume is None:
return
target_volume = self._prev_volume
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
@@ -125,6 +125,9 @@
},
"invalid_sound_value": {
"message": "Invalid sound {sound} specified"
},
"unknown_exception": {
"message": "Unknown error occurred: {error}"
}
},
"selector": {
+19 -5
View File
@@ -2,12 +2,18 @@
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -15,12 +21,20 @@ PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
address = entry.data[CONF_ADDRESS]
ble_device = async_ble_device_from_address(hass, address, connectable=True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
entry.runtime_data = avea.Bulb(ble_device)
@@ -22,6 +22,11 @@
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Avea device with address {address}: {reason}"
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.0"
"habluetooth==6.8.1"
]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.6.1"]
}
@@ -12,13 +12,19 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
# protected, but only used for legacy triggers
_async_attach_trigger_cls,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -79,16 +85,18 @@ async def async_attach_trigger(
event = zone.EVENT_ENTER
else:
event = zone.EVENT_LEAVE
zone_config = {
CONF_PLATFORM: ZONE_DOMAIN,
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
return await zone.async_attach_trigger(
hass, zone_config, action, trigger_info, platform_type="device"
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
hass,
{
CONF_OPTIONS: {
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
},
)
return await _async_attach_trigger_cls(
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
)
@@ -8,13 +8,14 @@ from eq3btsmart import Thermostat
from eq3btsmart.exceptions import Eq3Exception
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED
from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [
@@ -49,7 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
if device is None:
raise ConfigEntryNotReady(
f"[{eq3_config.mac_address}] Device could not be found"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"mac_address": eq3_config.mac_address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
mac_address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
thermostat = Thermostat(device)
@@ -61,5 +61,10 @@
"name": "Lock"
}
}
},
"exceptions": {
"device_not_found": {
"message": "[{mac_address}] Device could not be found: {reason}"
}
}
}
@@ -23,14 +23,18 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN
from .const import CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES, DOMAIN, USER_AGENT
LOGGER = logging.getLogger(__name__)
async def async_fetch_feed(hass: HomeAssistant, url: str) -> feedparser.FeedParserDict:
"""Fetch the feed."""
return await hass.async_add_executor_job(feedparser.parse, url)
def _parse_feed() -> feedparser.FeedParserDict:
return feedparser.parse(url, agent=USER_AGENT)
return await hass.async_add_executor_job(_parse_feed)
class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -3,6 +3,8 @@
from datetime import timedelta
from typing import Final
from homeassistant.const import APPLICATION_NAME, __version__ as ha_version
DOMAIN: Final[str] = "feedreader"
CONF_MAX_ENTRIES: Final[str] = "max_entries"
@@ -10,3 +12,5 @@ DEFAULT_MAX_ENTRIES: Final[int] = 20
DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(hours=1)
EVENT_FEEDREADER: Final[str] = "feedreader"
USER_AGENT: Final[str] = f"{APPLICATION_NAME}/{ha_version}"
@@ -18,7 +18,13 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import CONF_MAX_ENTRIES, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_FEEDREADER
from .const import (
CONF_MAX_ENTRIES,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
EVENT_FEEDREADER,
USER_AGENT,
)
DELAY_SAVE = 30
STORAGE_VERSION = 1
@@ -74,6 +80,7 @@ class FeedReaderCoordinator(
self.url,
etag=None if not self._feed else self._feed.get("etag"),
modified=None if not self._feed else self._feed.get("modified"),
agent=USER_AGENT,
)
feed = await self.hass.async_add_executor_job(_parse_feed)
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.1"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
@@ -320,7 +320,7 @@ class AFSAPIDevice(MediaPlayerEntity):
@fs_command_exception_wrap
async def async_media_play(self) -> None:
"""Send play command."""
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
if (await self.fs_device.get_play_status()) == PlayState.STOPPED:
# The 'play' command only seems to work when the current stream is paused.
# We need to send a 'stop' command instead to resume a stopped stream.
await self.fs_device.stop()
@@ -6,6 +6,7 @@ from bleak import BleakError
from bleak_retry_connector import close_stale_connections_by_address, get_device
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform
from homeassistant.core import HomeAssistant
@@ -56,7 +57,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) ->
)
except (TimeoutError, BleakError) as exception:
raise ConfigEntryNotReady(
f"Unable to connect to device {address} due to {exception}"
translation_domain=DOMAIN,
translation_key="connection_failed",
translation_placeholders={
"address": address,
"error": str(exception) or type(exception).__name__,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
) from exception
LOGGER.debug("connected and paired")
@@ -45,6 +45,9 @@
}
},
"exceptions": {
"connection_failed": {
"message": "Unable to connect to device {address} due to {error}: {reason}"
},
"pin_required": {
"message": "PIN is required for {domain_name}"
}
@@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
await hass.async_add_executor_job(account.setup)
entry.runtime_data = account
entry.async_on_unload(account.cancel_fetch)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+9 -1
View File
@@ -92,6 +92,7 @@ class IcloudAccount:
self._retried_fetch = False
self._config_entry = config_entry
self._unsub_fetch: CALLBACK_TYPE | None = None
self.listeners: list[CALLBACK_TYPE] = []
def setup(self) -> None:
@@ -293,9 +294,16 @@ class IcloudAccount:
self._max_interval,
)
def cancel_fetch(self) -> None:
"""Cancel the scheduled fetch timer."""
if self._unsub_fetch is not None:
self._unsub_fetch()
self._unsub_fetch = None
def _schedule_next_fetch(self) -> None:
self.cancel_fetch()
if not self._config_entry.pref_disable_polling:
track_point_in_utc_time(
self._unsub_fetch = track_point_in_utc_time(
self.hass,
self.keep_alive,
utcnow() + timedelta(minutes=self._fetch_interval),
+26 -1
View File
@@ -1,7 +1,10 @@
"""Home Assistant integration for indevolt device."""
from typing import Any
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -20,6 +23,28 @@ PLATFORMS: list[Platform] = [
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_migrate_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version == 1 and entry.minor_version < 2:
# 1.1 -> 1.2: indevolt-api 1.8.3 changed IndevoltBattery.MAIN_HEATING_STATE
# from 9079 to 9080, so migrate affected unique IDs.
@callback
def migrate_unique_id(
entity_entry: er.RegistryEntry,
) -> dict[str, Any] | None:
if entity_entry.unique_id.endswith("_9079"):
return {
"new_unique_id": entity_entry.unique_id.removesuffix("_9079")
+ "_9080"
}
return None
await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id)
hass.config_entries.async_update_entry(entry, version=1, minor_version=2)
return True
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
"""Set up indevolt integration entry using given configuration."""
coordinator = IndevoltCoordinator(hass, entry)
@@ -25,8 +25,8 @@ PARALLEL_UPDATES = 0
class IndevoltBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Custom entity description class for Indevolt binary sensors."""
on_value: int = 1
off_value: int = 0
on_value: int = 1000
off_value: int = 1001
generation: tuple[int, ...] = (1, 2)
@@ -35,8 +35,6 @@ BINARY_SENSORS: Final = (
IndevoltBinarySensorEntityDescription(
key=IndevoltGrid.METER_CONNECTED,
translation_key="meter_connected",
on_value=1000,
off_value=1001,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -46,8 +44,6 @@ BINARY_SENSORS: Final = (
key=IndevoltSystem.HEATING_STATE,
generation=(1,),
translation_key="electric_heating_state",
on_value=1000,
off_value=1001,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -22,6 +22,7 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
"""Configuration flow for Indevolt integration."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -8,6 +8,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["indevolt-api==1.8.2"],
"requirements": ["indevolt-api==1.8.3"],
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
}
@@ -7,9 +7,11 @@ from typing import Any
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
BluetoothScanningMode,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_address_reachability_diagnostics,
async_ble_device_from_address,
async_last_service_info,
)
@@ -84,7 +86,14 @@ class INKBIRDActiveBluetoothProcessorCoordinator(
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="no_advertisement",
translation_placeholders={"address": self.address},
translation_placeholders={
"address": self.address,
"reason": async_address_reachability_diagnostics(
self.hass,
self.address.upper(),
BluetoothReachabilityIntent.ACTIVE_ADVERTISEMENT,
),
},
)
await self._data.async_start(service_info, service_info.device)
self._entry.async_on_unload(self._data.async_stop)
@@ -20,7 +20,7 @@
},
"exceptions": {
"no_advertisement": {
"message": "The device with address {address} is not advertising; Make sure it is in range and powered on."
"message": "The device with address {address} is not advertising: {reason}"
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"knx-frontend==2026.4.30.60856"
"knx-frontend==2026.6.1.213802"
],
"single_config_entry": true
}
@@ -10,11 +10,13 @@ from bleak_retry_connector import (
from ld2410_ble import LD2410BLE
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import LD2410BLECoordinator
from .models import LD2410BLEConfigEntry, LD2410BLEData
@@ -34,7 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) ->
) or await get_device(address)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find LD2410B device with address {address}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
ld2410_ble = LD2410BLE(ble_device)
@@ -97,5 +97,10 @@
"name": "Static target energy"
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find LD2410B device with address {address}: {reason}"
}
}
}
+12 -2
View File
@@ -5,12 +5,13 @@ import asyncio
from led_ble import LEDBLE
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DEVICE_TIMEOUT
from .const import DEVICE_TIMEOUT, DOMAIN
from .coordinator import LEDBLEConfigEntry, LEDBLECoordinator, LEDBLEData
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -22,7 +23,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bo
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find LED BLE device with address {address}"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
led_ble = LEDBLE(ble_device)
@@ -18,5 +18,10 @@
}
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find LED BLE device with address {address}: {reason}"
}
}
}
@@ -23,6 +23,7 @@ from matter_ble_proxy import (
)
from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BluetoothScanningMode,
async_ble_device_from_address,
async_register_callback,
@@ -51,11 +52,18 @@ class HaBluetoothScanSource(BleScanSource):
if self._cancel is not None:
return
# Drop HA's synchronous replay of stale history on register; otherwise a
# rotating peripheral's old addresses each become a parallel connect candidate.
# `MONOTONIC_TIME` is the clock that stamps `service_info.time`.
scan_start = MONOTONIC_TIME()
@callback
def _on_advertisement(
service_info: BluetoothServiceInfoBleak,
_change: object,
) -> None:
if service_info.time < scan_start:
return
try:
callback_fn(_to_advertisement_data(service_info))
except Exception:
+2 -2
View File
@@ -3,7 +3,7 @@
from collections.abc import Mapping
from typing import Any
from homeassistant.const import CONF_USERNAME
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from .const import AUTH_OTHER, CONF_AUTH_METHOD, CONF_REALM, CONF_TOKEN_ID
@@ -21,7 +21,7 @@ def sanitize_config_entry(input_data: Mapping[str, Any]) -> dict[str, Any]:
data[CONF_REALM] = realm
data[CONF_USERNAME] = f"{username}@{realm}"
if CONF_TOKEN_ID in data and "!" in data[CONF_TOKEN_ID]:
if data.get(CONF_TOKEN) and data.get(CONF_TOKEN_ID) and "!" in data[CONF_TOKEN_ID]:
data[CONF_TOKEN_ID] = data[CONF_TOKEN_ID].split("!")[1]
return data
@@ -2,7 +2,7 @@
import asyncio
import logging
from typing import Any, cast
from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from httpx import HTTPError, InvalidURL
@@ -41,7 +41,8 @@ def ensure_printer_is_supported(version: VersionInfo) -> None:
# Workaround to allow PrusaLink 0.7.2 on MK3 and MK2.5 that supports
# the 2.0.0 API, but doesn't advertise it yet
original = cast(str, version.get("original", ""))
original_value = version.get("original")
original = original_value if isinstance(original_value, str) else ""
if original.startswith(("PrusaLink I3MK3", "PrusaLink I3MK2")) and (
AwesomeVersion("0.7.2") <= AwesomeVersion(version["server"])
):
@@ -38,5 +38,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.7.3"]
"requirements": ["pysmartthings==4.0.0"]
}
+16 -3
View File
@@ -4,12 +4,16 @@ import logging
from pysnooz.device import SnoozDevice
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.components.bluetooth import (
BluetoothReachabilityIntent,
async_address_reachability_diagnostics,
async_ble_device_from_address,
)
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .const import DOMAIN, PLATFORMS
from .models import SnoozConfigEntry, SnoozConfigurationData
@@ -23,7 +27,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo
if not (ble_device := async_ble_device_from_address(hass, address)):
raise ConfigEntryNotReady(
f"Could not find Snooz with address {address}. Try power cycling the device"
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={
"address": address,
"reason": async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
device = SnoozDevice(ble_device, token)
@@ -24,6 +24,11 @@
}
}
},
"exceptions": {
"device_not_found": {
"message": "Could not find Snooz with address {address}: {reason}"
}
},
"services": {
"transition_off": {
"description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.",
@@ -3,7 +3,9 @@
import datetime
from functools import partial
import logging
import os
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from soco import SoCo, alarms
from soco.core import (
@@ -90,6 +92,7 @@ SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()}
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"]
ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS: frozenset[str] = frozenset({".mp3", ".wav"})
async def async_setup_entry(
@@ -460,6 +463,15 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
volume = kwargs.get("extra", {}).get("volume")
ext = os.path.splitext(urlparse(media_id).path)[1].lower()
if ext and ext not in ANNOUNCE_AUDIOCLIP_SUPPORTED_FORMATS:
_LOGGER.warning(
"Sonos AudioClip announce only supports MP3 and WAV; "
"%s has extension %s and will be attempted as a clip anyway on %s",
media_id,
ext,
self.speaker.zone_name,
)
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
try:
assert self.speaker.websocket
+2 -2
View File
@@ -218,8 +218,8 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or (
_tilt > self.CLOSED_UP_THRESHOLD
)
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
@@ -139,12 +139,14 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "Zones",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "Defines a template that returns a list of zones the device tracker is currently in. The template should return a list of zone entity IDs. If the device tracker is not in any zone, the template should return an empty list.",
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
"name": "[%key:common::config_flow::data::name%]"
@@ -715,11 +717,13 @@
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"in_zones": "[%key:component::template::config::step::device_tracker::data_description::in_zones%]",
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
@@ -39,6 +39,7 @@ from homeassistant.helpers import (
entity,
target as target_helpers,
template,
trace,
)
from homeassistant.helpers.condition import (
async_from_config as async_condition_from_config,
@@ -1026,14 +1027,53 @@ async def handle_test_condition(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle test condition command."""
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
# Test the condition
condition = await async_condition_from_config(hass, config)
# Validating and instantiating the condition can fail on bad user input.
# Handle those errors here so they are reported to the client without being
# logged as unexpected errors by the default websocket error handler.
try:
connection.send_result(
msg["id"], {"result": condition.async_check(variables=msg.get("variables"))}
# Do static + dynamic validation of the condition
config = await async_validate_condition_config(hass, msg["condition"])
condition = await async_condition_from_config(hass, config)
except vol.Invalid as err:
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
return
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
return
# Template errors (e.g. undefined variables) are recorded in the trace
# instead of being logged. Capture the trace and forward them to the client
# alongside the result.
condition_trace = trace.trace_get()
try:
with trace.record_template_errors():
check_result = condition.async_check(variables=msg.get("variables"))
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
else:
result: dict[str, Any] = {"result": check_result}
if template_errors := [
template_error
for elements in condition_trace.values()
for element in elements
for template_error in element.template_errors
]:
result["template_errors"] = template_errors
connection.send_result(msg["id"], result)
finally:
condition.async_unload()
@@ -1050,9 +1090,23 @@ async def handle_subscribe_condition(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe condition command."""
condition_config = await async_validate_condition_config(hass, msg["condition"])
try:
condition_config = await async_validate_condition_config(hass, msg["condition"])
condition = await async_condition_from_config(hass, condition_config)
except vol.Invalid as err:
connection.send_error(msg["id"], const.ERR_INVALID_FORMAT, str(err))
return
except HomeAssistantError as err:
connection.send_error(
msg["id"],
const.ERR_HOME_ASSISTANT_ERROR,
str(err),
translation_domain=err.translation_domain,
translation_key=err.translation_key,
translation_placeholders=err.translation_placeholders,
)
return
condition = await async_condition_from_config(hass, condition_config)
event_data: dict[str, Any] = {}
@callback
@@ -1061,10 +1115,24 @@ async def handle_subscribe_condition(
nonlocal event_data
new_event_data: dict[str, Any]
condition_trace = trace.trace_get()
try:
new_event_data = {"result": condition.async_check()}
with trace.record_template_errors():
new_event_data = {"result": condition.async_check()}
except HomeAssistantError as err:
new_event_data = {"error": str(err)}
# Template errors (e.g. undefined variables) are recorded in the trace
# instead of being logged. Forward them to the client so they are not
# lost, even when the condition still evaluated to a result.
if template_errors := [
template_error
for elements in condition_trace.values()
for element in elements
for template_error in element.template_errors
]:
new_event_data["template_errors"] = template_errors
if new_event_data == event_data:
return
event_data = new_event_data
@@ -20,7 +20,7 @@ async def async_setup_entry(
entities = [
XthingsCloudSwitch(coordinator, device_id, device_data)
for device_id, device_data in coordinator.data.items()
if device_data["type"] in ("switch", "plug")
if device_data["type"] == "plug"
]
async_add_entities(entities)
@@ -12,6 +12,7 @@ from yalexs_ble import (
)
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback
@@ -24,6 +25,7 @@ from .const import (
CONF_LOCAL_NAME,
CONF_SLOT,
DEVICE_TIMEOUT,
DOMAIN,
)
from .models import YaleXSBLEData
from .util import async_find_existing_service_info, bluetooth_callback_matcher
@@ -83,7 +85,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) ->
# If we are starting and the advertisement is not found, do not delay
# the setup. We will wait for the advertisement to be found and then
# discovery will trigger setup retry.
raise ConfigEntryNotReady("{local_name} ({address}) not advertising yet")
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_advertising",
translation_placeholders={
"local_name": local_name,
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
entry.async_on_unload(
bluetooth.async_register_callback(
@@ -53,6 +53,11 @@
}
}
},
"exceptions": {
"device_not_advertising": {
"message": "{local_name} ({address}) is not advertising yet: {reason}"
}
},
"options": {
"step": {
"device_options": {
+128 -2
View File
@@ -4,12 +4,15 @@ from typing import Any, Unpack, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_ENTITY_ID,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -17,15 +20,23 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ANY,
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionCheckParams,
ConditionConfig,
EntityConditionBase,
)
from homeassistant.helpers.typing import ConfigType
from . import in_zone
from .const import DOMAIN
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
@@ -149,11 +160,126 @@ class ZoneCondition(Condition):
return all_ok
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(value_source=ATTR_IN_ZONES),
"device_tracker": DomainSpec(value_source=ATTR_IN_ZONES),
}
_ZONE_CONDITION_SCHEMA = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain(DOMAIN),
},
}
)
class _ZoneTargetConditionBase(EntityConditionBase):
"""Base for zone-target conditions on person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize the condition."""
super().__init__(hass, config)
assert config.options is not None
self._zone: str = config.options[CONF_ZONE]
def _in_target_zone(self, entity_state: State) -> bool:
"""Check if the entity is currently in the selected zone."""
in_zones = entity_state.attributes.get(ATTR_IN_ZONES) or ()
return self._zone in in_zones
class InZoneCondition(_ZoneTargetConditionBase):
"""Condition: targeted entity is in the selected zone."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the entity is in the selected zone."""
return self._in_target_zone(entity_state)
class NotInZoneCondition(_ZoneTargetConditionBase):
"""Condition: targeted entity is not in the selected zone."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the entity is not in the selected zone."""
return not self._in_target_zone(entity_state)
_OCCUPANCY_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class _ZoneOccupancyConditionBase(EntityConditionBase):
"""Base for zone occupancy conditions (single zone, no behavior)."""
_domain_specs = {"zone": DomainSpec()}
_schema = _OCCUPANCY_CONDITION_SCHEMA
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and synthesize a target from the zone option.
We synthesize a target because we allow users to pick a single zone
to monitor, not a target.
"""
config = cast(ConfigType, cls._schema(config))
zone_entity_id: str = config[CONF_OPTIONS][CONF_ZONE]
config[CONF_TARGET] = {CONF_ENTITY_ID: [zone_entity_id]}
# `behavior` is needed by `EntityConditionBase.__init__`
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
return config
@staticmethod
def _occupancy_count(entity_state: State) -> int | None:
"""Return the zone's persons-in-zone count; None if unparsable."""
try:
return int(entity_state.state)
except TypeError, ValueError:
return None
@classmethod
def _is_occupied(cls, entity_state: State) -> bool:
"""Return True if the zone has at least one occupant."""
count = cls._occupancy_count(entity_state)
return count is not None and count >= 1
class OccupancyIsDetectedCondition(_ZoneOccupancyConditionBase):
"""Condition: the selected zone is occupied."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(entity_state)
class OccupancyIsNotDetectedCondition(_ZoneOccupancyConditionBase):
"""Condition: the selected zone is empty."""
def is_valid_state(self, entity_state: State) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(entity_state) == 0
CONDITIONS: dict[str, type[Condition]] = {
"_": ZoneCondition,
"in_zone": InZoneCondition,
"not_in_zone": NotInZoneCondition,
"occupancy_is_detected": OccupancyIsDetectedCondition,
"occupancy_is_not_detected": OccupancyIsNotDetectedCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the sun conditions."""
"""Return the zone conditions."""
return CONDITIONS
@@ -0,0 +1,42 @@
.condition_zone: &condition_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: any
selector:
automation_behavior:
mode: condition
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
in_zone: *condition_zone
not_in_zone: *condition_zone
.condition_occupancy: &condition_occupancy
fields:
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
occupancy_is_detected: *condition_occupancy
occupancy_is_not_detected: *condition_occupancy
+28
View File
@@ -1,7 +1,35 @@
{
"conditions": {
"in_zone": {
"condition": "mdi:map-marker-check"
},
"not_in_zone": {
"condition": "mdi:map-marker-remove"
},
"occupancy_is_detected": {
"condition": "mdi:account-group"
},
"occupancy_is_not_detected": {
"condition": "mdi:account-off"
}
},
"services": {
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"entered": {
"trigger": "mdi:map-marker-plus"
},
"left": {
"trigger": "mdi:map-marker-minus"
},
"occupancy_cleared": {
"trigger": "mdi:account-off"
},
"occupancy_detected": {
"trigger": "mdi:account-group"
}
}
}
+130
View File
@@ -1,8 +1,138 @@
{
"common": {
"condition_behavior_name": "Check when",
"condition_for_name": "For at least",
"condition_zone_description": "The zone to test against.",
"condition_zone_name": "Zone",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_zone_description": "The zone to trigger on.",
"trigger_zone_name": "Zone"
},
"conditions": {
"in_zone": {
"description": "Tests if one or more persons or device trackers are in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::condition_zone_description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Is in zone"
},
"not_in_zone": {
"description": "Tests if one or more persons or device trackers are not in a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::condition_zone_description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Is not in zone"
},
"occupancy_is_detected": {
"description": "Tests if a zone is occupied.",
"fields": {
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "The zone to monitor.",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Zone occupancy is detected"
},
"occupancy_is_not_detected": {
"description": "Tests if a zone is empty.",
"fields": {
"for": {
"name": "[%key:component::zone::common::condition_for_name%]"
},
"zone": {
"description": "[%key:component::zone::conditions::occupancy_is_detected::fields::zone::description%]",
"name": "[%key:component::zone::common::condition_zone_name%]"
}
},
"name": "Zone occupancy is not detected"
}
},
"services": {
"reload": {
"description": "Reloads zones from the YAML-configuration.",
"name": "Reload zones"
}
},
"triggers": {
"entered": {
"description": "Triggers when one or more persons or device trackers enter a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Entered zone"
},
"left": {
"description": "Triggers when one or more persons or device trackers leave a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Left zone"
},
"occupancy_cleared": {
"description": "Triggers when a zone transitions from occupied to unoccupied.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::triggers::occupancy_detected::fields::zone::description%]",
"name": "[%key:component::zone::triggers::occupancy_detected::fields::zone::name%]"
}
},
"name": "Zone occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers when a zone transitions to an occupied state.",
"fields": {
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "The zone to monitor.",
"name": "Zone"
}
},
"name": "Zone occupancy detected"
}
}
}
+233 -76
View File
@@ -1,22 +1,26 @@
"""Offer zone automation rules."""
import logging
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_PLATFORM,
CONF_FOR,
CONF_OPTIONS,
CONF_TARGET,
CONF_ZONE,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HassJob,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
@@ -24,8 +28,18 @@ from homeassistant.helpers import (
entity_registry as er,
location,
)
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
)
from homeassistant.helpers.typing import ConfigType
from . import condition
@@ -38,93 +52,236 @@ _LOGGER = logging.getLogger(__name__)
_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE),
}
_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "zone",
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(
EVENT_ENTER, EVENT_LEAVE
),
vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA,
},
)
# New-style zone trigger schema
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
},
}
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config."""
config = _TRIGGER_SCHEMA(config)
registry = er.async_get(hass)
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_ENTITY_ID]
)
return config
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(),
"device_tracker": DomainSpec(),
}
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = "zone",
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
trigger_data = trigger_info["trigger_data"]
entity_id: list[str] = config[CONF_ENTITY_ID]
zone_entity_id: str = config[CONF_ZONE]
event: str = config[CONF_EVENT]
job = HassJob(action)
class LegacyZoneTrigger(Trigger):
"""Legacy zone trigger (platform: zone)."""
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config, migrating legacy format to options."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _LEGACY_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config))
registry = er.async_get(hass)
config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_OPTIONS][CONF_ENTITY_ID]
)
return config
if not (zone_state := hass.states.get(zone_entity_id)):
_LOGGER.warning(
(
"Automation '%s' is referencing non-existing zone '%s' in a zone"
" trigger"
),
trigger_info["name"],
zone_entity_id,
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id: list[str] = self._options[CONF_ENTITY_ID]
zone_entity_id: str = self._options[CONF_ZONE]
event: str = self._options[CONF_EVENT]
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
if not (zone_state := self._hass.states.get(zone_entity_id)):
_LOGGER.warning(
"Non-existing zone '%s' in a zone trigger",
zone_entity_id,
)
return
from_match = (
condition.zone(self._hass, zone_state, from_s) if from_s else False
)
return
to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = (
f"{entity} {_EVENT_DESCRIPTION[event]}"
f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
)
hass.async_run_hass_job(
job,
{
"trigger": {
**trigger_data,
"platform": platform_type,
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
run_action(
{
"entity_id": entity,
"from_state": from_s,
"to_state": to_s,
"zone": zone_state,
"event": event,
"description": description,
}
},
to_s.context if to_s else None,
)
},
description,
to_s.context if to_s else None,
)
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
return async_track_state_change_event(
self._hass, entity_id, zone_automation_listener
)
class ZoneTriggerBase(EntityTriggerBase):
"""Base for zone-based triggers targeting person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._zone: str = self._options[CONF_ZONE]
def _in_target_zone(self, state: State) -> bool:
"""Check if the entity is in the selected zone."""
in_zones = state.attributes.get(ATTR_IN_ZONES) or ()
return self._zone in in_zones
class EnteredZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity enters the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was not already in the selected zone."""
return not self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is now in the selected zone."""
return self._in_target_zone(state)
class LeftZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity leaves the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was previously in the selected zone."""
return self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is no longer in the selected zone."""
return not self._in_target_zone(state)
_OCCUPANCY_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
}
)
class _ZoneOccupancyTriggerBase(EntityTriggerBase):
"""Base for zone occupancy triggers (single zone, no behavior)."""
_domain_specs = {"zone": DomainSpec()}
_schema = _OCCUPANCY_TRIGGER_SCHEMA
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and synthesize a target from the zone option.
We synthesize a target because we allow users to pick a single zone
to monitor, not a target.
"""
config = cast(ConfigType, cls._schema(config))
config[CONF_TARGET] = {CONF_ENTITY_ID: [config[CONF_OPTIONS][CONF_ZONE]]}
return config
@staticmethod
def _occupancy_count(state: State) -> int | None:
"""Return the zone's persons-in-zone count; None if unparsable."""
try:
return int(state.state)
except TypeError, ValueError:
return None
@classmethod
def _is_occupied(cls, state: State) -> bool:
"""Return True if the zone has at least one occupant."""
count = cls._occupancy_count(state)
return count is not None and count >= 1
class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions to an occupied state."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(state)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously not occupied."""
return not self._is_occupied(from_state)
class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions from occupied to unoccupied."""
def is_valid_state(self, state: State) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(state) == 0
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the zone was previously occupied."""
return self._is_occupied(from_state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
"occupancy_detected": OccupancyDetectedTrigger,
"occupancy_cleared": OccupancyClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for zones."""
return TRIGGERS
@@ -0,0 +1,42 @@
.trigger_zone: &trigger_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: each
selector:
automation_behavior:
mode: trigger
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
entered: *trigger_zone
left: *trigger_zone
.trigger_occupancy: &trigger_occupancy
fields:
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
occupancy_detected: *trigger_occupancy
occupancy_cleared: *trigger_occupancy
+1 -1
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0b1"
PATCH_VERSION: Final = "0b4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
@@ -1953,6 +1953,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema(
[
{
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_NOTE): str, # Is only used in frontend
vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA,
vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA,
}
@@ -24,6 +24,11 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.trace import (
record_template_errors_cv,
trace_stack_cv,
trace_stack_top,
)
from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
@@ -627,6 +632,15 @@ def make_logging_undefined(
return jinja2.StrictUndefined
def _log_with_logger(level: int, msg: str) -> None:
# When a consumer such as the subscribe_condition websocket command has
# opted in, record the error on the active trace element instead of
# logging it, so repeated evaluations don't spam the log.
if record_template_errors_cv.get() and (
node := trace_stack_top(trace_stack_cv)
):
node.add_template_error(msg)
return
template, action = template_cv.get() or ("", "rendering or compiling")
_LOGGER.log(
level,
+58 -2
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine, Generator
from contextlib import contextmanager
from contextvars import ContextVar
from functools import wraps
from typing import Any
from typing import Any, Literal, overload
from homeassistant.core import ServiceResponse
from homeassistant.util import dt as dt_util
@@ -22,6 +22,7 @@ class TraceElement:
"_error",
"_last_variables",
"_result",
"_template_errors",
"_timestamp",
"_variables",
"path",
@@ -35,6 +36,7 @@ class TraceElement:
self._error: BaseException | None = None
self.path: str = path
self._result: dict[str, Any] | None = None
self._template_errors: list[str] | None = None
self.reuse_by_child = False
self._timestamp = dt_util.utcnow()
@@ -54,6 +56,23 @@ class TraceElement:
"""Set error."""
self._error = ex
def add_template_error(self, msg: str) -> None:
"""Record a template error message.
Used to record template variable errors which would otherwise be logged
directly, so they are surfaced in the trace instead of spamming the log.
A single template render can emit more than one message, so they are
accumulated in a list.
"""
if self._template_errors is None:
self._template_errors = []
self._template_errors.append(msg)
@property
def template_errors(self) -> list[str]:
"""Return the recorded template error messages."""
return self._template_errors or []
def set_result(self, **kwargs: Any) -> None:
"""Set result."""
self._result = {**kwargs}
@@ -90,6 +109,8 @@ class TraceElement:
result["changed_variables"] = self._variables
if self._error is not None:
result["error"] = str(self._error) or self._error.__class__.__name__
if self._template_errors:
result["template_errors"] = self._template_errors
if self._result is not None:
result["result"] = self._result
return result
@@ -118,6 +139,26 @@ trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar(
script_execution_cv: ContextVar[StopReason | None] = ContextVar(
"script_execution_cv", default=None
)
# When set, template errors are recorded on the active TraceElement instead of
# being logged directly
record_template_errors_cv: ContextVar[bool] = ContextVar(
"record_template_errors_cv", default=False
)
@contextmanager
def record_template_errors() -> Generator[None]:
"""Record template errors in the active trace instead of logging them.
Used by consumers such as the subscribe_condition websocket command, which
re-evaluate a condition repeatedly and forward template errors to the client
via the trace, so the errors don't spam the log.
"""
token = record_template_errors_cv.set(True)
try:
yield
finally:
record_template_errors_cv.reset(token)
def trace_id_set(trace_id: tuple[str, str]) -> None:
@@ -189,8 +230,23 @@ def trace_append_element(
trace[path].append(trace_element)
@overload
def trace_get(clear: Literal[True] = True) -> dict[str, deque[TraceElement]]: ...
@overload
def trace_get(clear: Literal[False]) -> dict[str, deque[TraceElement]] | None: ...
def trace_get(clear: bool = True) -> dict[str, deque[TraceElement]] | None:
"""Return the current trace."""
"""Return the current trace.
When clear is True the trace is reset and a fresh (empty) trace is
unconditionally returned.
When clear is False, the current trace is returned without modification
if it exists, otherwise None is returned.
"""
if clear:
trace_clear()
return trace_cv.get()
+21 -3
View File
@@ -330,6 +330,16 @@ BEHAVIOR_FIRST: Final = "first"
BEHAVIOR_ALL: Final = "all"
BEHAVIOR_EACH: Final = "each"
def _backwards_compatible_behavior(value: Any) -> Any:
"""Convert legacy behavior values to new ones."""
if value == "any":
return BEHAVIOR_EACH
if value == "last":
return BEHAVIOR_ALL
return value
ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
@@ -340,8 +350,9 @@ ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema(
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH]
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): vol.All(
_backwards_compatible_behavior,
vol.In([BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH]),
),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
@@ -1714,7 +1725,14 @@ def async_extract_entities(trigger_conf: dict) -> list[str]:
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
options = trigger_conf[CONF_OPTIONS]
return [*options[CONF_ENTITY_ID], options[CONF_ZONE]]
if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"):
return [
*async_extract_targets(trigger_conf, CONF_ENTITY_ID),
trigger_conf[CONF_OPTIONS][CONF_ZONE],
]
if trigger_conf[CONF_PLATFORM] == "geo_location":
return [trigger_conf[CONF_ZONE]]
+3 -3
View File
@@ -35,12 +35,12 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.0
habluetooth==6.8.1
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.1
home-assistant-intents==2026.5.5
home-assistant-frontend==20260527.4
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260527.1"
FRONTEND_VERSION: Final[str] = "20260527.4"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.0b1"
version = "2026.6.0b4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+1 -1
View File
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.5.5
home-assistant-intents==2026.6.1
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==5.6.1
+7 -7
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.8.2
aioamazondevices==14.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.8.0
habluetooth==6.8.1
# homeassistant.components.hanna
hanna-cloud==0.0.7
@@ -1266,10 +1266,10 @@ hole==0.9.0
holidays==0.97
# homeassistant.components.frontend
home-assistant-frontend==20260527.1
home-assistant-frontend==20260527.4
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
home-assistant-intents==2026.6.1
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -1350,7 +1350,7 @@ imgw_pib==2.2.0
incomfort-client==0.7.0
# homeassistant.components.indevolt
indevolt-api==1.8.2
indevolt-api==1.8.3
# homeassistant.components.influxdb
influxdb-client==1.50.0
@@ -1423,7 +1423,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2026.4.30.60856
knx-frontend==2026.6.1.213802
# homeassistant.components.kraken
krakenex==2.2.2
@@ -2540,7 +2540,7 @@ pysmappee==0.2.29
pysmarlaapi==1.0.2
# homeassistant.components.smartthings
pysmartthings==3.7.3
pysmartthings==4.0.0
# homeassistant.components.smarty
pysmarty2==0.10.3
@@ -1,6 +1,7 @@
"""Test the Airthings BLE integration init."""
from copy import deepcopy
from unittest.mock import patch
from airthings_ble import AirthingsDeviceType
from freezegun.api import FrozenDateTimeFactory
@@ -12,6 +13,7 @@ from homeassistant.components.airthings_ble.const import (
DEVICE_SPECIFIC_SCAN_INTERVAL,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import (
@@ -66,6 +68,36 @@ async def test_migration_existing_entries(
assert entry.data[DEVICE_MODEL] == device_info.model.value
async def test_setup_retries_when_device_not_found(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when the device is missing."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
data={DEVICE_MODEL: WAVE_DEVICE_INFO.model.value},
)
entry.add_to_hass(hass)
with (
patch_async_ble_device_from_address(None),
patch(
"homeassistant.components.airthings_ble.coordinator.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
"Could not find Airthings device with address "
f"{WAVE_SERVICE_INFO.address}: mock reachability reason" in caplog.text
)
async def test_no_migration_when_device_model_exists(
hass: HomeAssistant,
) -> None:
+2 -2
View File
@@ -113,7 +113,7 @@ TEST_DEVICE_2 = AmazonDevice(
TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord(
timestamp=1000,
utterance_type="WAKE_WORD_UTTERANCE",
history_type="WAKE_WORD_UTTERANCE",
intent="PlayMusicIntent",
title="Play some music",
sub_title="Echo Test",
@@ -121,7 +121,7 @@ TEST_VOCAL_RECORD_INITIAL = AmazonVocalRecord(
TEST_VOCAL_RECORD_EVENT = AmazonVocalRecord(
timestamp=1234567890,
utterance_type="WAKE_WORD_UTTERANCE",
history_type="WAKE_WORD_UTTERANCE",
intent="PlayMusicIntent",
title="Play some music",
sub_title="Echo Test",
@@ -126,3 +126,48 @@ async def test_sync_history_state_error(
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
pytest.param(
CannotAuthenticate,
ConfigEntryState.SETUP_ERROR,
id="cannot_authenticate",
),
pytest.param(
CannotConnect,
ConfigEntryState.SETUP_RETRY,
id="cannot_connect",
),
pytest.param(
TimeoutError,
ConfigEntryState.SETUP_RETRY,
id="timeout_error",
),
pytest.param(
CannotRetrieveData,
ConfigEntryState.SETUP_RETRY,
id="cannot_retrieve_data",
),
pytest.param(
ValueError,
ConfigEntryState.SETUP_RETRY,
id="value_error",
),
],
)
async def test_sync_media_state_auth_failed(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
side_effect: type[Exception],
expected_state: ConfigEntryState,
) -> None:
"""Test setup fails with ConfigEntryAuthFailed when sync_media_state raises CannotAuthenticate."""
mock_amazon_devices_client.sync_media_state.side_effect = side_effect
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@@ -700,3 +700,28 @@ async def test_unmute_volume_without_prev_volume_returns_early(
)
mock_amazon_devices_client.set_device_volume.assert_not_awaited()
async def test_unmute_volume_when_alexa_muted_restores_current_volume(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Unmute restores current volume when device was muted directly by Alexa."""
await _setup_media_player_platform(hass, mock_config_entry)
await _push_volume_state(
mock_amazon_devices_client,
volume_state=AmazonVolumeState(volume=30, is_muted=True),
)
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_VOLUME_MUTE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
blocking=True,
)
mock_amazon_devices_client.set_device_volume.assert_awaited_once()
assert mock_amazon_devices_client.set_device_volume.call_args.args[1] == 30
+18 -3
View File
@@ -2,8 +2,11 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.avea.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
@@ -50,19 +53,31 @@ async def _setup_yaml_import(hass: HomeAssistant, bulbs: list[MagicMock]) -> Non
async def test_setup_entry_retries_when_ble_device_is_missing(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup retries when the Bluetooth device is unavailable."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=None,
with (
patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=None,
),
patch(
"homeassistant.components.avea.async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert (
"Could not find Avea device with address "
f"{mock_config_entry.data[CONF_ADDRESS]}: mock reachability reason"
in caplog.text
)
async def test_yaml_import_creates_entries_for_discovered_bulbs(
+58 -20
View File
@@ -1485,12 +1485,12 @@ async def _validate_condition_options(
options: dict[str, Any] | None,
*,
valid: bool,
supports_target: bool = True,
) -> None:
"""Assert that a condition accepts or rejects the given options."""
config: dict[str, Any] = {
CONF_CONDITION: condition,
CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
}
config: dict[str, Any] = {CONF_CONDITION: condition}
if supports_target:
config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"}
if options is not None:
config[CONF_OPTIONS] = options
if valid:
@@ -1536,6 +1536,7 @@ async def assert_condition_options_supported(
*,
supports_behavior: bool,
supports_duration: bool,
supports_target: bool = True,
) -> None:
"""Assert which options a condition supports.
@@ -1555,9 +1556,15 @@ async def assert_condition_options_supported(
# Minimal config should always be valid
# If there are no base options, also test that options can be omitted or be empty
supports_empty = not bool(base_options)
await _validate_condition_options(hass, condition, None, valid=supports_empty)
await _validate_condition_options(hass, condition, {}, valid=supports_empty)
await _validate_condition_options(hass, condition, base_options, valid=True)
await _validate_condition_options(
hass, condition, None, valid=supports_empty, supports_target=supports_target
)
await _validate_condition_options(
hass, condition, {}, valid=supports_empty, supports_target=supports_target
)
await _validate_condition_options(
hass, condition, base_options, valid=True, supports_target=supports_target
)
def _merge(extra: dict[str, Any]) -> dict[str, Any]:
return {**(base_options or {}), **extra}
@@ -1565,18 +1572,30 @@ async def assert_condition_options_supported(
# Behavior
for behavior in ("any", "all"):
await _validate_condition_options(
hass, condition, _merge({"behavior": behavior}), valid=supports_behavior
hass,
condition,
_merge({"behavior": behavior}),
valid=supports_behavior,
supports_target=supports_target,
)
# Duration
for for_value in ({"seconds": 5}, "00:00:05", 5):
await _validate_condition_options(
hass, condition, _merge({"for": for_value}), valid=supports_duration
hass,
condition,
_merge({"for": for_value}),
valid=supports_duration,
supports_target=supports_target,
)
# Unknown option should always be rejected
await _validate_condition_options(
hass, condition, _merge({"unknown_option": True}), valid=False
hass,
condition,
_merge({"unknown_option": True}),
valid=False,
supports_target=supports_target,
)
@@ -1586,12 +1605,12 @@ async def _validate_trigger_options(
options: dict[str, Any] | None,
*,
valid: bool,
supports_target: bool = True,
) -> None:
"""Assert that a trigger accepts or rejects the given options during validation."""
trigger_config: dict[str, Any] = {
CONF_PLATFORM: trigger,
CONF_TARGET: {ATTR_LABEL_ID: "test_label"},
}
trigger_config: dict[str, Any] = {CONF_PLATFORM: trigger}
if supports_target:
trigger_config[CONF_TARGET] = {ATTR_LABEL_ID: "test_label"}
if options is not None:
trigger_config[CONF_OPTIONS] = options
if valid:
@@ -1608,6 +1627,7 @@ async def assert_trigger_options_supported(
*,
supports_behavior: bool,
supports_duration: bool,
supports_target: bool = True,
) -> None:
"""Assert which options a trigger supports.
@@ -1624,9 +1644,15 @@ async def assert_trigger_options_supported(
# Minimal config should always be valid
supports_empty = not bool(base_options)
await _validate_trigger_options(hass, trigger, None, valid=supports_empty)
await _validate_trigger_options(hass, trigger, {}, valid=supports_empty)
await _validate_trigger_options(hass, trigger, base_options, valid=True)
await _validate_trigger_options(
hass, trigger, None, valid=supports_empty, supports_target=supports_target
)
await _validate_trigger_options(
hass, trigger, {}, valid=supports_empty, supports_target=supports_target
)
await _validate_trigger_options(
hass, trigger, base_options, valid=True, supports_target=supports_target
)
def _merge(extra: dict[str, Any]) -> dict[str, Any]:
return {**(base_options or {}), **extra}
@@ -1634,18 +1660,30 @@ async def assert_trigger_options_supported(
# Behavior
for behavior in ("each", "first", "all"):
await _validate_trigger_options(
hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior
hass,
trigger,
_merge({"behavior": behavior}),
valid=supports_behavior,
supports_target=supports_target,
)
# Duration
for for_value in ({"seconds": 5}, "00:00:05", 5):
await _validate_trigger_options(
hass, trigger, _merge({"for": for_value}), valid=supports_duration
hass,
trigger,
_merge({"for": for_value}),
valid=supports_duration,
supports_target=supports_target,
)
# Unknown option should always be rejected
await _validate_trigger_options(
hass, trigger, _merge({"unknown_option": True}), valid=False
hass,
trigger,
_merge({"unknown_option": True}),
valid=False,
supports_target=supports_target,
)
+42
View File
@@ -0,0 +1,42 @@
"""Test the eq3btsmart integration init."""
from unittest.mock import patch
import pytest
from homeassistant.components.eq3btsmart.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import format_mac
from .const import MAC
from tests.common import MockConfigEntry
async def test_setup_retries_when_device_not_found(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when the device is missing."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_MAC: MAC},
unique_id=format_mac(MAC),
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.eq3btsmart.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
f"[{format_mac(MAC)}] Device could not be found: mock reachability reason"
in caplog.text
)
+2 -1
View File
@@ -4,9 +4,10 @@ from homeassistant.components.feedreader.const import (
CONF_MAX_ENTRIES,
DEFAULT_MAX_ENTRIES,
)
from homeassistant.const import CONF_URL
from homeassistant.const import APPLICATION_NAME, CONF_URL, __version__ as ha_version
URL = "http://some.rss.local/rss_feed.xml"
USER_AGENT = f"{APPLICATION_NAME}/{ha_version}"
FEED_TITLE = "RSS Sample"
VALID_CONFIG_DEFAULT = {CONF_URL: URL, CONF_MAX_ENTRIES: DEFAULT_MAX_ENTRIES}
VALID_CONFIG_100 = {CONF_URL: URL, CONF_MAX_ENTRIES: 100}
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import create_mock_entry
from .const import FEED_TITLE, URL, VALID_CONFIG_DEFAULT
from .const import FEED_TITLE, URL, USER_AGENT, VALID_CONFIG_DEFAULT
@pytest.fixture(name="feedparser")
@@ -53,6 +53,9 @@ async def test_user(hass: HomeAssistant, feedparser, setup_entry) -> None:
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_URL: URL}
)
# check user-agent
assert feedparser.call_args.args[3] == USER_AGENT
# check flow result
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == FEED_TITLE
assert result["data"][CONF_URL] == URL
+15
View File
@@ -19,6 +19,7 @@ from homeassistant.util import dt as dt_util
from . import async_setup_config_entry, create_mock_entry
from .const import (
URL,
USER_AGENT,
VALID_CONFIG_1,
VALID_CONFIG_5,
VALID_CONFIG_100,
@@ -398,3 +399,17 @@ async def test_feed_atom_htmlentities(
identifiers={(DOMAIN, entry.entry_id)}
)
assert device_entry.manufacturer == "Juan Pérez"
async def test_feedparser_user_agent(hass: HomeAssistant, feed_one_event) -> None:
"""Test that the correct user-agent is used for feedparser requests."""
entry = create_mock_entry(VALID_CONFIG_DEFAULT)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.feedreader.coordinator.feedparser.http.get"
) as feedparser:
feedparser.return_value = feed_one_event
await hass.config_entries.async_setup(entry.entry_id)
# check user-agent
assert feedparser.call_args.args[3] == USER_AGENT
@@ -1,6 +1,6 @@
"""Test the Husqvarna Automower Bluetooth setup."""
from unittest.mock import Mock
from unittest.mock import Mock, patch
from automower_ble.protocol import ResponseResult
import pytest
@@ -75,16 +75,28 @@ async def test_setup_failed_connect(
hass: HomeAssistant,
mock_automower_client: Mock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup creates expected devices."""
"""Test setup retries with a diagnostic reason when the device cannot connect."""
mock_automower_client.connect.side_effect = TimeoutError
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with patch(
"homeassistant.components.husqvarna_automower_ble.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
# A bare TimeoutError has an empty str(), so the error falls back to the
# exception class name; both the error and the reachability reason appear.
assert (
f"Unable to connect to device {mock_config_entry.data[CONF_ADDRESS]} "
"due to TimeoutError: mock reachability reason" in caplog.text
)
async def test_setup_unknown_error(
+21
View File
@@ -64,6 +64,26 @@ def mock_config_entry(generation: int) -> MockConfigEntry:
domain=DOMAIN,
title=device_info["device"],
version=1,
minor_version=2,
data={
CONF_HOST: device_info["host"],
CONF_SERIAL_NUMBER: device_info["sn"],
CONF_MODEL: device_info["device"],
CONF_GENERATION: device_info["generation"],
},
unique_id=device_info["sn"],
)
@pytest.fixture
def mock_config_entry_v1_1(generation: int) -> MockConfigEntry:
"""Return a mocked config entry with version 1.1 for migration testing."""
device_info = DEVICE_MAPPING[generation]
return MockConfigEntry(
domain=DOMAIN,
title=device_info["device"],
version=1,
minor_version=1,
data={
CONF_HOST: device_info["host"],
CONF_SERIAL_NUMBER: device_info["sn"],
@@ -82,6 +102,7 @@ def alt_mock_config_entry(alt_generation: int) -> MockConfigEntry:
domain=DOMAIN,
title=device_info["device"],
version=1,
minor_version=2,
data={
CONF_HOST: device_info["host"],
CONF_SERIAL_NUMBER: device_info["sn"],
@@ -24,12 +24,12 @@
"6006": 380.58,
"6007": 338.07,
"7120": 1001,
"9079": 1,
"9096": 1,
"9112": 0,
"9128": 1,
"9144": 0,
"9279": 1,
"9080": 1000,
"9096": 1000,
"9112": 1001,
"9128": 1000,
"9144": 1001,
"9279": 1000,
"11016": 0,
"2600": 1200,
"2612": 50.0,
@@ -383,7 +383,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'main_electric_heating_state',
'unique_id': 'SolidFlex2000-87654321_9079',
'unique_id': 'SolidFlex2000-87654321_9080',
'unit_of_measurement': None,
})
# ---
@@ -136,15 +136,15 @@
'9058': 51.1,
'9068': 25.0,
'9070': '**REDACTED**',
'9079': 1,
'9080': 1000,
'9085': 31.5,
'9096': 1,
'9096': 1000,
'9101': 32.8,
'9112': 0,
'9112': 1001,
'9117': 31.9,
'9128': 1,
'9128': 1000,
'9133': 33.0,
'9144': 0,
'9144': 1001,
'9149': 94,
'9152': 125,
'9153': 51.4,
@@ -156,7 +156,7 @@
'9216': 24.9,
'9218': '**REDACTED**',
'9270': 31.2,
'9279': 1,
'9279': 1000,
}),
'device': dict({
'firmware_version': '1.2.3',
+35 -2
View File
@@ -4,13 +4,14 @@ from unittest.mock import AsyncMock
import pytest
from homeassistant.components.indevolt.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from . import setup_integration
from .conftest import DEVICE_MAPPING
from .conftest import DEVICE_MAPPING, TEST_DEVICE_SN_GEN2
from tests.common import MockConfigEntry
@@ -72,3 +73,35 @@ async def test_load_failure(
# Verify the config entry enters retry state due to failure
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize("generation", [2], indirect=True)
async def test_migrate_main_heating_state_unique_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_indevolt: AsyncMock,
mock_config_entry_v1_1: MockConfigEntry,
) -> None:
"""Test migration of MAIN_HEATING_STATE unique ID from 9079 to 9080."""
mock_config_entry_v1_1.add_to_hass(hass)
old_unique_id = f"{TEST_DEVICE_SN_GEN2}_9079"
new_unique_id = f"{TEST_DEVICE_SN_GEN2}_9080"
entity_registry.async_get_or_create(
"binary_sensor",
DOMAIN,
old_unique_id,
config_entry=mock_config_entry_v1_1,
)
assert mock_config_entry_v1_1.minor_version == 1
await hass.config_entries.async_setup(mock_config_entry_v1_1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1_1.minor_version == 2
assert entity_registry.async_get_entity_id("binary_sensor", DOMAIN, new_unique_id)
assert not entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, old_unique_id
)
+14 -3
View File
@@ -14,6 +14,7 @@ from inkbird_ble import (
Units,
)
from inkbird_ble.parser import Model
import pytest
from sensor_state_data import SensorDeviceClass
from homeassistant.components.bluetooth import async_last_service_info
@@ -210,7 +211,9 @@ async def test_fallback_poll_queries_latest_service_info(hass: HomeAssistant) ->
await hass.async_block_till_done()
async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None:
async def test_notify_sensor_no_advertisement(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test setting up a notify sensor that has no advertisement."""
entry = MockConfigEntry(
domain=DOMAIN,
@@ -219,10 +222,18 @@ async def test_notify_sensor_no_advertisement(hass: HomeAssistant) -> None:
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
with patch(
"homeassistant.components.inkbird.coordinator."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
):
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
"62:00:A1:3C:AE:7B is not advertising: mock reachability reason" in caplog.text
)
async def test_notify_sensor(hass: HomeAssistant) -> None:
+47
View File
@@ -0,0 +1,47 @@
"""Test the LD2410 BLE integration init."""
from unittest.mock import patch
import pytest
from homeassistant.components.ld2410_ble.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from . import LD2410_BLE_DISCOVERY_INFO
from tests.common import MockConfigEntry
async def test_setup_retries_when_device_not_found(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when the device is missing."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=LD2410_BLE_DISCOVERY_INFO.address,
data={CONF_ADDRESS: LD2410_BLE_DISCOVERY_INFO.address},
)
entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.ld2410_ble.get_device",
return_value=None,
),
patch(
"homeassistant.components.ld2410_ble.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
"Could not find LD2410B device with address "
f"{LD2410_BLE_DISCOVERY_INFO.address}: mock reachability reason" in caplog.text
)
+41
View File
@@ -0,0 +1,41 @@
"""Test the LED BLE integration init."""
from unittest.mock import patch
import pytest
from homeassistant.components.led_ble.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from . import LED_BLE_DISCOVERY_INFO
from tests.common import MockConfigEntry
async def test_setup_retries_when_device_not_found(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when the device is missing."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=LED_BLE_DISCOVERY_INFO.address,
data={CONF_ADDRESS: LED_BLE_DISCOVERY_INFO.address},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.led_ble.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
"Could not find LED BLE device with address "
f"{LED_BLE_DISCOVERY_INFO.address}: mock reachability reason" in caplog.text
)
+39 -2
View File
@@ -22,7 +22,7 @@ from homeassistant.components.matter.ble_proxy import (
from homeassistant.core import HomeAssistant
def _make_service_info() -> BluetoothServiceInfoBleak:
def _make_service_info(time: float | None = None) -> BluetoothServiceInfoBleak:
"""Return a real BluetoothServiceInfoBleak with realistic field values."""
address = "AA:BB:CC:DD:EE:FF"
name = "TestDevice"
@@ -37,7 +37,7 @@ def _make_service_info() -> BluetoothServiceInfoBleak:
device=BLEDevice(name=name, address=address, details={}),
advertisement=None,
connectable=True,
time=monotonic_time_coarse(),
time=monotonic_time_coarse() if time is None else time,
tx_power=0,
raw=None,
)
@@ -152,6 +152,43 @@ async def test_scan_source_callback_forwards_advertisement(
assert forwarded[0].address == "AA:BB:CC:DD:EE:FF"
@pytest.mark.parametrize(
("advert_time", "expected_count"),
[
pytest.param(999.0, 0, id="stale-before-scan-start-dropped"),
pytest.param(1000.0, 1, id="equal-scan-start-forwarded"),
pytest.param(1001.0, 1, id="fresh-after-scan-start-forwarded"),
],
)
async def test_scan_source_drops_replayed_history(
hass: HomeAssistant, advert_time: float, expected_count: int
) -> None:
"""Adverts older than the registration instant (HA history replay) are dropped."""
forwarded: list[AdvertisementData] = []
captured: dict[str, object] = {}
def fake_register(hass_, cb, _matcher, _mode):
captured["cb"] = cb
return MagicMock()
source = HaBluetoothScanSource(hass)
with (
patch(
"homeassistant.components.matter.ble_proxy.async_register_callback",
side_effect=fake_register,
),
patch(
"homeassistant.components.matter.ble_proxy.MONOTONIC_TIME",
return_value=1000.0,
),
):
await source.start(forwarded.append)
captured["cb"](_make_service_info(time=advert_time), object())
assert len(forwarded) == expected_count
async def test_scan_source_callback_swallows_exceptions(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
@@ -496,6 +496,11 @@ def sanitize_config_entry(data: dict[str, Any]) -> dict[str, Any]:
MOCK_USER_AUTH_STEP_OTHER_TOKEN,
MOCK_TEST_TOKEN_OTHER_CONFIG,
),
(
MOCK_USER_STEP_TOKEN,
MOCK_USER_AUTH_STEP_TOKEN_FULL_ID,
MOCK_TEST_TOKEN_CONFIG,
),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
+42 -1
View File
@@ -1,8 +1,49 @@
"""Test Snooz configuration."""
from unittest.mock import patch
import pytest
from homeassistant.components.snooz.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from . import SnoozFixture
from . import TEST_ADDRESS, TEST_PAIRING_TOKEN, SnoozFixture
from tests.common import MockConfigEntry
async def test_setup_retries_when_device_not_found(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when the device is missing."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_ADDRESS,
data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN},
)
entry.add_to_hass(hass)
with (
patch(
"homeassistant.components.snooz.async_ble_device_from_address",
return_value=None,
),
patch(
"homeassistant.components.snooz.async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
f"Could not find Snooz with address {TEST_ADDRESS}: mock reachability reason"
in caplog.text
)
async def test_removing_entry_cleans_up_connections(
+59 -3
View File
@@ -1,7 +1,7 @@
"""Tests for the Sonos Media Player platform."""
from collections.abc import Generator
from datetime import UTC, datetime
import logging
from typing import Any
from unittest.mock import MagicMock, patch
@@ -87,6 +87,7 @@ from homeassistant.helpers.device_registry import (
DeviceRegistry,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import MockMusicServiceItem, MockSoCo, SoCoMockFactory, SonosMockEvent
@@ -1347,6 +1348,61 @@ async def test_play_media_announce(
soco.play_uri.assert_called_with(content_id, force_radio=False)
@pytest.mark.parametrize(
("content_id", "expect_warning"),
[
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.mp3",
False,
id="mp3_no_warning",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.wav",
False,
id="wav_no_warning",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123.flac",
True,
id="flac_warns_and_plays",
),
pytest.param(
"http://10.0.0.1:8123/api/tts_proxy/abc123",
False,
id="no_extension_no_warning",
),
],
)
async def test_play_media_announce_format_warning(
hass: HomeAssistant,
soco: MockSoCo,
async_autosetup_sonos,
sonos_websocket,
content_id: str,
expect_warning: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that announce logs a warning for unsupported file formats."""
caplog.clear()
caplog.set_level(
logging.WARNING, logger="homeassistant.components.sonos.media_player"
)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.zone_a",
ATTR_MEDIA_CONTENT_TYPE: "music",
ATTR_MEDIA_CONTENT_ID: content_id,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
)
assert sonos_websocket.play_clip.call_count == 1
warning_logged = "only supports MP3 and WAV" in caplog.text
assert warning_logged == expect_warning
async def test_media_get_queue(
hass: HomeAssistant,
soco: MockSoCo,
@@ -1477,7 +1533,7 @@ async def test_position_updates(
assert state.attributes[ATTR_MEDIA_POSITION] == 42
# updated_at should be recent
updated_at = state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT]
assert updated_at == datetime.now(UTC)
assert updated_at == dt_util.utcnow()
# Position only updated by 1 second; should not update attributes
new_track_info = current_track_info.copy()
@@ -1507,7 +1563,7 @@ async def test_position_updates(
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_MEDIA_POSITION] == 70
assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == datetime.now(UTC)
assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == dt_util.utcnow()
@pytest.mark.parametrize(
+31
View File
@@ -392,6 +392,37 @@ async def test_blindtilt_controlling(
assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50
async def test_blindtilt_idle_advertisement(
hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry]
) -> None:
"""Test blindtilt handles BLE advertisement without motionDirection."""
inject_bluetooth_service_info(hass, WOBLINDTILT_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="blind_tilt")
entry.add_to_hass(hass)
with patch(
"homeassistant.components.switchbot.cover.switchbot.SwitchbotBlindTilt.get_basic_info",
new=AsyncMock(return_value={}),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_id = "cover.test_name"
address = "AA:BB:CC:DD:EE:FF"
service_data = b"x\x00*"
manufacturer_data = b"\xfbgA`\x98\xe8\x1d%F\x12\x85"
inject_bluetooth_service_info(
hass, make_advertisement(address, manufacturer_data, service_data)
)
await hass.async_block_till_done()
# Should not crash; entity should still exist
state = hass.states.get(entity_id)
assert state is not None
async def test_roller_shade_setup(
hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry]
) -> None:
+256 -11
View File
@@ -2821,6 +2821,131 @@ async def test_test_condition(
assert msg["result"]["result"] is False
@pytest.mark.parametrize(
("value_template", "expected_template_errors"),
[
("{{ no_such_variable }}", ["'no_such_variable' is undefined"]),
# A single render emitting multiple errors forwards all of them
("{{ foo }}{{ bar }}", ["'foo' is undefined", "'bar' is undefined"]),
],
)
async def test_test_condition_template_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
value_template: str,
expected_template_errors: list[str],
) -> None:
"""Test template errors are forwarded in the result without being logged."""
caplog.set_level(logging.WARNING)
await websocket_client.send_json_auto_id(
{
"type": "test_condition",
"condition": {"condition": "template", "value_template": value_template},
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"result": False,
"template_errors": expected_template_errors,
}
assert "Template variable" not in caplog.text
@pytest.mark.parametrize(
("condition", "expected_error"),
[
# Missing mandatory config, raised by async_validate_condition_config
(
{"condition": "sun"},
{
"code": "invalid_format",
"message": (
"must contain at least one of before, after. for dictionary value "
"@ data['options']"
),
},
),
# Failing enabled template, raised by async_condition_from_config
(
{
"condition": "template",
"value_template": "{{ true }}",
"enabled": "{{ 1 / 0 }}",
},
{
"code": "home_assistant_error",
"message": (
"Error rendering condition enabled template: "
"ZeroDivisionError: division by zero"
),
},
),
],
)
async def test_test_condition_config_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
condition: dict,
expected_error: dict,
) -> None:
"""Test condition config errors are reported to the client without logging."""
caplog.set_level(logging.ERROR)
await websocket_client.send_json_auto_id(
{"type": "test_condition", "condition": condition}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"] == expected_error
# The expected error is not logged by the default websocket error handler
assert "Error handling message" not in caplog.text
async def test_test_condition_check_error_not_logged(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test errors raised while checking the condition are not logged.
The condition is valid and instantiates fine, but checking it raises (here
the entity does not exist). The error is reported to the client without
being logged by the default websocket error handler.
"""
caplog.set_level(logging.ERROR)
await websocket_client.send_json_auto_id(
{
"type": "test_condition",
"condition": {
"condition": "state",
"entity_id": "hello.world",
"state": "paulus",
},
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"] == {
"code": "home_assistant_error",
"message": "In 'state':\n In 'state' condition: unknown entity hello.world",
}
assert "Error handling message" not in caplog.text
async def test_subscribe_condition(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
@@ -2868,6 +2993,83 @@ async def test_subscribe_condition(
}
@pytest.mark.parametrize(
("value_template", "expected_event"),
[
# Undefined variable used in a way that raises: forwarded as an error,
# with the underlying template error included.
(
"{{ trigger.to_state.attributes.event_type == 'double_press' }}",
{
"error": "In 'template' condition: UndefinedError: 'trigger' is undefined",
"template_errors": ["'trigger' is undefined"],
},
),
# Undefined variable used in a way that only warns: the condition still
# evaluates to a result, but the template error is forwarded alongside it.
(
"{{ no_such_variable }}",
{"result": False, "template_errors": ["'no_such_variable' is undefined"]},
),
# A single render emitting multiple errors forwards all of them.
(
"{{ foo }}{{ bar }}",
{
"result": False,
"template_errors": ["'foo' is undefined", "'bar' is undefined"],
},
),
],
)
async def test_subscribe_condition_template_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
value_template: str,
expected_event: dict[str, Any],
) -> None:
"""Test template errors are forwarded as events and don't spam the log."""
caplog.set_level(logging.WARNING)
await websocket_client.send_json_auto_id(
{
"type": "subscribe_condition",
"condition": {
"condition": "template",
"value_template": value_template,
},
}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
subscription_id = msg["id"]
msg = await websocket_client.receive_json()
assert msg == {
"id": subscription_id,
"type": "event",
"event": expected_event,
}
# Let the condition be evaluated a few more times
for _ in range(5):
freezer.tick(1.1)
await hass.async_block_till_done()
# The unchanged result/error is not re-sent; a ping is the next message
await websocket_client.send_json_auto_id({"type": "ping"})
msg = await websocket_client.receive_json()
assert msg["type"] == "pong"
# The template error is forwarded, not logged
assert "Template variable warning" not in caplog.text
assert "Template variable error" not in caplog.text
@pytest.mark.parametrize(
("condition", "expected_error"),
[
@@ -2892,17 +3094,6 @@ async def test_subscribe_condition(
),
},
),
# Validated by async_validate_condition_config
(
{"condition": "sun"},
{
"code": "invalid_format",
"message": (
"must contain at least one of before, after. for dictionary value "
"@ data['options']. Got None"
),
},
),
],
)
async def test_subscribe_condition_error(
@@ -2924,6 +3115,60 @@ async def test_subscribe_condition_error(
assert msg["error"] == expected_error
@pytest.mark.parametrize(
("condition", "expected_error"),
[
# Missing mandatory config, raised by async_validate_condition_config
(
{"condition": "sun"},
{
"code": "invalid_format",
"message": (
"must contain at least one of before, after. for dictionary value "
"@ data['options']"
),
},
),
# Failing enabled template, raised by async_condition_from_config
(
{
"condition": "template",
"value_template": "{{ true }}",
"enabled": "{{ 1 / 0 }}",
},
{
"code": "home_assistant_error",
"message": (
"Error rendering condition enabled template: "
"ZeroDivisionError: division by zero"
),
},
),
],
)
async def test_subscribe_condition_config_error(
hass: HomeAssistant,
websocket_client: MockHAClientWebSocket,
caplog: pytest.LogCaptureFixture,
condition: dict,
expected_error: dict,
) -> None:
"""Test condition config errors are reported to the client without logging."""
caplog.set_level(logging.ERROR)
await websocket_client.send_json_auto_id(
{"type": "subscribe_condition", "condition": condition}
)
msg = await websocket_client.receive_json()
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"] == expected_error
# The expected error is not logged by the default websocket error handler
assert "Error handling message" not in caplog.text
async def test_execute_script(
hass: HomeAssistant, websocket_client: MockHAClientWebSocket
) -> None:
+1 -20
View File
@@ -40,36 +40,20 @@ async def test_switches(
@pytest.mark.parametrize(
("entity_id", "device_id", "device_type", "service", "method"),
("entity_id", "device_id", "service", "method"),
[
(
"switch.smart_plug_50",
"dev_plug_001",
"plug",
SERVICE_TURN_ON,
"async_plug_on",
),
(
"switch.smart_plug_50",
"dev_plug_001",
"plug",
SERVICE_TURN_OFF,
"async_plug_off",
),
(
"switch.smart_plug_100",
"dev_plug_002",
"switch",
SERVICE_TURN_ON,
"async_switch_on",
),
(
"switch.smart_plug_100",
"dev_plug_002",
"switch",
SERVICE_TURN_OFF,
"async_switch_off",
),
],
)
async def test_turn_on_off(
@@ -78,13 +62,10 @@ async def test_turn_on_off(
mock_api_client: AsyncMock,
entity_id: str,
device_id: str,
device_type: str,
service: str,
method: str,
) -> None:
"""Test turning on and off a device."""
get_device_by_id(mock_api_client, device_id)["type"] = device_type
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
+61
View File
@@ -0,0 +1,61 @@
"""Test the Yale Access Bluetooth init."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.yalexs_ble.const import (
CONF_KEY,
CONF_LOCAL_NAME,
CONF_SLOT,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import CoreState, HomeAssistant
from . import YALE_ACCESS_LOCK_DISCOVERY_INFO
from tests.common import MockConfigEntry
async def test_setup_retries_when_not_advertising_at_startup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test setup is retried with a diagnostic reason when not advertising at startup."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name,
CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f",
CONF_SLOT: 66,
},
unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address,
)
entry.add_to_hass(hass)
hass.set_state(CoreState.starting)
push_lock = MagicMock()
push_lock.start = AsyncMock(return_value=MagicMock())
with (
patch("homeassistant.components.yalexs_ble.close_stale_connections_by_address"),
patch("homeassistant.components.yalexs_ble.PushLock", return_value=push_lock),
patch(
"homeassistant.components.yalexs_ble.bluetooth."
"async_address_reachability_diagnostics",
return_value="mock reachability reason",
),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY
assert (
f"{YALE_ACCESS_LOCK_DISCOVERY_INFO.name} "
f"({YALE_ACCESS_LOCK_DISCOVERY_INFO.address}) is not advertising yet: "
"mock reachability reason" in caplog.text
)
+337
View File
@@ -1,12 +1,29 @@
"""The tests for the location condition."""
from datetime import timedelta
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from homeassistant.components.zone import condition as zone_condition
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_options_supported,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
target_entities,
)
async def test_zone_raises(hass: HomeAssistant) -> None:
"""Test that zone raises ConditionError on errors."""
@@ -206,3 +223,323 @@ async def test_multiple_zones(hass: HomeAssistant) -> None:
{"friendly_name": "person", "latitude": 50.1, "longitude": 20.1},
)
assert not test.async_check()
# --- New-style zone condition tests ---
ZONE_HOME = "zone.home"
ZONE_WORK = "zone.work"
IN_ZONES_HOME = {"in_zones": [ZONE_HOME]}
IN_ZONES_WORK = {"in_zones": [ZONE_WORK]}
IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []}
TARGET_ZONE = ZONE_HOME
@pytest.mark.parametrize(
(
"condition_key",
"base_options",
"supports_behavior",
"supports_duration",
"supports_target",
),
[
("zone.in_zone", {"zone": TARGET_ZONE}, True, True, True),
("zone.not_in_zone", {"zone": TARGET_ZONE}, True, True, True),
("zone.occupancy_is_detected", {"zone": ZONE_HOME}, False, True, False),
("zone.occupancy_is_not_detected", {"zone": ZONE_HOME}, False, True, False),
],
)
async def test_zone_condition_options_validation(
hass: HomeAssistant,
condition_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
supports_target: bool,
) -> None:
"""Test that zone conditions support the expected options."""
await assert_condition_options_supported(
hass,
condition_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
supports_target=supports_target,
)
@pytest.mark.parametrize(
("condition_key", "config"),
[
(
"zone.in_zone",
{"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}},
),
(
"zone.not_in_zone",
{"target": {"entity_id": "person.alice"}, "options": {"zone": "light.x"}},
),
(
"zone.occupancy_is_detected",
{"options": {"zone": "light.x"}},
),
(
"zone.occupancy_is_not_detected",
{"options": {"zone": "light.x"}},
),
],
)
async def test_zone_condition_rejects_non_zone_entity_id(
hass: HomeAssistant, condition_key: str, config: dict[str, Any]
) -> None:
"""Test that the zone option must reference entities in the zone domain."""
with pytest.raises(vol.Invalid):
await condition.async_validate_condition_config(
hass,
{"condition": condition_key, **config},
)
@pytest.fixture
async def target_zone_entities(
hass: HomeAssistant, domain: str
) -> dict[str, list[str]]:
"""Create multiple zone-trackable entities associated with different targets."""
return await target_entities(hass, domain, domain_excluded="sensor")
# `in_zone` is True for states where the entity carries the target zone in
# `in_zones`; `not_in_zone` flips the relation.
_ZONE_CONDITION_STATES_ANY = [
*parametrize_condition_states_any(
condition="zone.in_zone",
condition_options={"zone": TARGET_ZONE},
target_states=[
("home", IN_ZONES_HOME),
],
other_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="zone.not_in_zone",
condition_options={"zone": TARGET_ZONE},
target_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
other_states=[
("home", IN_ZONES_HOME),
],
excluded_entities_from_other_domain=True,
),
]
_ZONE_CONDITION_STATES_ALL = [
*parametrize_condition_states_all(
condition="zone.in_zone",
condition_options={"zone": TARGET_ZONE},
target_states=[
("home", IN_ZONES_HOME),
],
other_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="zone.not_in_zone",
condition_options={"zone": TARGET_ZONE},
target_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
other_states=[
("home", IN_ZONES_HOME),
],
excluded_entities_from_other_domain=True,
),
]
def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]:
"""Parametrize target entities for all supported zone condition domains."""
return [
(*params, domain)
for domain in ("person", "device_tracker")
for params in parametrize_target_entities(domain)
]
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target", "domain"),
_parametrize_zone_target_entities(),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
_ZONE_CONDITION_STATES_ANY,
)
async def test_zone_condition_behavior_any(
hass: HomeAssistant,
target_zone_entities: dict[str, list[str]],
condition_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test zone conditions under behavior=any."""
await assert_condition_behavior_any(
hass,
target_entities=target_zone_entities,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target", "domain"),
_parametrize_zone_target_entities(),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
_ZONE_CONDITION_STATES_ALL,
)
async def test_zone_condition_behavior_all(
hass: HomeAssistant,
target_zone_entities: dict[str, list[str]],
condition_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test zone conditions under behavior=all."""
await assert_condition_behavior_all(
hass,
target_entities=target_zone_entities,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
async def test_in_zone_condition_for_attribute_only_change(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test `for:` anchors to in_zones updates, not state.state changes.
A person already "home" who enters an overlapping zone (e.g. zone.coffee)
keeps state.state == "home" while in_zones grows. `for: 5m` on
in_zone(zone.coffee) must start counting from when in_zones changed, not
from the (older) last state.state transition.
"""
coffee_zone = "zone.coffee"
# Person at home but not yet in the coffee zone.
hass.states.async_set(
"person.alice",
"home",
{"in_zones": [ZONE_HOME]},
)
await hass.async_block_till_done()
# Time passes — state.state's last_changed sits 10 minutes in the past.
freezer.tick(timedelta(minutes=10))
config = await condition.async_validate_condition_config(
hass,
{
"condition": "zone.in_zone",
"target": {"entity_id": "person.alice"},
"options": {"zone": coffee_zone, "for": {"minutes": 5}},
},
)
test = await condition.async_from_config(hass, config)
# in_zones gains the coffee zone; state.state stays "home", so last_changed
# is untouched and only last_updated advances.
hass.states.async_set(
"person.alice",
"home",
{"in_zones": [ZONE_HOME, coffee_zone]},
)
await hass.async_block_till_done()
# Just entered; `for: 5m` must not be satisfied yet. (Without value_source
# set on the DomainSpec, the anchor would be last_changed from 10 minutes
# ago and this would incorrectly evaluate to True.)
assert test.async_check() is False
# After the duration elapses, the condition is satisfied.
freezer.tick(timedelta(minutes=6))
assert test.async_check() is True
# --- Zone occupancy condition tests ---
@pytest.mark.parametrize(
("condition_key", "zone_state", "expected"),
[
# occupancy_is_detected — true when count >= 1
pytest.param("zone.occupancy_is_detected", "1", True, id="detected_1"),
pytest.param("zone.occupancy_is_detected", "3", True, id="detected_3"),
pytest.param("zone.occupancy_is_detected", "0", False, id="detected_0"),
pytest.param(
"zone.occupancy_is_detected",
STATE_UNAVAILABLE,
False,
id="detected_unavailable",
),
pytest.param(
"zone.occupancy_is_detected", STATE_UNKNOWN, False, id="detected_unknown"
),
# occupancy_is_not_detected — true only when count == 0
pytest.param("zone.occupancy_is_not_detected", "0", True, id="empty_0"),
pytest.param("zone.occupancy_is_not_detected", "1", False, id="empty_1"),
pytest.param("zone.occupancy_is_not_detected", "3", False, id="empty_3"),
# Unavailable / unknown are not "empty" — they're indeterminate.
pytest.param(
"zone.occupancy_is_not_detected",
STATE_UNAVAILABLE,
False,
id="empty_unavailable",
),
pytest.param(
"zone.occupancy_is_not_detected",
STATE_UNKNOWN,
False,
id="empty_unknown",
),
],
)
async def test_zone_occupancy_condition_evaluates(
hass: HomeAssistant,
condition_key: str,
zone_state: str,
expected: bool,
) -> None:
"""Test occupancy conditions evaluate against the zone's integer state."""
hass.states.async_set(ZONE_HOME, zone_state)
await hass.async_block_till_done()
config = await condition.async_validate_condition_config(
hass, {"condition": condition_key, "options": {"zone": ZONE_HOME}}
)
test = await condition.async_from_config(hass, config)
assert test.async_check() is expected
+372 -9
View File
@@ -1,14 +1,36 @@
"""The tests for the location automation."""
from datetime import timedelta
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
import voluptuous as vol
from homeassistant.components import automation, zone
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF
from homeassistant.const import (
ATTR_ENTITY_ID,
ENTITY_MATCH_ALL,
SERVICE_TURN_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.trigger import async_validate_trigger_config
from homeassistant.setup import async_setup_component
from tests.common import mock_component
from tests.common import async_fire_time_changed, mock_component
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_options_supported,
parametrize_target_entities,
parametrize_trigger_states,
target_entities,
)
@pytest.fixture(autouse=True)
@@ -343,10 +365,7 @@ async def test_unknown_zone(
},
)
assert (
"Automation 'My Automation' is referencing non-existing zone"
" 'zone.no_such_zone' in a zone trigger" not in caplog.text
)
assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" not in caplog.text
hass.states.async_set(
"test.entity",
@@ -356,7 +375,351 @@ async def test_unknown_zone(
)
await hass.async_block_till_done()
assert (
"Automation 'My Automation' is referencing non-existing zone"
" 'zone.no_such_zone' in a zone trigger" in caplog.text
assert "Non-existing zone 'zone.no_such_zone' in a zone trigger" in caplog.text
# --- New-style zone trigger tests ---
ZONE_HOME = "zone.home"
ZONE_WORK = "zone.work"
IN_ZONES_HOME = {"in_zones": [ZONE_HOME]}
IN_ZONES_WORK = {"in_zones": [ZONE_WORK]}
IN_ZONES_NONE: dict[str, list[str]] = {"in_zones": []}
TRIGGER_ZONE = ZONE_HOME
@pytest.mark.parametrize(
("trigger_key", "base_options", "supports_behavior", "supports_duration"),
[
("zone.entered", {"zone": TRIGGER_ZONE}, True, True),
("zone.left", {"zone": TRIGGER_ZONE}, True, True),
],
)
async def test_zone_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
base_options: dict[str, Any] | None,
supports_behavior: bool,
supports_duration: bool,
) -> None:
"""Test that zone triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
base_options,
supports_behavior=supports_behavior,
supports_duration=supports_duration,
)
@pytest.mark.parametrize("trigger_key", ["zone.entered", "zone.left"])
async def test_zone_trigger_rejects_non_zone_entity_id(
hass: HomeAssistant, trigger_key: str
) -> None:
"""Test that the zone option must reference entities in the zone domain."""
with pytest.raises(vol.Invalid):
await async_validate_trigger_config(
hass,
[
{
"platform": trigger_key,
"target": {"entity_id": "person.alice"},
"options": {"zone": "person.alice"},
}
],
)
@pytest.fixture
async def target_zone_entities(
hass: HomeAssistant, domain: str
) -> dict[str, list[str]]:
"""Create multiple zone-trackable entities associated with different targets."""
return await target_entities(hass, domain, domain_excluded="sensor")
_ZONE_TRIGGER_STATES = [
*parametrize_trigger_states(
trigger="zone.entered",
trigger_options={"zone": TRIGGER_ZONE},
target_states=[
("home", IN_ZONES_HOME),
],
other_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
),
*parametrize_trigger_states(
trigger="zone.left",
trigger_options={"zone": TRIGGER_ZONE},
target_states=[
("not_home", IN_ZONES_NONE),
("Work", IN_ZONES_WORK),
],
other_states=[
("home", IN_ZONES_HOME),
],
),
]
def _parametrize_zone_target_entities() -> list[tuple[dict[str, Any], str, int, str]]:
"""Parametrize target entities for all supported zone trigger domains."""
return [
(*params, domain)
for domain in ("person", "device_tracker")
for params in parametrize_target_entities(domain)
]
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target", "domain"),
_parametrize_zone_target_entities(),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
_ZONE_TRIGGER_STATES,
)
async def test_zone_trigger_behavior_each(
hass: HomeAssistant,
target_zone_entities: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when any targeted entity changes."""
await assert_trigger_behavior_each(
hass,
target_entities=target_zone_entities,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target", "domain"),
_parametrize_zone_target_entities(),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
_ZONE_TRIGGER_STATES,
)
async def test_zone_trigger_behavior_first(
hass: HomeAssistant,
target_zone_entities: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when first targeted entity changes."""
await assert_trigger_behavior_first(
hass,
target_entities=target_zone_entities,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target", "domain"),
_parametrize_zone_target_entities(),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
_ZONE_TRIGGER_STATES,
)
async def test_zone_trigger_behavior_all(
hass: HomeAssistant,
target_zone_entities: dict[str, list[str]],
trigger_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test zone triggers fire when last targeted entity changes."""
await assert_trigger_behavior_all(
hass,
target_entities=target_zone_entities,
trigger_target_config=trigger_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
trigger=trigger,
trigger_options=trigger_options,
states=states,
)
# --- Zone occupancy trigger tests ---
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key"),
["zone.occupancy_detected", "zone.occupancy_cleared"],
)
async def test_zone_occupancy_trigger_options_validation(
hass: HomeAssistant,
trigger_key: str,
) -> None:
"""Test that occupancy triggers support the expected options."""
await assert_trigger_options_supported(
hass,
trigger_key,
{"zone": ZONE_HOME},
supports_behavior=False,
supports_duration=True,
supports_target=False,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "from_state", "to_state", "should_fire"),
[
# occupancy_detected
pytest.param("zone.occupancy_detected", "0", "1", True, id="detected_0_to_1"),
pytest.param("zone.occupancy_detected", "0", "3", True, id="detected_0_to_3"),
pytest.param("zone.occupancy_detected", "1", "2", False, id="detected_1_to_2"),
pytest.param("zone.occupancy_detected", "2", "0", False, id="detected_2_to_0"),
pytest.param(
"zone.occupancy_detected",
STATE_UNKNOWN,
"1",
False,
id="detected_unknown_to_1",
),
pytest.param(
"zone.occupancy_detected",
STATE_UNAVAILABLE,
"1",
False,
id="detected_unavailable_to_1",
),
pytest.param(
"zone.occupancy_detected",
"0",
STATE_UNAVAILABLE,
False,
id="detected_0_to_unavailable",
),
# occupancy_cleared
pytest.param("zone.occupancy_cleared", "1", "0", True, id="cleared_1_to_0"),
pytest.param("zone.occupancy_cleared", "3", "0", True, id="cleared_3_to_0"),
pytest.param("zone.occupancy_cleared", "2", "1", False, id="cleared_2_to_1"),
pytest.param("zone.occupancy_cleared", "0", "1", False, id="cleared_0_to_1"),
pytest.param(
"zone.occupancy_cleared",
"1",
STATE_UNAVAILABLE,
False,
id="cleared_1_to_unavailable",
),
pytest.param(
"zone.occupancy_cleared",
"1",
STATE_UNKNOWN,
False,
id="cleared_1_to_unknown",
),
],
)
async def test_zone_occupancy_trigger_transitions(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_key: str,
from_state: str,
to_state: str,
should_fire: bool,
) -> None:
"""Test occupancy triggers fire on the expected numeric-state transitions."""
hass.states.async_set(ZONE_HOME, from_state)
await hass.async_block_till_done()
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"trigger": trigger_key,
"options": {"zone": ZONE_HOME},
},
"action": {"service": "test.automation"},
}
},
)
hass.states.async_set(ZONE_HOME, to_state)
await hass.async_block_till_done()
assert (len(service_calls) == 1) is should_fire
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_key", "from_value", "to_value", "revert_value"),
[
("zone.occupancy_detected", "0", "1", "0"),
("zone.occupancy_cleared", "1", "0", "1"),
],
)
async def test_zone_occupancy_trigger_for_duration(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_calls: list[ServiceCall],
trigger_key: str,
from_value: str,
to_value: str,
revert_value: str,
) -> None:
"""Test that `for` delays the firing and an early revert cancels it."""
hass.states.async_set(ZONE_HOME, from_value)
await hass.async_block_till_done()
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"trigger": trigger_key,
"options": {"zone": ZONE_HOME, "for": {"seconds": 5}},
},
"action": {"service": "test.automation"},
}
},
)
# Transition, then revert before the duration elapses -> no fire.
hass.states.async_set(ZONE_HOME, to_value)
await hass.async_block_till_done()
hass.states.async_set(ZONE_HOME, revert_value)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(service_calls) == 0
# Transition and hold past the duration -> fire once.
hass.states.async_set(ZONE_HOME, to_value)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(service_calls) == 1
+84
View File
@@ -2205,6 +2205,90 @@ async def test_condition_template_error(hass: HomeAssistant) -> None:
test.async_check()
@pytest.mark.parametrize(
("value_template", "expectation", "expected_template_errors", "expected_result"),
[
# Undefined variable used in a way that raises (e.g. attribute access)
(
"{{ trigger.to_state.attributes.event_type == 'double_press' }}",
pytest.raises(ConditionError),
["'trigger' is undefined"],
{},
),
# Undefined variable used in a way that only warns
(
"{{ no_such_variable }}",
does_not_raise(),
["'no_such_variable' is undefined"],
{"result": False, "entities": []},
),
# A single render can emit more than one message
(
"{{ foo }}{{ bar }}",
does_not_raise(),
["'foo' is undefined", "'bar' is undefined"],
{"result": False, "entities": []},
),
],
)
async def test_condition_template_error_traced_not_logged(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
value_template: str,
expectation: AbstractContextManager,
expected_template_errors: list[str],
expected_result: dict[str, Any],
) -> None:
"""Test template errors are added to the trace and not logged when opted in.
The subscribe_condition websocket command re-evaluates a condition every
second and opts in via trace.record_template_errors(). Template variable
errors must then be recorded in the trace instead of being logged repeatedly.
"""
caplog.set_level(logging.WARNING)
config = {"condition": "template", "value_template": value_template}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
with expectation, trace.record_template_errors():
test.async_check()
# The template errors are recorded in the trace...
condition_trace = trace.trace_get(clear=False)
trace.trace_clear()
trace_element = condition_trace[""][0]
assert trace_element.template_errors == expected_template_errors
assert (trace_element._result or {}) == expected_result
# ...and not logged
assert "Template variable" not in caplog.text
async def test_condition_template_error_logged_without_opt_in(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test template errors are logged when recording is not opted in.
An active trace is not enough to suppress logging; the consumer must opt in
via trace.record_template_errors(). Without it, the error is logged as usual
and not recorded in the trace.
"""
caplog.set_level(logging.WARNING)
config = {"condition": "template", "value_template": "{{ no_such_variable }}"}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
assert test.async_check() is False
assert "Template variable warning: 'no_such_variable' is undefined" in caplog.text
condition_trace = trace.trace_get(clear=False)
trace.trace_clear()
assert condition_trace[""][0].template_errors == []
async def test_condition_template_invalid_results(hass: HomeAssistant) -> None:
"""Test template condition render false with invalid results."""
config = {"condition": "template", "value_template": "{{ 'string' }}"}
+36
View File
@@ -2084,3 +2084,39 @@ def test_base_schemas_reject_invalid_note(
"""Test that script, condition, trigger base schemas reject non-string notes."""
with pytest.raises(vol.Invalid):
validator({**base_config, "note": invalid_note})
_CHOOSE_OPTION_BASE_CONFIG = {
"conditions": [
{"condition": "state", "entity_id": "sun.sun", "state": "above_horizon"}
],
"sequence": [{"action": "test.foo"}],
}
@pytest.mark.usefixtures("hass")
def test_choose_option_accepts_note() -> None:
"""Test that the note field is accepted and stripped from a choose option."""
validated = cv.script_action(
{"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": "Single line"}]}
)
assert "note" not in validated["choose"][0]
@pytest.mark.parametrize(
"invalid_note",
[
pytest.param(None, id="none"),
pytest.param(42, id="int"),
pytest.param(True, id="bool"),
pytest.param([], id="list"),
pytest.param({}, id="dict"),
],
)
@pytest.mark.usefixtures("hass")
def test_choose_option_rejects_invalid_note(invalid_note: Any) -> None:
"""Test that choose option schemas reject non-string notes."""
with pytest.raises(vol.Invalid):
cv.script_action(
{"choose": [{**_CHOOSE_OPTION_BASE_CONFIG, "note": invalid_note}]}
)
+69 -3
View File
@@ -53,6 +53,7 @@ from homeassistant.helpers.trigger import (
BEHAVIOR_EACH,
BEHAVIOR_FIRST,
DATA_PLUGGABLE_ACTIONS,
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
TRIGGERS,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
@@ -4639,13 +4640,33 @@ async def test_entity_trigger_duration_cancelled_on_invalid_state(
pytest.param(
{
"platform": "zone",
"entity_id": ["person.a"],
"zone": "zone.home",
"event": "enter",
"options": {
"entity_id": ["person.a"],
"zone": "zone.home",
"event": "enter",
},
},
["person.a", "zone.home"],
id="zone-legacy",
),
pytest.param(
{
"platform": "zone.entered",
"target": {"entity_id": ["person.a", "device_tracker.b"]},
"options": {"zone": "zone.home"},
},
["person.a", "device_tracker.b", "zone.home"],
id="zone-entered-modern",
),
pytest.param(
{
"platform": "zone.left",
"target": {"entity_id": "person.a"},
"options": {"zone": "zone.home"},
},
["person.a", "zone.home"],
id="zone-left-modern",
),
pytest.param(
{"platform": "geo_location", "zone": "zone.home"},
["zone.home"],
@@ -4767,3 +4788,48 @@ def test_async_extract_devices(
) -> None:
"""Test extracting devices from various trigger config shapes."""
assert trigger.async_extract_devices(trigger_conf) == expected
@pytest.mark.parametrize(
("behavior", "expected"),
[
# Legacy values are converted to their new equivalents
("any", BEHAVIOR_EACH),
("last", BEHAVIOR_ALL),
# New values pass through unchanged
(BEHAVIOR_FIRST, BEHAVIOR_FIRST),
(BEHAVIOR_ALL, BEHAVIOR_ALL),
(BEHAVIOR_EACH, BEHAVIOR_EACH),
],
)
def test_entity_state_trigger_schema_behavior_backwards_compatible(
behavior: str, expected: str
) -> None:
"""Test legacy behavior values are converted to their new equivalents."""
config = {
CONF_TARGET: {CONF_ENTITY_ID: "test.entity"},
CONF_OPTIONS: {ATTR_BEHAVIOR: behavior},
}
validated = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config)
assert validated[CONF_OPTIONS][ATTR_BEHAVIOR] == expected
def test_entity_state_trigger_schema_behavior_default() -> None:
"""Test the behavior defaults to 'each' when omitted."""
config = {
CONF_TARGET: {CONF_ENTITY_ID: "test.entity"},
CONF_OPTIONS: {},
}
validated = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config)
assert validated[CONF_OPTIONS][ATTR_BEHAVIOR] == BEHAVIOR_EACH
@pytest.mark.parametrize("behavior", ["invalid", "anything", ""])
def test_entity_state_trigger_schema_behavior_invalid(behavior: str) -> None:
"""Test invalid behavior values are rejected."""
config = {
CONF_TARGET: {CONF_ENTITY_ID: "test.entity"},
CONF_OPTIONS: {ATTR_BEHAVIOR: behavior},
}
with pytest.raises(vol.Invalid):
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR(config)