mirror of
https://github.com/home-assistant/core.git
synced 2026-06-03 18:03:43 +02:00
Compare commits
45 Commits
2026.6.0b1
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
| ad99929178 | |||
| d2672050cf | |||
| 74fd636aa6 | |||
| b4f8fce912 | |||
| 78a97f99dc | |||
| 5d0565f007 | |||
| 083af9ccc7 | |||
| 6c87284dee | |||
| 0e0b29d16e | |||
| 8e493d84f1 | |||
| 4e2bc610e3 | |||
| 82d83feda4 | |||
| 265fe6d338 | |||
| bb8036f2c8 | |||
| 387b84ec7b | |||
| 24037fcfa3 | |||
| 994b210588 | |||
| db6f1426ec | |||
| 8ce5ba2ba4 | |||
| b176fb2113 | |||
| ada8a98f87 | |||
| 763d9879bf | |||
| 7bbd0ea472 | |||
| 60f458a372 | |||
| 05eada2569 | |||
| d2abd7f6ca | |||
| af08e5e7d0 | |||
| b03d87dc21 | |||
| d8a9ea1d9d | |||
| 5ff07fcc49 | |||
| 6f59bb0661 | |||
| c82d32bbae | |||
| 4fbc363965 | |||
| 8622f0f4de | |||
| b49a6b89b6 | |||
| 0bfd4c44bb | |||
| c09216650f | |||
| 6057d32636 | |||
| 51c9d0c6e5 | |||
| 323304664e | |||
| 3dda7d9848 | |||
| 5e56d74257 | |||
| e5f9c7892a | |||
| a0d713a4a7 | |||
| 84f4f876b1 |
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+7
-7
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}"}
|
||||
|
||||
@@ -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}]}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user