Compare commits

..

28 Commits

Author SHA1 Message Date
Ariel Ebersberger
e937b09652 Merge branch 'dev' into python-3.14.3 2026-04-15 19:04:03 +02:00
Ariel Ebersberger
533871babb Optimize add_job to skip double-deferral for @callback targets (#168198) 2026-04-15 18:50:33 +02:00
Erik Montnemery
1dc93a80c4 Improve type annotations and remove unused code in mobile_app (#168298) 2026-04-15 18:09:10 +02:00
Erik Montnemery
f8a94c6f22 Fix climate trigger labs flag test (#168299) 2026-04-15 17:53:26 +02:00
Erik Montnemery
b127d13587 Add additional media_player triggers (#156927)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-04-15 17:34:36 +02:00
renovate[bot]
1895f8ebce Update attrs to 26.1.0 (#168276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-04-15 17:22:33 +02:00
renovate[bot]
b6916954dc Update respx to 0.23.1 (#168272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 17:10:28 +02:00
renovate[bot]
23181f5275 Update pytest-github-actions-annotate-failures to 0.4.0 (#168269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 16:59:51 +02:00
Robert Resch
607a10d1e1 Use pip to install dynamically extracted version from requirements.txt (#168246) 2026-04-15 16:34:01 +02:00
Ariel Ebersberger
ecb814adb0 Add test coverage for add_job and fix docstring (#168291) 2026-04-15 16:17:01 +02:00
G Johansson
67df556e84 Add async_on_create_entry method to create config entries (#155016)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 15:57:32 +02:00
AlCalzone
4d472418c5 Ensure extra_fields in Z-Wave automation config are strings (#168281) 2026-04-15 15:12:18 +02:00
renovate[bot]
cf6441561c Update voluptuous-openapi to 0.3.0 (#168275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 15:06:24 +02:00
Erik Montnemery
6d8d447355 Revert "Add last_non_buffering_state media_player state attribute (#166941)" (#168285) 2026-04-15 14:41:02 +02:00
Erik Montnemery
ab5ae33290 Exclude unavailable and unknown in trigger first and last checks (#168224)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 14:20:49 +02:00
renovate[bot]
c0bf9a2bd2 Update pytest-sugar to 1.1.1 (#168270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 13:07:21 +02:00
Norbert Rittel
d862b999ae Capitalize "REST" abbreviation in scrape error messages (#168280) 2026-04-15 11:36:39 +02:00
Erik Montnemery
d6be6e8810 Improve timer tests (#168277) 2026-04-15 11:21:59 +02:00
Daniel Hjelseth Høyer
f397f4c908 Handle Tibber async_get_client failing (#168207)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-04-15 10:50:29 +02:00
G Johansson
d58e7862c0 Scrape sub config entry (#141389) 2026-04-15 09:59:12 +02:00
Erik Montnemery
84f57f9859 Deduplicate toggle entity condition tests (#168195) 2026-04-15 08:19:09 +02:00
Erik Montnemery
c6169ec8eb Add update conditions (#167751) 2026-04-15 08:03:51 +02:00
renovate[bot]
c47cecf350 Update SQLAlchemy to 2.0.49 (#168260) 2026-04-15 07:20:58 +02:00
renovate[bot]
e31f611901 Update pytest-cov to 7.1.0 (#168267) 2026-04-15 07:20:10 +02:00
renovate[bot]
bc36b1dda2 Update coverage to 7.13.5 (#168238) 2026-04-15 07:19:39 +02:00
renovate[bot]
b3967130f0 Update orjson to 3.11.8 (#168259) 2026-04-15 06:40:43 +02:00
renovate[bot]
2960db3d8e Update codespell (#168235) 2026-04-15 06:34:50 +02:00
Jan Čermák
427faf4854 Bump base image to 2026.02.0 with Python 3.14.3, use 3.14.3 in CI
This also bumps libcec used in the base image to 7.1.1, full changelog:
* https://github.com/home-assistant/docker/releases/tag/2026.02.0

Python changelog:
* https://docs.python.org/release/3.14.3/whatsnew/changelog.html
2026-04-10 10:23:28 +02:00
108 changed files with 2896 additions and 2412 deletions

View File

@@ -36,7 +36,6 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**

View File

@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2026.02.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}

View File

@@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
rev: v2.4.2
hooks:
- id: codespell
args:

View File

@@ -1 +1 @@
3.14.2
3.14.3

16
Dockerfile generated
View File

@@ -19,25 +19,23 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv==0.11.1
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \
uv pip install \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv at the version pinned in the requirements file
&& pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' homeassistant/requirements.txt)" \
&& uv pip install \
--no-build \
-r homeassistant/requirements.txt

View File

@@ -152,6 +152,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"water_heater",

View File

@@ -35,7 +35,6 @@ from aioesphomeapi import (
MediaPlayerInfo,
MediaPlayerSupportedFormat,
NumberInfo,
RadioFrequencyInfo,
SelectInfo,
SensorInfo,
SensorState,
@@ -89,7 +88,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
NumberInfo: Platform.NUMBER,

View File

@@ -1,79 +0,0 @@
"""Radio Frequency platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import (
EntityState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
ModulationType.OOK: RadioFrequencyModulation.OOK,
}
class EsphomeRadioFrequencyEntity(
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
):
"""ESPHome radio frequency entity using native API."""
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges from device info."""
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending RF command: %s", timings)
self._client.radio_frequency_transmit_raw_timings(
self._static_info.key,
frequency=command.frequency,
timings=timings,
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
repeat_count=command.repeat_count + 1,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=RadioFrequencyInfo,
entity_type=EsphomeRadioFrequencyEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & RadioFrequencyCapability.TRANSMITTER
),
)

View File

@@ -75,7 +75,6 @@ from .const import ( # noqa: F401
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_INPUT_SOURCE_LIST,
ATTR_LAST_NON_BUFFERING_STATE,
ATTR_MEDIA_ALBUM_ARTIST,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ANNOUNCE,
@@ -588,8 +587,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_volume_level: float | None = None
_attr_volume_step: float
__last_non_buffering_state: MediaPlayerState | None = None
# Implement these for your media player
@cached_property
def device_class(self) -> MediaPlayerDeviceClass | None:
@@ -1127,12 +1124,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
if (state := self.state) != MediaPlayerState.BUFFERING:
self.__last_non_buffering_state = state
state_attr: dict[str, Any] = {
ATTR_LAST_NON_BUFFERING_STATE: self.__last_non_buffering_state
}
state_attr: dict[str, Any] = {}
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members

View File

@@ -13,7 +13,6 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local"
ATTR_GROUP_MEMBERS = "group_members"
ATTR_INPUT_SOURCE = "source"
ATTR_INPUT_SOURCE_LIST = "source_list"
ATTR_LAST_NON_BUFFERING_STATE = "last_non_buffering_state"
ATTR_MEDIA_ANNOUNCE = "announce"
ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist"
ATTR_MEDIA_ALBUM_NAME = "media_album_name"

View File

@@ -123,8 +123,20 @@
}
},
"triggers": {
"paused_playing": {
"trigger": "mdi:pause"
},
"started_playing": {
"trigger": "mdi:play"
},
"stopped_playing": {
"trigger": "mdi:stop"
},
"turned_off": {
"trigger": "mdi:power"
},
"turned_on": {
"trigger": "mdi:power"
}
}
}

View File

@@ -433,14 +433,50 @@
},
"title": "Media player",
"triggers": {
"paused_playing": {
"description": "Triggers after one or more media players pause playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "Media player paused playing"
},
"started_playing": {
"description": "Triggers after one or more media players start playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "Media player started playing"
},
"stopped_playing": {
"description": "Triggers after one or more media players stop playing media.",
"description": "Triggers after one or more media players stop playing.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "Media player stopped playing"
},
"turned_off": {
"description": "Triggers after one or more media players turn off.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "Media player turned off"
},
"turned_on": {
"description": "Triggers after one or more media players turn on.",
"fields": {
"behavior": {
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "Media player turned on"
}
}
}

View File

@@ -7,6 +7,29 @@ from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"paused_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.PAUSED,
},
),
"started_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
to_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
},
),
"stopped_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
@@ -20,6 +43,32 @@ TRIGGERS: dict[str, type[Trigger]] = {
MediaPlayerState.ON,
},
),
"turned_off": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.OFF,
},
),
"turned_on": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.OFF,
},
to_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
),
}

View File

@@ -1,4 +1,4 @@
stopped_playing:
.trigger_common: &trigger_common
target:
entity:
domain: media_player
@@ -13,3 +13,9 @@ stopped_playing:
- first
- last
- any
paused_playing: *trigger_common
started_playing: *trigger_common
stopped_playing: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -1,5 +1,6 @@
"""Device tracker for Mobile app."""
from collections.abc import Callable
from typing import Any
from homeassistant.components.device_tracker import (
@@ -53,11 +54,11 @@ async def async_setup_entry(
class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
def __init__(self, entry, data=None):
def __init__(self, entry: ConfigEntry) -> None:
"""Set up Mobile app entity."""
self._entry = entry
self._data = data
self._dispatch_unsub = None
self._data: dict[str, Any] = {}
self._dispatch_unsub: Callable[[], None] | None = None
@property
def unique_id(self) -> str:
@@ -132,12 +133,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
self.update_data,
)
# Don't restore if we got set up with data.
if self._data is not None:
return
if (state := await self.async_get_last_state()) is None:
self._data = {}
return
attr = state.attributes
@@ -158,7 +154,7 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
self._dispatch_unsub = None
@callback
def update_data(self, data):
def update_data(self, data: dict[str, Any]) -> None:
"""Mark the device as seen."""
self._data = data
self.async_write_ha_state()

View File

@@ -54,7 +54,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
) -> None:
"""Initialize sensor entiry."""
"""Initialize sensor entity."""
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-error-status"

View File

@@ -1,189 +0,0 @@
"""Provides functionality to interact with radio frequency devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import timedelta
import logging
from typing import final
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
__all__ = [
"DOMAIN",
"ModulationType",
"RadioFrequencyTransmitterEntity",
"RadioFrequencyTransmitterEntityDescription",
"async_get_transmitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[RadioFrequencyTransmitterEntity]] = HassKey(
DOMAIN
)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the radio_frequency domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[
RadioFrequencyTransmitterEntity
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_transmitters(
hass: HomeAssistant,
frequency: int,
modulation: ModulationType,
) -> list[str]:
"""Get entity IDs of all RF transmitters supporting the given frequency.
An empty list means no compatible transmitters.
Raises:
HomeAssistantError: If no transmitters exist.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
entities = list(component.entities)
if not entities:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="no_transmitters",
)
return [
entity.entity_id
for entity in entities
if any(
low <= frequency <= high for low, high in entity.supported_frequency_ranges
)
]
async def async_send_command(
hass: HomeAssistant,
entity_id_or_uuid: str,
command: RadioFrequencyCommand,
context: Context | None = None,
) -> None:
"""Send an RF command to the specified radio_frequency entity.
Raises:
HomeAssistantError: If the radio_frequency entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class RadioFrequencyTransmitterEntityDescription(
EntityDescription, frozen_or_thawed=True
):
"""Describes radio frequency transmitter entities."""
class RadioFrequencyTransmitterEntity(RestoreEntity):
"""Base class for radio frequency transmitter entities."""
entity_description: RadioFrequencyTransmitterEntityDescription
_attr_should_poll = False
_attr_state: None = None
__last_command_sent: str | None = None
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return list of (min_hz, max_hz) tuples."""
raise NotImplementedError
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_command_sent
@final
async def async_send_command_internal(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the radio frequency entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_command_sent = state.state
@abstractmethod
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command.
Args:
command: The RF command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -1,5 +0,0 @@
"""Constants for the Radio Frequency integration."""
from typing import Final
DOMAIN: Final = "radio_frequency"

View File

@@ -1,7 +0,0 @@
{
"entity_component": {
"_": {
"default": "mdi:radio-tower"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"domain": "radio_frequency",
"name": "Radio Frequency",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==0.0.1"]
}

View File

@@ -1,13 +0,0 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Radio Frequency component not loaded"
},
"entity_not_found": {
"message": "Radio Frequency entity `{entity_id}` not found"
},
"no_transmitters": {
"message": "No Radio Frequency transmitters available"
}
}
}

View File

@@ -1,13 +0,0 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Radio Frequency component not loaded"
},
"entity_not_found": {
"message": "Radio Frequency entity `{entity_id}` not found"
},
"no_transmitters": {
"message": "No Radio Frequency transmitters available"
}
}
}

View File

@@ -192,7 +192,7 @@ ID_TYPE = BigInteger().with_variant(sqlite.INTEGER, "sqlite")
# For MariaDB and MySQL we can use an unsigned integer type since it will fit 2**32
# for sqlite and postgresql we use a bigint
UINT_32_TYPE = BigInteger().with_variant(
mysql.INTEGER(unsigned=True), # type: ignore[no-untyped-call]
mysql.INTEGER(unsigned=True),
"mysql",
"mariadb",
)
@@ -206,12 +206,12 @@ JSONB_VARIANT_CAST = Text().with_variant(
)
DATETIME_TYPE = (
DateTime(timezone=True)
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql", "mariadb")
.with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") # type: ignore[no-untyped-call]
)
DOUBLE_TYPE = (
Float()
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb") # type: ignore[no-untyped-call]
.with_variant(mysql.DOUBLE(asdecimal=False), "mysql", "mariadb")
.with_variant(oracle.DOUBLE_PRECISION(), "oracle")
.with_variant(postgresql.DOUBLE_PRECISION(), "postgresql")
)

View File

@@ -7,7 +7,7 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.41",
"SQLAlchemy==2.0.49",
"fnv-hash-fast==2.0.0",
"psutil-home-assistant==0.0.1"
]

View File

@@ -447,10 +447,10 @@ def setup_connection_for_dialect(
slow_dependent_subquery = False
if dialect_name == SupportedDialect.SQLITE:
if first_connection:
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
dbapi_connection.isolation_level = None # type: ignore[attr-defined]
old_isolation = dbapi_connection.isolation_level
dbapi_connection.isolation_level = None
execute_on_connection(dbapi_connection, "PRAGMA journal_mode=WAL")
dbapi_connection.isolation_level = old_isolation # type: ignore[attr-defined]
dbapi_connection.isolation_level = old_isolation
# WAL mode only needs to be setup once
# instead of every time we open the sqlite connection
# as its persistent and isn't free to call every time.

View File

@@ -4,27 +4,40 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from copy import deepcopy
from datetime import timedelta
import logging
from types import MappingProxyType
from typing import Any
import voluptuous as vol
from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.components.sensor import CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_AUTHENTICATION,
CONF_DEVICE_CLASS,
CONF_HEADERS,
CONF_NAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
TEMPLATE_SENSOR_BASE_SCHEMA,
@@ -32,11 +45,22 @@ from homeassistant.helpers.trigger_template_entity import (
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS
from .const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
PLATFORMS,
)
from .coordinator import ScrapeCoordinator
type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator]
_LOGGER = logging.getLogger(__name__)
SENSOR_SCHEMA = vol.Schema(
{
**TEMPLATE_SENSOR_BASE_SCHEMA.schema,
@@ -103,7 +127,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Set up Scrape from a config entry."""
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options))
config: dict[str, Any] = dict(entry.options)
# Config flow uses sections but the COMBINED SCHEMA does not
# so we need to flatten the config here
config.update(config.pop(CONF_ADVANCED, {}))
config.update(config.pop(CONF_AUTH, {}))
rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(config))
rest = create_rest_data_from_config(hass, rest_config)
coordinator = ScrapeCoordinator(
@@ -117,17 +147,159 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
old_to_new_sensor_id = {}
for sensor_config in entry.options[SENSOR_DOMAIN]:
# Create a new sub config entry per sensor
title = sensor_config[CONF_NAME]
old_unique_id = sensor_config[CONF_UNIQUE_ID]
subentry_config = {
CONF_INDEX: sensor_config[CONF_INDEX],
CONF_SELECT: sensor_config[CONF_SELECT],
CONF_ADVANCED: {},
}
for sensor_advanced_key in (
CONF_ATTRIBUTE,
CONF_VALUE_TEMPLATE,
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
):
if sensor_advanced_key not in sensor_config:
continue
subentry_config[CONF_ADVANCED][sensor_advanced_key] = sensor_config[
sensor_advanced_key
]
new_sub_entry = ConfigSubentry(
data=MappingProxyType(subentry_config),
subentry_type="entity",
title=title,
unique_id=None,
)
_LOGGER.debug(
"Migrating sensor %s with unique id %s to sub config entry id %s, old data %s, new data %s",
title,
old_unique_id,
new_sub_entry.subentry_id,
sensor_config,
subentry_config,
)
old_to_new_sensor_id[old_unique_id] = new_sub_entry.subentry_id
hass.config_entries.async_add_subentry(entry, new_sub_entry)
# Use the new sub config entry id as the unique id for the sensor entity
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
for entity in entities:
if (old_unique_id := entity.unique_id) in old_to_new_sensor_id:
new_unique_id = old_to_new_sensor_id[old_unique_id]
_LOGGER.debug(
"Migrating entity %s with unique id %s to new unique id %s",
entity.entity_id,
entity.unique_id,
new_unique_id,
)
entity_reg.async_update_entity(
entity.entity_id,
config_entry_id=entry.entry_id,
config_subentry_id=new_unique_id,
new_unique_id=new_unique_id,
)
# Use the new sub config entry id as the identifier for the sensor device
device_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(device_reg, entry.entry_id)
for device in devices:
for domain, identifier in device.identifiers:
if domain != DOMAIN or identifier not in old_to_new_sensor_id:
continue
subentry_id = old_to_new_sensor_id[identifier]
new_identifiers = deepcopy(device.identifiers)
new_identifiers.remove((domain, identifier))
new_identifiers.add((domain, old_to_new_sensor_id[identifier]))
_LOGGER.debug(
"Migrating device %s with identifiers %s to new identifiers %s",
device.id,
device.identifiers,
new_identifiers,
)
device_reg.async_update_device(
device.id,
add_config_entry_id=entry.entry_id,
add_config_subentry_id=subentry_id,
new_identifiers=new_identifiers,
)
# Removing None from the list of subentries if existing
# as the device should only belong to the subentry
# and not the main config entry
device_reg.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Update the resource config
new_config_entry_data = dict(entry.options)
new_config_entry_data[CONF_AUTH] = {}
new_config_entry_data[CONF_ADVANCED] = {}
new_config_entry_data.pop(SENSOR_DOMAIN, None)
for resource_advanced_key in (
CONF_HEADERS,
CONF_VERIFY_SSL,
CONF_TIMEOUT,
CONF_ENCODING,
):
if resource_advanced_key in new_config_entry_data:
new_config_entry_data[CONF_ADVANCED][resource_advanced_key] = (
new_config_entry_data.pop(resource_advanced_key)
)
for resource_auth_key in (CONF_AUTHENTICATION, CONF_USERNAME, CONF_PASSWORD):
if resource_auth_key in new_config_entry_data:
new_config_entry_data[CONF_AUTH][resource_auth_key] = (
new_config_entry_data.pop(resource_auth_key)
)
_LOGGER.debug(
"Migrating config entry %s from version 1 to version 2 with data %s",
entry.entry_id,
new_config_entry_data,
)
hass.config_entries.async_update_entry(
entry, version=2, options=new_config_entry_data
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Unload Scrape config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: ScrapeConfigEntry) -> None:
"""Handle config entry update."""
hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
) -> bool:
"""Remove Scrape config entry from a device."""
entity_registry = er.async_get(hass)

View File

@@ -2,12 +2,13 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
import uuid
from copy import deepcopy
import logging
from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.rest import create_rest_data_from_config
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
DEFAULT_TIMEOUT,
@@ -18,10 +19,17 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_AUTHENTICATION,
@@ -33,7 +41,6 @@ from homeassistant.const import (
CONF_PAYLOAD,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
@@ -42,15 +49,7 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
UnitOfTemperature,
)
from homeassistant.core import async_get_hass
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaFlowMenuStep,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
@@ -69,6 +68,8 @@ from homeassistant.helpers.trigger_template_entity import CONF_AVAILABILITY
from . import COMBINED_SCHEMA
from .const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
@@ -78,243 +79,212 @@ from .const import (
DOMAIN,
)
RESOURCE_SETUP = {
vol.Required(CONF_RESOURCE): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
),
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
SelectSelectorConfig(
options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_USERNAME): TextSelector(),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Optional(CONF_HEADERS): ObjectSelector(),
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(),
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(),
}
_LOGGER = logging.getLogger(__name__)
SENSOR_SETUP = {
vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[
cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM
],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
sort=True,
)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
sort=True,
)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
translation_key="unit_of_measurement",
sort=True,
)
),
}
async def validate_rest_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate rest setup."""
hass = async_get_hass()
rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input)
try:
rest = create_rest_data_from_config(hass, rest_config)
await rest.async_update()
except Exception as err:
raise SchemaFlowError("resource_error") from err
if rest.data is None:
raise SchemaFlowError("resource_error")
return user_input
async def validate_sensor_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate sensor input."""
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
user_input[CONF_UNIQUE_ID] = str(uuid.uuid1())
# Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly.
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
sensors.append(user_input)
return {}
async def validate_select_sensor(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Store sensor index in flow state."""
handler.flow_state["_idx"] = int(user_input[CONF_INDEX])
return {}
async def get_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for selecting a sensor."""
return vol.Schema(
{
vol.Required(CONF_INDEX): vol.In(
{
str(index): config[CONF_NAME]
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
},
)
}
)
async def get_edit_sensor_suggested_values(
handler: SchemaCommonFlowHandler,
) -> dict[str, Any]:
"""Return suggested values for sensor editing."""
idx: int = handler.flow_state["_idx"]
return dict(handler.options[SENSOR_DOMAIN][idx])
async def validate_sensor_edit(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Update edited sensor."""
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly,
# including popping omitted optional schema items.
idx: int = handler.flow_state["_idx"]
handler.options[SENSOR_DOMAIN][idx].update(user_input)
for key in DATA_SCHEMA_EDIT_SENSOR.schema:
if isinstance(key, vol.Optional) and key not in user_input:
# Key not present, delete keys old value (if present) too
handler.options[SENSOR_DOMAIN][idx].pop(key, None)
return {}
async def get_remove_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for sensor removal."""
return vol.Schema(
{
vol.Required(CONF_INDEX): cv.multi_select(
{
str(index): config[CONF_NAME]
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
},
)
}
)
async def validate_remove_sensor(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate remove sensor."""
removed_indexes: set[str] = set(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options.
# In this case, we want to remove sub-items so we update the options directly.
entity_registry = er.async_get(handler.parent_handler.hass)
sensors: list[dict[str, Any]] = []
sensor: dict[str, Any]
for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]):
if str(index) not in removed_indexes:
sensors.append(sensor)
elif entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, sensor[CONF_UNIQUE_ID]
):
entity_registry.async_remove(entity_id)
handler.options[SENSOR_DOMAIN] = sensors
return {}
DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP)
DATA_SCHEMA_EDIT_SENSOR = vol.Schema(SENSOR_SETUP)
DATA_SCHEMA_SENSOR = vol.Schema(
RESOURCE_SETUP = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
**SENSOR_SETUP,
vol.Required(CONF_RESOURCE): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector(
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
),
vol.Optional(CONF_PAYLOAD): ObjectSelector(),
vol.Required(CONF_AUTH): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_AUTHENTICATION): SelectSelector(
SelectSelectorConfig(
options=[
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_USERNAME): TextSelector(
TextSelectorConfig(
type=TextSelectorType.TEXT, autocomplete="username"
)
),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
vol.Required(CONF_ADVANCED): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_HEADERS): ObjectSelector(),
vol.Optional(
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
): BooleanSelector(),
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Optional(
CONF_ENCODING, default=DEFAULT_ENCODING
): TextSelector(),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
}
)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_RESOURCE,
next_step="sensor",
validate_user_input=validate_rest_setup,
),
"sensor": SchemaFlowFormStep(
schema=DATA_SCHEMA_SENSOR,
validate_user_input=validate_sensor_setup,
),
}
OPTIONS_FLOW = {
"init": SchemaFlowMenuStep(
["resource", "add_sensor", "select_edit_sensor", "remove_sensor"]
),
"resource": SchemaFlowFormStep(
DATA_SCHEMA_RESOURCE,
validate_user_input=validate_rest_setup,
),
"add_sensor": SchemaFlowFormStep(
DATA_SCHEMA_SENSOR,
suggested_values=None,
validate_user_input=validate_sensor_setup,
),
"select_edit_sensor": SchemaFlowFormStep(
get_select_sensor_schema,
suggested_values=None,
validate_user_input=validate_select_sensor,
next_step="edit_sensor",
),
"edit_sensor": SchemaFlowFormStep(
DATA_SCHEMA_EDIT_SENSOR,
suggested_values=get_edit_sensor_suggested_values,
validate_user_input=validate_sensor_edit,
),
"remove_sensor": SchemaFlowFormStep(
get_remove_sensor_schema,
suggested_values=None,
validate_user_input=validate_remove_sensor,
),
}
SENSOR_SETUP = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): vol.All(
NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
),
vol.Coerce(int),
),
vol.Required(CONF_ADVANCED): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[
cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
sort=True,
)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
sort=True,
)
),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
translation_key="unit_of_measurement",
sort=True,
)
),
}
),
data_entry_flow.SectionConfig(collapsed=True),
),
}
)
class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Scrape."""
async def validate_rest_setup(
hass: HomeAssistant, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate rest setup."""
config = deepcopy(user_input)
config.update(config.pop(CONF_ADVANCED, {}))
config.update(config.pop(CONF_AUTH, {}))
rest_config: dict[str, Any] = COMBINED_SCHEMA(config)
try:
rest = create_rest_data_from_config(hass, rest_config)
await rest.async_update()
except Exception:
_LOGGER.exception("Error when getting resource %s", config[CONF_RESOURCE])
return {"base": "resource_error"}
if rest.data is None:
return {"base": "no_data"}
return {}
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
options_flow_reloads = True
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_RESOURCE])
class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Scrape configuration flow."""
VERSION = 2
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow:
"""Get the options flow for this handler."""
return ScrapeOptionFlow()
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"entity": ScrapeSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User flow to create the main config entry."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await validate_rest_setup(self.hass, user_input)
title = user_input[CONF_RESOURCE]
if not errors:
return self.async_create_entry(data={}, options=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
RESOURCE_SETUP, user_input or {}
),
errors=errors,
)
class ScrapeOptionFlow(OptionsFlow):
"""Scrape Options flow."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage Scrape options."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await validate_rest_setup(self.hass, user_input)
if not errors:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
RESOURCE_SETUP,
user_input or self.config_entry.options,
),
errors=errors,
)
class ScrapeSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a sensor subentry."""
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
SENSOR_SETUP, user_input or {}
),
)

View File

@@ -14,6 +14,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
PLATFORMS = [Platform.SENSOR]
CONF_ADVANCED = "advanced"
CONF_AUTH = "auth"
CONF_ENCODING = "encoding"
CONF_SELECT = "select"
CONF_INDEX = "index"

View File

@@ -0,0 +1,21 @@
{
"config": {
"step": {
"user": {
"sections": {
"advanced": "mdi:cog",
"auth": "mdi:lock"
}
}
}
},
"options": {
"step": {
"init": {
"sections": {
"advanced": "mdi:cog"
}
}
}
}
}

View File

@@ -46,9 +46,10 @@ TRIGGER_ENTITY_OPTIONS = (
CONF_AVAILABILITY,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_NAME,
CONF_PICTURE,
CONF_UNIQUE_ID,
CONF_STATE_CLASS,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
)
@@ -70,7 +71,7 @@ async def async_setup_platform(
entities: list[ScrapeSensor] = []
for sensor_config in sensors_config:
trigger_entity_config = {CONF_NAME: sensor_config[CONF_NAME]}
trigger_entity_config = {}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
continue
@@ -98,23 +99,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Scrape sensor entry."""
entities: list = []
coordinator = entry.runtime_data
config = dict(entry.options)
for sensor in config["sensor"]:
for subentry in entry.subentries.values():
sensor = dict(subentry.data)
sensor.update(sensor.pop("advanced", {}))
sensor[CONF_UNIQUE_ID] = subentry.subentry_id
sensor[CONF_NAME] = subentry.title
sensor_config: ConfigType = vol.Schema(
TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA
)(sensor)
name: str = sensor_config[CONF_NAME]
value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE)
value_template: ValueTemplate | None = (
ValueTemplate(value_string, hass) if value_string is not None else None
)
trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name}
trigger_entity_config: dict[str, str | Template | None] = {}
for key in TRIGGER_ENTITY_OPTIONS:
if key not in sensor_config:
continue
@@ -123,21 +125,22 @@ async def async_setup_entry(
continue
trigger_entity_config[key] = sensor_config[key]
entities.append(
ScrapeSensor(
hass,
coordinator,
trigger_entity_config,
sensor_config[CONF_SELECT],
sensor_config.get(CONF_ATTRIBUTE),
sensor_config[CONF_INDEX],
value_template,
False,
)
async_add_entities(
[
ScrapeSensor(
hass,
coordinator,
trigger_entity_config,
sensor_config[CONF_SELECT],
sensor_config.get(CONF_ATTRIBUTE),
sensor_config[CONF_INDEX],
value_template,
False,
)
],
config_subentry_id=subentry.subentry_id,
)
async_add_entities(entities)
class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEntity):
"""Representation of a web scrape sensor."""

View File

@@ -4,134 +4,140 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"resource_error": "Could not update rest data. Verify your configuration"
"no_data": "REST data is empty. Verify your configuration",
"resource_error": "Could not update REST data. Verify your configuration"
},
"step": {
"sensor": {
"data": {
"attribute": "Attribute",
"availability": "Availability template",
"device_class": "Device class",
"index": "Index",
"name": "[%key:common::config_flow::data::name%]",
"select": "Select",
"state_class": "State class",
"unit_of_measurement": "Unit of measurement",
"value_template": "Value template"
},
"data_description": {
"attribute": "Get value of an attribute on the selected tag.",
"availability": "Defines a template to get the availability of the sensor.",
"device_class": "The type/class of the sensor to set the icon in the frontend.",
"index": "Defines which of the elements returned by the CSS selector to use.",
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.",
"state_class": "The state_class of the sensor.",
"unit_of_measurement": "Choose unit of measurement or create your own.",
"value_template": "Defines a template to get the state of the sensor."
}
},
"user": {
"data": {
"authentication": "Select authentication method",
"encoding": "Character encoding",
"headers": "Headers",
"method": "Method",
"password": "[%key:common::config_flow::data::password%]",
"payload": "Payload",
"resource": "Resource",
"timeout": "Timeout",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"resource": "Resource"
},
"data_description": {
"authentication": "Type of the HTTP authentication. Either basic or digest.",
"encoding": "Character encoding to use. Defaults to UTF-8.",
"headers": "Headers to use for the web request.",
"payload": "Payload to use when method is POST.",
"resource": "The URL to the website that contains the value.",
"timeout": "Timeout for connection to website.",
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
"resource": "The URL to the website that contains the value."
},
"sections": {
"advanced": {
"data": {
"encoding": "Character encoding",
"headers": "Headers",
"timeout": "Timeout",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"encoding": "Character encoding to use. Defaults to UTF-8.",
"headers": "Headers to use for the web request.",
"timeout": "Timeout for connection to website.",
"verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed."
},
"description": "Provide additional advanced settings for the resource.",
"name": "Advanced settings"
},
"auth": {
"data": {
"authentication": "Select authentication method",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"authentication": "Type of the HTTP authentication. Either basic or digest."
},
"description": "Provide authentication details to access the resource.",
"name": "Authentication settings"
}
}
}
}
},
"config_subentries": {
"entity": {
"entry_type": "Sensor",
"initiate_flow": {
"user": "Add sensor"
},
"step": {
"user": {
"data": {
"index": "Index",
"select": "Select"
},
"data_description": {
"index": "Defines which of the elements returned by the CSS selector to use.",
"select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details."
},
"sections": {
"advanced": {
"data": {
"attribute": "Attribute",
"availability": "Availability template",
"device_class": "Device class",
"state_class": "State class",
"unit_of_measurement": "Unit of measurement",
"value_template": "Value template"
},
"data_description": {
"attribute": "Get value of an attribute on the selected tag.",
"availability": "Defines a template to get the availability of the sensor.",
"device_class": "The type/class of the sensor to set the icon in the frontend.",
"state_class": "The state_class of the sensor.",
"unit_of_measurement": "Choose unit of measurement or create your own.",
"value_template": "Defines a template to get the state of the sensor."
},
"description": "Provide additional advanced settings for the sensor.",
"name": "Advanced settings"
}
}
}
}
}
},
"options": {
"error": {
"no_data": "[%key:component::scrape::config::error::no_data%]",
"resource_error": "[%key:component::scrape::config::error::resource_error%]"
},
"step": {
"add_sensor": {
"data": {
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
"name": "[%key:common::config_flow::data::name%]",
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
},
"data_description": {
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
}
},
"edit_sensor": {
"data": {
"attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data::index%]",
"name": "[%key:common::config_flow::data::name%]",
"select": "[%key:component::scrape::config::step::sensor::data::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]"
},
"data_description": {
"attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]",
"availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]",
"device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]",
"index": "[%key:component::scrape::config::step::sensor::data_description::index%]",
"select": "[%key:component::scrape::config::step::sensor::data_description::select%]",
"state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]",
"unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]",
"value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]"
}
},
"init": {
"menu_options": {
"add_sensor": "Add sensor",
"remove_sensor": "Remove sensor",
"resource": "Configure resource",
"select_edit_sensor": "Configure sensor"
}
},
"resource": {
"data": {
"authentication": "[%key:component::scrape::config::step::user::data::authentication%]",
"encoding": "[%key:component::scrape::config::step::user::data::encoding%]",
"headers": "[%key:component::scrape::config::step::user::data::headers%]",
"method": "[%key:component::scrape::config::step::user::data::method%]",
"password": "[%key:common::config_flow::data::password%]",
"payload": "[%key:component::scrape::config::step::user::data::payload%]",
"resource": "[%key:component::scrape::config::step::user::data::resource%]",
"timeout": "[%key:component::scrape::config::step::user::data::timeout%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
"resource": "[%key:component::scrape::config::step::user::data::resource%]"
},
"data_description": {
"authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]",
"encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]",
"headers": "[%key:component::scrape::config::step::user::data_description::headers%]",
"payload": "[%key:component::scrape::config::step::user::data_description::payload%]",
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]",
"timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]",
"verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]"
"resource": "[%key:component::scrape::config::step::user::data_description::resource%]"
},
"sections": {
"advanced": {
"data": {
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data::encoding%]",
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data::headers%]",
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data::timeout%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"encoding": "[%key:component::scrape::config::step::user::sections::advanced::data_description::encoding%]",
"headers": "[%key:component::scrape::config::step::user::sections::advanced::data_description::headers%]",
"timeout": "[%key:component::scrape::config::step::user::sections::advanced::data_description::timeout%]",
"verify_ssl": "[%key:component::scrape::config::step::user::sections::advanced::data_description::verify_ssl%]"
},
"description": "[%key:component::scrape::config::step::user::sections::advanced::description%]",
"name": "[%key:component::scrape::config::step::user::sections::advanced::name%]"
},
"auth": {
"data": {
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data::authentication%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"authentication": "[%key:component::scrape::config::step::user::sections::auth::data_description::authentication%]"
},
"description": "[%key:component::scrape::config::step::user::sections::auth::description%]",
"name": "[%key:component::scrape::config::step::user::sections::auth::name%]"
}
}
}
}

View File

@@ -807,7 +807,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
)
ssid_options = [network["ssid"] for network in sorted_networks]
# Pre-select SSID if returning from failed provisioning attempt
# Preselect SSID if returning from failed provisioning attempt
suggested_values: dict[str, Any] = {}
if self.selected_ssid:
suggested_values[CONF_SSID] = self.selected_ssid
@@ -1086,7 +1086,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle failed provisioning - allow retry."""
if user_input is not None:
# User wants to retry - keep selected_ssid so it's pre-selected
# User wants to retry - keep selected_ssid so it's preselected
self.wifi_networks = []
return await self.async_step_wifi_scan()

View File

@@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
"requirements": ["SQLAlchemy==2.0.41", "sqlparse==0.5.5"]
"requirements": ["SQLAlchemy==2.0.49", "sqlparse==0.5.5"]
}

View File

@@ -25,7 +25,6 @@ from homeassistant.components.recorder.statistics import (
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -92,11 +91,40 @@ def _build_home_data(home: tibber.TibberHome) -> TibberHomeData:
return result
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Base Tibber coordinator."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: TibberConfigEntry,
*,
name: str,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=name,
update_interval=update_interval,
)
self._runtime_data = config_entry.runtime_data
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except (ClientError, TimeoutError, tibber.exceptions.HttpExceptionError) as err:
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
class TibberDataCoordinator(TibberCoordinator[None]):
"""Handle Tibber data and insert statistics."""
def __init__(
self,
hass: HomeAssistant,
@@ -106,17 +134,14 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Initialize the data handler."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
config_entry,
name=f"Tibber {tibber_connection.name}",
update_interval=timedelta(minutes=20),
)
async def _async_update_data(self) -> None:
"""Update data via API."""
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
tibber_connection = await self._async_get_client()
try:
await tibber_connection.fetch_consumption_data_active_homes()
@@ -132,9 +157,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
async def _insert_statistics(self) -> None:
"""Insert Tibber statistics."""
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
tibber_connection = await self._async_get_client()
for home in tibber_connection.get_homes():
sensors: list[tuple[str, bool, str | None, str]] = []
if home.hourly_consumption_data:
@@ -254,11 +277,9 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
async_add_external_statistics(self.hass, metadata, statistics)
class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
"""Handle Tibber price data and insert statistics."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -267,8 +288,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
"""Initialize the price coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
config_entry,
name=f"{DOMAIN} price",
update_interval=timedelta(minutes=1),
)
@@ -290,9 +310,7 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
async def _async_update_data(self) -> dict[str, TibberHomeData]:
"""Update data via API and return per-home data for sensors."""
tibber_connection = await self.config_entry.runtime_data.async_get_client(
self.hass
)
tibber_connection = await self._async_get_client()
active_homes = tibber_connection.get_homes(only_active=True)
now = dt_util.now()
@@ -347,11 +365,9 @@ class TibberPriceCoordinator(DataUpdateCoordinator[dict[str, TibberHomeData]]):
return result
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -360,12 +376,10 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
entry,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
@@ -383,15 +397,6 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> tibber.Tibber:
"""Get the Tibber client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()

View File

@@ -0,0 +1,17 @@
"""Provides conditions for updates."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_available": make_entity_state_condition(DOMAIN, STATE_ON),
"is_not_available": make_entity_state_condition(DOMAIN, STATE_OFF),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the update conditions."""
return CONDITIONS

View File

@@ -0,0 +1,17 @@
.condition_common: &condition_common
target:
entity:
domain: update
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_available: *condition_common
is_not_available: *condition_common

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_available": {
"condition": "mdi:package-up"
},
"is_not_available": {
"condition": "mdi:package"
}
},
"entity_component": {
"_": {
"default": "mdi:package-up",

View File

@@ -1,7 +1,28 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_available": {
"description": "Tests if one or more updates are available.",
"fields": {
"behavior": {
"name": "[%key:component::update::common::condition_behavior_name%]"
}
},
"name": "Update is available"
},
"is_not_available": {
"description": "Tests if one or more updates are not available.",
"fields": {
"behavior": {
"name": "[%key:component::update::common::condition_behavior_name%]"
}
},
"name": "Update is not available"
}
},
"device_automation": {
"extra_fields": {
"for": "[%key:common::device_automation::extra_fields::for%]"
@@ -59,6 +80,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -315,7 +315,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
return await self.async_step_verify_radio()
# Pre-select the currently configured port
# Preselect the currently configured port
default_port: vol.Undefined | str = vol.UNDEFINED
if self._radio_mgr.device_path is not None:
@@ -345,7 +345,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
)
return await self.async_step_manual_port_config()
# Pre-select the current radio type
# Preselect the current radio type
default: vol.Undefined | str = vol.UNDEFINED
if self._radio_mgr.radio_type is not None:

View File

@@ -3,6 +3,7 @@
from typing import Any
import voluptuous as vol
from zwave_js_server.const import CommandClass
from homeassistant.helpers import config_validation as cv
@@ -18,6 +19,10 @@ BITMASK_SCHEMA = vol.All(
lambda value: int(value, 16),
)
COMMAND_CLASS_SCHEMA = vol.All(
vol.Coerce(int), vol.In([cc.value for cc in CommandClass])
)
def boolean(value: Any) -> bool:
"""Validate and coerce a boolean value."""

View File

@@ -30,7 +30,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .config_validation import VALUE_SCHEMA
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .const import (
ATTR_COMMAND_CLASS,
ATTR_CONFIG_PARAMETER,
@@ -122,7 +122,7 @@ SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): SERVICE_SET_VALUE,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
@@ -334,7 +334,7 @@ async def async_get_action_capabilities(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In(
{
CommandClass(cc.id).value: cc.name
str(CommandClass(cc.id).value): cc.name
for cc in sorted(
node.command_classes, key=lambda cc: cc.name
)

View File

@@ -15,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .config_validation import VALUE_SCHEMA
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .const import (
ATTR_COMMAND_CLASS,
ATTR_ENDPOINT,
@@ -65,7 +65,7 @@ CONFIG_PARAMETER_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
VALUE_CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend(
{
vol.Required(CONF_TYPE): VALUE_TYPE,
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),
@@ -221,7 +221,7 @@ async def async_get_condition_capabilities(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In(
{
CommandClass(cc.id).value: cc.name
str(CommandClass(cc.id).value): cc.name
for cc in sorted(
node.command_classes, key=lambda cc: cc.name
)

View File

@@ -31,7 +31,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from .config_validation import VALUE_SCHEMA
from .config_validation import COMMAND_CLASS_SCHEMA, VALUE_SCHEMA
from .const import (
ATTR_COMMAND_CLASS,
ATTR_DATA_TYPE,
@@ -91,7 +91,7 @@ NOTIFICATION_EVENT_CC_MAPPINGS = (
# Event based trigger schemas
BASE_EVENT_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
}
)
@@ -162,7 +162,7 @@ NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend(
# zwave_js.value_updated based trigger schemas
BASE_VALUE_UPDATED_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]),
vol.Required(ATTR_COMMAND_CLASS): COMMAND_CLASS_SCHEMA,
vol.Required(ATTR_PROPERTY): vol.Any(int, str),
vol.Optional(ATTR_PROPERTY_KEY): vol.Any(None, vol.Coerce(int), str),
vol.Optional(ATTR_ENDPOINT, default=0): vol.Any(None, vol.Coerce(int)),
@@ -558,7 +558,7 @@ async def async_get_trigger_capabilities(
{
vol.Required(ATTR_COMMAND_CLASS): vol.In(
{
CommandClass(cc.id).value: cc.name
str(CommandClass(cc.id).value): cc.name
for cc in sorted(
node.command_classes, key=lambda cc: cc.name
)

View File

@@ -572,12 +572,12 @@ def get_value_state_schema(
return vol.Coerce(bool)
if value.configuration_value_type == ConfigurationValueType.ENUMERATED:
return vol.In({int(k): v for k, v in value.metadata.states.items()})
return vol.In({str(int(k)): v for k, v in value.metadata.states.items()})
return None
if value.metadata.states:
return vol.In({int(k): v for k, v in value.metadata.states.items()})
return vol.In({str(int(k)): v for k, v in value.metadata.states.items()})
return vol.All(
vol.Coerce(int),

View File

@@ -51,8 +51,8 @@ ATTR_TO = "to"
_OPTIONS_SCHEMA_DICT = {
vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_COMMAND_CLASS): vol.In(
{cc.value: cc.name for cc in CommandClass}
vol.Required(ATTR_COMMAND_CLASS): vol.All(
vol.Coerce(int), vol.In({cc.value: cc.name for cc in CommandClass})
),
vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string),
vol.Optional(ATTR_ENDPOINT): vol.Coerce(int),

View File

@@ -316,11 +316,11 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
class FlowType(StrEnum):
"""Flow type."""
"""Flow type supported in `next_flow` of ConfigFlowResult."""
CONFIG_FLOW = "config_flow"
# Add other flow types here as needed in the future,
# if we want to support them in the `next_flow` parameter.
OPTIONS_FLOW = "options_flow"
CONFIG_SUBENTRIES_FLOW = "config_subentries_flow"
def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None:
@@ -1608,6 +1608,26 @@ class ConfigEntriesFlowManager(
issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}"
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
def _async_validate_next_flow(
self,
result: ConfigFlowResult,
) -> None:
"""Validate `next_flow` in result if provided."""
if (next_flow := result.get("next_flow")) is None:
return
flow_type, flow_id = next_flow
if flow_type not in FlowType:
raise HomeAssistantError(f"Invalid flow type: {flow_type}")
if flow_type == FlowType.CONFIG_FLOW:
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.flow.async_get(flow_id)
if flow_type == FlowType.OPTIONS_FLOW:
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.options.async_get(flow_id)
if flow_type == FlowType.CONFIG_SUBENTRIES_FLOW:
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.subentries.async_get(flow_id)
async def async_finish_flow(
self,
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
@@ -1656,6 +1676,8 @@ class ConfigEntriesFlowManager(
self.config_entries.async_update_entry(
entry, discovery_keys=new_discovery_keys
)
self._async_validate_next_flow(result)
return result
# Mark the step as done.
@@ -1770,6 +1792,10 @@ class ConfigEntriesFlowManager(
self.config_entries._async_clean_up(existing_entry) # noqa: SLF001
result["result"] = entry
if not existing_entry:
result = await flow.async_on_create_entry(result)
self._async_validate_next_flow(result)
return result
async def async_create_flow(
@@ -3291,7 +3317,10 @@ class ConfigFlow(ConfigEntryBaseFlow):
return
flow_type, flow_id = next_flow
if flow_type != FlowType.CONFIG_FLOW:
raise HomeAssistantError("Invalid next_flow type")
raise HomeAssistantError(
"next_flow only supports FlowType.CONFIG_FLOW; "
"use async_on_create_entry for options or subentry flows"
)
# Raises UnknownFlow if the flow does not exist.
self.hass.config_entries.flow.async_get(flow_id)
result["next_flow"] = next_flow
@@ -3312,6 +3341,15 @@ class ConfigFlow(ConfigEntryBaseFlow):
self._async_set_next_flow_if_valid(result, next_flow)
return result
async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult:
"""Runs after a config flow has created a config entry.
Can be overridden by integrations to add additional data to the result.
Example: creating next flow entries to the result which needs a
config entry created before it can start.
"""
return result
@callback
def async_create_entry( # type: ignore[override]
self,

View File

@@ -544,8 +544,9 @@ class HomeAssistant:
) -> None:
"""Add a job to be executed by the event loop or by an executor.
If the job is either a coroutine or decorated with @callback, it will be
run by the event loop, if not it will be run by an executor.
If the job is a coroutine, coroutine function, or decorated with
@callback, it will be run by the event loop, if not it will be run
by an executor.
target: target to call.
args: parameters for method to call.
@@ -557,6 +558,14 @@ class HomeAssistant:
functools.partial(self.async_create_task, target, eager_start=True)
)
return
# For @callback targets, schedule directly via call_soon_threadsafe
# to avoid the extra deferral through _async_add_hass_job + call_soon.
# Check iscoroutinefunction to gracefully handle incorrectly labeled @callback functions.
if is_callback_check_partial(target) and not inspect.iscoroutinefunction(
target
):
self.loop.call_soon_threadsafe(target, *args)
return
self.loop.call_soon_threadsafe(
functools.partial(self._async_add_hass_job, HassJob(target), *args)
)
@@ -598,8 +607,9 @@ class HomeAssistant:
) -> asyncio.Future[_R] | None:
"""Add a job to be executed by the event loop or by an executor.
If the job is either a coroutine or decorated with @callback, it will be
run by the event loop, if not it will be run by an executor.
If the job is a coroutine, coroutine function, or decorated with
@callback, it will be run by the event loop, if not it will be run
by an executor.
This method must be run in the event loop.

View File

@@ -31,7 +31,6 @@ class EntityPlatforms(StrEnum):
IMAGE_PROCESSING = "image_processing"
INFRARED = "infrared"
LAWN_MOWER = "lawn_mower"
RADIO_FREQUENCY = "radio_frequency"
LIGHT = "light"
LOCK = "lock"
MEDIA_PLAYER = "media_player"

View File

@@ -349,6 +349,9 @@ class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
@override
@@ -392,6 +395,7 @@ class EntityTriggerBase(Trigger):
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
def check_one_match(self, entity_ids: set[str]) -> bool:
@@ -401,6 +405,7 @@ class EntityTriggerBase(Trigger):
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
== 1
)

View File

@@ -15,7 +15,7 @@ astral==2.2
async-interrupt==1.2.2
async-upnp-client==0.46.2
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
attrs==26.1.0
audioop-lts==0.2.1
av==16.0.1
awesomeversion==25.8.0
@@ -47,7 +47,7 @@ Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
openai==2.21.0
orjson==3.11.7
orjson==3.11.8
packaging>=23.1
paho-mqtt==2.1.0
Pillow==12.2.0
@@ -64,14 +64,14 @@ PyTurboJPEG==1.8.0
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
SQLAlchemy==2.0.41
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.1
voluptuous-openapi==0.2.0
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0

View File

@@ -36,7 +36,7 @@ dependencies = [
"annotatedyaml==1.0.2",
"astral==2.2",
"async-interrupt==1.2.2",
"attrs==25.4.0",
"attrs==26.1.0",
"atomicwrites-homeassistant==1.4.1",
"audioop-lts==0.2.1",
"awesomeversion==25.8.0",
@@ -61,14 +61,14 @@ dependencies = [
"Pillow==12.2.0",
"propcache==0.4.1",
"pyOpenSSL==26.0.0",
"orjson==3.11.7",
"orjson==3.11.8",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",
"PyYAML==6.0.3",
"requests==2.33.1",
"securetar==2026.4.1",
"SQLAlchemy==2.0.41",
"SQLAlchemy==2.0.49",
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",
"typing-extensions>=4.15.0,<5.0",
@@ -77,7 +77,7 @@ dependencies = [
"uv==0.11.1",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.2.0",
"voluptuous-openapi==0.3.0",
"yarl==1.23.0",
"webrtc-models==0.3.0",
"zeroconf==0.148.0",

8
requirements.txt generated
View File

@@ -14,7 +14,7 @@ annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
attrs==26.1.0
audioop-lts==0.2.1
awesomeversion==25.8.0
bcrypt==5.0.0
@@ -34,7 +34,7 @@ infrared-protocols==1.1.0
Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.11.7
orjson==3.11.8
packaging>=23.1
Pillow==12.2.0
propcache==0.4.1
@@ -48,14 +48,14 @@ PyTurboJPEG==1.8.0
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
SQLAlchemy==2.0.41
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.1
voluptuous-openapi==0.2.0
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
webrtc-models==0.3.0

5
requirements_all.txt generated
View File

@@ -115,7 +115,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.41
SQLAlchemy==2.0.49
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -2831,9 +2831,6 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1

View File

@@ -8,7 +8,7 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==4.0.4
coverage==7.10.6
coverage==7.13.5
freezegun==1.5.5
# librt is an internal mypy dependency
librt==0.8.1
@@ -22,18 +22,18 @@ pylint-per-file-ignores==3.2.1
pipdeptree==2.26.1
pytest-asyncio==1.3.0
pytest-aiohttp==1.1.0
pytest-cov==7.0.0
pytest-cov==7.1.0
pytest-freezer==0.4.9
pytest-github-actions-annotate-failures==0.3.0
pytest-github-actions-annotate-failures==0.4.0
pytest-socket==0.7.0
pytest-sugar==1.0.0
pytest-sugar==1.1.1
pytest-timeout==2.4.0
pytest-unordered==0.7.0
pytest-picked==0.5.1
pytest-xdist==3.8.0
pytest==9.0.3
requests-mock==1.12.1
respx==0.22.0
respx==0.23.1
syrupy==5.1.0
tqdm==4.67.1
types-aiofiles==24.1.0.20250822

View File

@@ -112,7 +112,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.41
SQLAlchemy==2.0.49
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -2409,9 +2409,6 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.radio_frequency
rf-protocols==0.0.1
# homeassistant.components.rflink
rflink==0.0.67

View File

@@ -1,6 +1,6 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.1
codespell==2.4.2
ruff==0.15.1
yamllint==1.38.0
zizmor==1.23.1

View File

@@ -34,25 +34,23 @@ ENV \
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
WORKDIR /usr/src
# Home Assistant S6-Overlay
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc} /usr/local/bin/go2rtc /bin/go2rtc
RUN \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv
&& pip3 install uv=={uv}
WORKDIR /usr/src
## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \
uv pip install \
# Verify go2rtc can be executed
go2rtc --version \
# Install uv at the version pinned in the requirements file
&& pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' homeassistant/requirements.txt)" \
&& uv pip install \
--no-build \
-r homeassistant/requirements.txt
@@ -143,12 +141,12 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \
# Uv creates a lock file in /tmp
--mount=type=tmpfs,target=/tmp \
# Uv creates a lock file in /tmp
RUN --mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
# Install uv at the version pinned in the requirements file
&& pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{{print $2}}' /usr/src/homeassistant/requirements.txt)" \
&& uv pip install \
--no-build \
--no-cache \
@@ -217,8 +215,7 @@ def _generate_files(config: Config) -> list[File]:
+ 10
) * 1000
package_versions = _get_package_versions(config.root / "requirements.txt", {"uv"})
package_versions |= _get_package_versions(
package_versions = _get_package_versions(
config.root / "requirements_test.txt", {"pipdeptree", "tqdm"}
)
package_versions |= _get_package_versions(

View File

@@ -13,12 +13,12 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
RUN --mount=from=ghcr.io/astral-sh/uv:0.11.1,source=/uv,target=/bin/uv \
# Uv creates a lock file in /tmp
--mount=type=tmpfs,target=/tmp \
# Uv creates a lock file in /tmp
RUN --mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
apk add --no-cache libturbojpeg \
# Install uv at the version pinned in the requirements file
&& pip install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' /usr/src/homeassistant/requirements.txt)" \
&& uv pip install \
--no-build \
--no-cache \

View File

@@ -252,6 +252,12 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
"coinbase": {"homeassistant": {"coinbase-advanced-py"}},
# https://github.com/u9n/dlms-cosem
"dsmr": {"dsmr-parser": {"dlms-cosem"}},
# https://github.com/tkdrob/pyefergy
# pyefergy declares codecov as a runtime dependency, which pulls in
# coverage; coverage ships an 'a1_coverage.pth' file starting from
# 7.13.x. Upstream fix pending in
# https://github.com/tkdrob/pyefergy/pull/47
"efergy": {"codecov": {"coverage"}},
# https://github.com/ChrisMandich/PyFlume # Fixed with >=0.7.1
"fitbit": {
# Setuptools - distutils-precedence.pth

View File

@@ -41,7 +41,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1)',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'supported_features': <MediaPlayerEntityFeature: 200588>,
'volume_level': 0.0,
}),
@@ -95,7 +94,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Arcam FMJ (127.0.0.1) Zone 2',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'supported_features': <MediaPlayerEntityFeature: 135052>,
'volume_level': 0.0,
}),

View File

@@ -63,7 +63,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': 'playing',
'media_content_type': 'music',
'repeat': 'off',
'shuffle': False,
@@ -186,7 +185,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'media_player.beosound_a5_44444444',
]),
'last_non_buffering_state': 'playing',
'media_content_type': 'music',
'repeat': 'off',
'shuffle': False,

View File

@@ -23,7 +23,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -72,7 +71,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -122,7 +120,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -172,7 +169,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -222,7 +218,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -272,7 +267,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -321,7 +315,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -370,7 +363,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -419,7 +411,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -468,7 +459,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -517,7 +507,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -566,7 +555,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -615,7 +603,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -665,7 +652,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -715,7 +701,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -765,7 +750,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -815,7 +799,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'media_position': 0,
'repeat': <RepeatMode.OFF: 'off'>,
@@ -866,7 +849,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -916,7 +898,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <BeoMediaType.TIDAL: 'tidal'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -966,7 +947,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -1016,7 +996,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -1063,7 +1042,6 @@
'media_player.beoconnect_core_22222222',
'media_player.beosound_balance_11111111',
]),
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
@@ -1112,7 +1090,6 @@
'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com',
'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com',
]),
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,

View File

@@ -5,7 +5,6 @@
'friendly_name': 'player-name1111',
'group_members': None,
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'master': False,
'media_album_name': 'album',
'media_artist': 'artist',

View File

@@ -54,6 +54,8 @@ async def target_climates(hass: HomeAssistant) -> dict[str, list[str]]:
"climate.target_temperature_crossed_threshold",
"climate.turned_off",
"climate.turned_on",
"climate.started_cooling",
"climate.started_drying",
"climate.started_heating",
],
)

View File

@@ -46,7 +46,6 @@
'device_class': 'tv',
'friendly_name': 'Living Room',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'source_list': list([
'TV',
]),

View File

@@ -1,211 +0,0 @@
"""Test ESPHome radio frequency platform."""
from aioesphomeapi import (
APIClient,
APIConnectionError,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
import pytest
from rf_protocols import ModulationType, OOKCommand, Timing
from homeassistant.components import radio_frequency
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .conftest import MockESPHomeDevice, MockESPHomeDeviceType
ENTITY_ID = "radio_frequency.test_rf"
async def _mock_rf_device(
mock_esphome_device: MockESPHomeDeviceType,
mock_client: APIClient,
capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER,
frequency_min: int = 433_000_000,
frequency_max: int = 434_000_000,
supported_modulations: int = 1,
) -> MockESPHomeDevice:
entity_info = [
RadioFrequencyInfo(
object_id="rf",
key=1,
name="RF",
capabilities=capabilities,
frequency_min=frequency_min,
frequency_max=frequency_max,
supported_modulations=supported_modulations,
)
]
return await mock_esphome_device(
mock_client=mock_client, entity_info=entity_info, states=[]
)
@pytest.mark.parametrize(
("capabilities", "entity_created"),
[
(RadioFrequencyCapability.TRANSMITTER, True),
(RadioFrequencyCapability.RECEIVER, False),
(
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER,
True,
),
(RadioFrequencyCapability(0), False),
],
)
async def test_radio_frequency_entity_transmitter(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
capabilities: RadioFrequencyCapability,
entity_created: bool,
) -> None:
"""Test radio frequency entity with transmitter capability is created."""
await _mock_rf_device(mock_esphome_device, mock_client, capabilities)
state = hass.states.get(ENTITY_ID)
assert (state is not None) == entity_created
async def test_radio_frequency_multiple_entities_mixed_capabilities(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test multiple radio frequency entities with mixed capabilities."""
entity_info = [
RadioFrequencyInfo(
object_id="rf_transmitter",
key=1,
name="RF Transmitter",
capabilities=RadioFrequencyCapability.TRANSMITTER,
),
RadioFrequencyInfo(
object_id="rf_receiver",
key=2,
name="RF Receiver",
capabilities=RadioFrequencyCapability.RECEIVER,
),
RadioFrequencyInfo(
object_id="rf_transceiver",
key=3,
name="RF Transceiver",
capabilities=(
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER
),
),
]
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
states=[],
)
# Only transmitter and transceiver should be created
assert hass.states.get("radio_frequency.test_rf_transmitter") is not None
assert hass.states.get("radio_frequency.test_rf_receiver") is None
assert hass.states.get("radio_frequency.test_rf_transceiver") is not None
async def test_radio_frequency_send_command_success(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending RF command successfully."""
await _mock_rf_device(mock_esphome_device, mock_client)
command = OOKCommand(
frequency=433_920_000,
timings=[
Timing(high_us=350, low_us=1050),
Timing(high_us=350, low_us=350),
],
)
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
mock_client.radio_frequency_transmit_raw_timings.assert_called_once()
call_args = mock_client.radio_frequency_transmit_raw_timings.call_args
assert call_args[0][0] == 1 # key
assert call_args[1]["frequency"] == 433_920_000
assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK
assert call_args[1]["repeat_count"] == 1
assert call_args[1]["device_id"] == 0
assert call_args[1]["timings"] == [350, -1050, 350, -350]
async def test_radio_frequency_send_command_failure(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test sending RF command with APIConnectionError raises HomeAssistantError."""
await _mock_rf_device(mock_esphome_device, mock_client)
mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError(
"Connection lost"
)
command = OOKCommand(
frequency=433_920_000,
timings=[Timing(high_us=350, low_us=1050)],
)
with pytest.raises(HomeAssistantError) as exc_info:
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
assert exc_info.value.translation_domain == "esphome"
assert exc_info.value.translation_key == "error_communicating_with_device"
async def test_radio_frequency_entity_availability(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test radio frequency entity becomes available after device reconnects."""
mock_device = await _mock_rf_device(mock_esphome_device, mock_client)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
await mock_device.mock_disconnect(False)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
await mock_device.mock_connect()
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_radio_frequency_supported_frequency_ranges(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
) -> None:
"""Test supported frequency ranges are exposed from device info."""
await _mock_rf_device(
mock_esphome_device,
mock_client,
frequency_min=433_000_000,
frequency_max=434_000_000,
)
transmitters = radio_frequency.async_get_transmitters(
hass, 433_920_000, ModulationType.OOK
)
assert len(transmitters) == 1
transmitters = radio_frequency.async_get_transmitters(
hass, 868_000_000, ModulationType.OOK
)
assert len(transmitters) == 0

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -22,17 +22,7 @@ from tests.components.common import (
@pytest.fixture
async def target_fans(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple fan entities associated with different targets."""
return await target_entities(hass, "fan")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only fan entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
return await target_entities(hass, "fan", domain_excluded="switch")
@pytest.mark.parametrize(
@@ -61,18 +51,19 @@ async def test_fan_conditions_gated_by_labs_flag(
condition="fan.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="fan.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_fan_state_condition_behavior_any(
hass: HomeAssistant,
target_fans: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -81,39 +72,17 @@ async def test_fan_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the fan state condition with the 'any' behavior."""
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_fans,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other fans also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -127,11 +96,13 @@ async def test_fan_state_condition_behavior_any(
condition="fan.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="fan.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -146,33 +117,13 @@ async def test_fan_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the fan state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_fans["included_entities"]) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_fans,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -489,7 +489,7 @@ LIGHT_ATTRS = [
]
LOCK_ATTRS = [{"supported_features": 1}, {}]
NOTIFY_ATTRS = [{"supported_features": 0}, {}]
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {"last_non_buffering_state": "on"}]
MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}]
SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}]
VALVE_ATTRS = [{"supported_features": 0}, {"is_closed": False}]

View File

@@ -314,7 +314,6 @@
'media_player.test_player_2',
]),
'is_volume_muted': False,
'last_non_buffering_state': 'idle',
'media_album_id': '1',
'media_album_name': 'Album',
'media_artist': 'Artist',

View File

@@ -208,7 +208,6 @@
'media_player.test_player_2',
]),
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_album_id': '1',
'media_album_name': 'Album',
'media_artist': 'Artist',

View File

@@ -18319,7 +18319,6 @@
'attributes': dict({
'device_class': 'tv',
'friendly_name': 'LG webOS TV AF80',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'source': 'HDMI 4',
'source_list': list([
'AirPlay',

View File

@@ -43,7 +43,6 @@
'assumed_state': True,
'device_class': 'tv',
'friendly_name': 'LG TV',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'supported_features': <MediaPlayerEntityFeature: 21945>,
}),
'context': <ANY>,

View File

@@ -13,11 +13,9 @@ from tests.components.common import (
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -135,17 +133,7 @@ def parametrize_brightness_condition_states_all(
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple light entities associated with different targets."""
return await target_entities(hass, "light")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only light entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
return await target_entities(hass, "light", domain_excluded="switch")
@pytest.mark.parametrize(
@@ -175,18 +163,19 @@ async def test_light_conditions_gated_by_labs_flag(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_light_state_condition_behavior_any(
hass: HomeAssistant,
target_lights: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -195,39 +184,17 @@ async def test_light_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'any' behavior."""
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -241,11 +208,13 @@ async def test_light_state_condition_behavior_any(
condition="light.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="light.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -260,37 +229,17 @@ async def test_light_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the light state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_lights["included_entities"]) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_lights,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(

View File

@@ -12,12 +12,10 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_SEARCH_QUERY,
DOMAIN,
BrowseMedia,
MediaClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerState,
SearchMedia,
SearchMediaQuery,
)
@@ -26,11 +24,11 @@ from homeassistant.components.media_player.const import (
SERVICE_SEARCH_MEDIA,
)
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_OFF
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockEntityPlatform, setup_test_component_platform
from tests.common import MockEntityPlatform
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator, WebSocketGenerator
@@ -637,62 +635,3 @@ async def test_play_media_via_selector(hass: HomeAssistant) -> None:
},
blocking=True,
)
async def test_media_player_state(hass: HomeAssistant) -> None:
"""Test that media player state includes last_non_buffering_state."""
entity1 = MediaPlayerEntity()
entity1._attr_name = "test1"
setup_test_component_platform(hass, DOMAIN, [entity1])
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
state = hass.states.get("media_player.test1")
assert state.state == "unknown"
assert state.attributes == {
"friendly_name": "test1",
"last_non_buffering_state": None,
"supported_features": 0,
}
entity1._attr_state = MediaPlayerState.PLAYING
entity1.async_write_ha_state()
state = hass.states.get("media_player.test1")
assert state.state == "playing"
assert state.attributes == {
"friendly_name": "test1",
"last_non_buffering_state": "playing",
"supported_features": 0,
}
# last_non_buffering_state not updated when state is buffering
entity1._attr_state = MediaPlayerState.BUFFERING
entity1.async_write_ha_state()
state = hass.states.get("media_player.test1")
assert state.state == "buffering"
assert state.attributes == {
"friendly_name": "test1",
"last_non_buffering_state": "playing",
"supported_features": 0,
}
entity1._attr_state = MediaPlayerState.PAUSED
entity1.async_write_ha_state()
state = hass.states.get("media_player.test1")
assert state.state == "paused"
assert state.attributes == {
"friendly_name": "test1",
"last_non_buffering_state": "paused",
"supported_features": 0,
}
# last_non_buffering_state not present when unavailable
entity1._attr_available = False
entity1.async_write_ha_state()
state = hass.states.get("media_player.test1")
assert state.state == "unavailable"
assert state.attributes == {
"friendly_name": "test1",
"supported_features": 0,
}

View File

@@ -28,7 +28,11 @@ async def target_media_players(hass: HomeAssistant) -> dict[str, list[str]]:
@pytest.mark.parametrize(
"trigger_key",
[
"media_player.paused_playing",
"media_player.started_playing",
"media_player.stopped_playing",
"media_player.turned_off",
"media_player.turned_on",
],
)
async def test_media_player_triggers_gated_by_labs_flag(
@@ -46,6 +50,29 @@ async def test_media_player_triggers_gated_by_labs_flag(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="media_player.paused_playing",
target_states=[
MediaPlayerState.PAUSED,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.started_playing",
target_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.PLAYING,
],
other_states=[
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
],
),
*parametrize_trigger_states(
trigger="media_player.stopped_playing",
target_states=[
@@ -59,6 +86,32 @@ async def test_media_player_triggers_gated_by_labs_flag(
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.turned_off",
target_states=[
MediaPlayerState.OFF,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.turned_on",
target_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
other_states=[
MediaPlayerState.OFF,
],
),
],
)
async def test_media_player_state_trigger_behavior_any(

View File

@@ -2830,7 +2830,7 @@ async def test_clean_up_registry_monitoring(
}
# Publish it config
# Since it is not enabled_by_default the sensor will not be loaded
# it should register a hook for monitoring the entiry registry
# it should register a hook for monitoring the entity registry
async_fire_mqtt_message(
hass,
"homeassistant/sensor/sbfspot_0/sbfspot_12345/config",

View File

@@ -49,7 +49,6 @@
]),
'icon': 'mdi:speaker',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'mass_player_type': 'player',
'media_album_name': 'Test Album',
'media_artist': 'Test Artist',
@@ -122,7 +121,6 @@
]),
'icon': 'mdi:speaker-multiple',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'mass_player_type': 'group',
'media_album_name': 'Use Your Illusion I',
'media_artist': "Guns N' Roses",
@@ -200,7 +198,6 @@
'group_members': list([
]),
'icon': 'mdi:speaker',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'mass_player_type': 'player',
'sound_mode_list': list([
'munich_translation',

View File

@@ -53,7 +53,6 @@
}),
'friendly_name': 'TX-NR7100',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'preset': 1,
'sound_mode': 'DIRECT',
'sound_mode_list': list([
@@ -128,7 +127,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TX-NR7100 Zone 2',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'sound_mode': 'Stereo',
'sound_mode_list': list([
'Stereo',
@@ -195,7 +193,6 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TX-NR7100 Zone 3',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'source_list': list([
'TV',
'FM Radio',

View File

@@ -42,7 +42,6 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation Vita',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -98,7 +97,6 @@
'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG',
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d',
'friendly_name': 'PlayStation Vita',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_id': 'PCSB00074_00',
'media_content_type': <MediaType.GAME: 'game'>,
'media_title': "Assassin's Creed® III Liberation",
@@ -156,7 +154,6 @@
'device_class': 'receiver',
'entity_picture_local': None,
'friendly_name': 'PlayStation Vita',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'media_content_type': <MediaType.GAME: 'game'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
@@ -212,7 +209,6 @@
'device_class': 'receiver',
'entity_picture_local': None,
'friendly_name': 'PlayStation 4',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'media_content_type': <MediaType.GAME: 'game'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
@@ -267,7 +263,6 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 4',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -323,7 +318,6 @@
'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png',
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102',
'friendly_name': 'PlayStation 4',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_id': 'CUSA23081_00',
'media_content_type': <MediaType.GAME: 'game'>,
'media_title': 'Untitled Goose Game',
@@ -381,7 +375,6 @@
'device_class': 'receiver',
'entity_picture_local': None,
'friendly_name': 'PlayStation 5',
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'media_content_type': <MediaType.GAME: 'game'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
@@ -436,7 +429,6 @@
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 5',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
@@ -492,7 +484,6 @@
'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png',
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b',
'friendly_name': 'PlayStation 5',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_content_id': 'PPSA07784_00',
'media_content_type': <MediaType.GAME: 'game'>,
'media_title': 'STAR WARS Jedi: Survivor™',

View File

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

View File

@@ -1,71 +0,0 @@
"""Common fixtures for the Radio Frequency tests."""
from typing import override
import pytest
from rf_protocols import ModulationType, RadioFrequencyCommand, Timing
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.components.radio_frequency.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
async def init_integration(hass: HomeAssistant) -> None:
"""Set up the Radio Frequency integration for testing."""
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
class MockRadioFrequencyCommand(RadioFrequencyCommand):
"""Mock RF command for testing."""
def __init__(
self,
*,
frequency: int = 433_920_000,
modulation: ModulationType = ModulationType.OOK,
repeat_count: int = 0,
) -> None:
"""Initialize mock command."""
super().__init__(
frequency=frequency, modulation=modulation, repeat_count=repeat_count
)
@override
def get_raw_timings(self) -> list[Timing]:
"""Return mock timings."""
return [Timing(high_us=350, low_us=1050), Timing(high_us=350, low_us=350)]
class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):
"""Mock radio frequency entity for testing."""
_attr_has_entity_name = True
_attr_name = "Test RF transmitter"
def __init__(
self,
unique_id: str,
frequency_ranges: list[tuple[int, int]] | None = None,
) -> None:
"""Initialize mock entity."""
self._attr_unique_id = unique_id
self._frequency_ranges = frequency_ranges or [(433_000_000, 434_000_000)]
self.send_command_calls: list[RadioFrequencyCommand] = []
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges."""
return self._frequency_ranges
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Mock send command."""
self.send_command_calls.append(command)
@pytest.fixture
def mock_rf_entity() -> MockRadioFrequencyEntity:
"""Return a mock radio frequency entity."""
return MockRadioFrequencyEntity("test_rf_transmitter")

View File

@@ -1,171 +0,0 @@
"""Tests for the Radio Frequency integration setup."""
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from rf_protocols import ModulationType
from homeassistant.components.radio_frequency import (
DATA_COMPONENT,
DOMAIN,
async_get_transmitters,
async_send_command,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import MockRadioFrequencyCommand, MockRadioFrequencyEntity
from tests.common import mock_restore_cache
async def test_get_transmitters_component_not_loaded(hass: HomeAssistant) -> None:
"""Test getting transmitters raises when the component is not loaded."""
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
@pytest.mark.usefixtures("init_integration")
async def test_get_transmitters_no_entities(hass: HomeAssistant) -> None:
"""Test getting transmitters raises when none are registered."""
with pytest.raises(
HomeAssistantError,
match="No Radio Frequency transmitters available",
):
async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
@pytest.mark.usefixtures("init_integration")
async def test_get_transmitters_with_frequency_ranges(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test transmitter with frequency ranges filters correctly."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
# 433.92 MHz is within 433-434 MHz range
result = async_get_transmitters(hass, 433_920_000, ModulationType.OOK)
assert result == [mock_rf_entity.entity_id]
# 868 MHz is outside the range
result = async_get_transmitters(hass, 868_000_000, ModulationType.OOK)
assert result == []
@pytest.mark.usefixtures("init_integration")
async def test_rf_entity_initial_state(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test radio frequency entity has no state before any command is sent."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_success(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sending command via async_send_command helper."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
now = dt_util.utcnow()
freezer.move_to(now)
command = MockRadioFrequencyCommand(frequency=433_920_000)
await async_send_command(hass, mock_rf_entity.entity_id, command)
assert len(mock_rf_entity.send_command_calls) == 1
assert mock_rf_entity.send_command_calls[0] is command
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == now.isoformat(timespec="milliseconds")
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_error_does_not_update_state(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
) -> None:
"""Test that state is not updated when async_send_command raises an error."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
command = MockRadioFrequencyCommand(frequency=433_920_000)
mock_rf_entity.async_send_command = AsyncMock(
side_effect=HomeAssistantError("Transmission failed")
)
with pytest.raises(HomeAssistantError, match="Transmission failed"):
await async_send_command(hass, mock_rf_entity.entity_id, command)
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when entity not found."""
command = MockRadioFrequencyCommand(frequency=433_920_000)
with pytest.raises(
HomeAssistantError,
match="Radio Frequency entity `radio_frequency.nonexistent_entity` not found",
):
await async_send_command(hass, "radio_frequency.nonexistent_entity", command)
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when component not loaded."""
command = MockRadioFrequencyCommand(frequency=433_920_000)
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
await async_send_command(hass, "radio_frequency.some_entity", command)
@pytest.mark.parametrize(
("restored_value", "expected_state"),
[
("2026-01-01T12:00:00.000+00:00", "2026-01-01T12:00:00.000+00:00"),
(STATE_UNAVAILABLE, STATE_UNKNOWN),
],
)
async def test_rf_entity_state_restore(
hass: HomeAssistant,
mock_rf_entity: MockRadioFrequencyEntity,
restored_value: str,
expected_state: str,
) -> None:
"""Test radio frequency entity state restore."""
mock_restore_cache(
hass, [State("radio_frequency.test_rf_transmitter", restored_value)]
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_rf_entity])
state = hass.states.get("radio_frequency.test_rf_transmitter")
assert state is not None
assert state.state == expected_state

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import uuid
import pytest
@@ -26,10 +25,8 @@ from homeassistant.components.scrape.const import (
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
CONF_METHOD,
CONF_NAME,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
@@ -49,9 +46,9 @@ def mock_setup_entry() -> Generator[AsyncMock]:
yield mock_setup_entry
@pytest.fixture(name="get_config")
async def get_config_to_integration_load() -> dict[str, Any]:
"""Return default minimal configuration.
@pytest.fixture(name="get_resource_config")
async def get_resource_config_to_integration_load() -> dict[str, Any]:
"""Return default minimal configuration for resource.
To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
@@ -59,20 +56,33 @@ async def get_config_to_integration_load() -> dict[str, Any]:
return {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
"auth": {},
"advanced": {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
},
}
@pytest.fixture(name="get_sensor_config")
async def get_sensor_config_to_integration_load() -> tuple[dict[str, Any], ...]:
"""Return default minimal configuration for sensor.
To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
"""
return (
{
"data": {"advanced": {}, CONF_INDEX: 0, CONF_SELECT: ".current-version h1"},
"subentry_id": "01JZN07D8D23994A49YKS649S7",
"subentry_type": "entity",
"title": "Current version",
"unique_id": None,
},
)
@pytest.fixture(name="get_data")
async def get_data_to_integration_load() -> MockRestData:
"""Return RestData.
@@ -85,14 +95,19 @@ async def get_data_to_integration_load() -> MockRestData:
@pytest.fixture(name="loaded_entry")
async def load_integration(
hass: HomeAssistant, get_config: dict[str, Any], get_data: MockRestData
hass: HomeAssistant,
get_resource_config: dict[str, Any],
get_sensor_config: tuple[dict[str, Any], ...],
get_data: MockRestData,
) -> MockConfigEntry:
"""Set up the Scrape integration in Home Assistant."""
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options=get_config,
entry_id="1",
options=get_resource_config,
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
subentries_data=get_sensor_config,
version=2,
)
config_entry.add_to_hass(hass)
@@ -105,13 +120,3 @@ async def load_integration(
await hass.async_block_till_done()
return config_entry
@pytest.fixture(autouse=True)
def uuid_fixture() -> str:
"""Automatically path uuid generator."""
with patch(
"homeassistant.components.scrape.config_flow.uuid.uuid1",
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120002"),
):
yield

View File

@@ -0,0 +1,153 @@
# serializer version: 1
# name: test_migrate_from_version_1_to_2[device_registry]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'scrape',
'01JZQ1G63X2DX66GZ9ZTFY9PEH',
),
}),
'labels': set({
}),
'manufacturer': 'Scrape',
'model': None,
'model_id': None,
'name': 'Current version',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_migrate_from_version_1_to_2[entity_registry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.current_version',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'scrape',
'previous_unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
'unit_of_measurement': None,
})
# ---
# name: test_migrate_from_version_1_to_2[post_migration_config_entry]
ConfigEntrySnapshot({
'data': dict({
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'scrape',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
'advanced': dict({
'encoding': 'UTF-8',
'timeout': 10.0,
'verify_ssl': True,
}),
'auth': dict({
'password': 'pass',
'username': 'user',
}),
'method': 'GET',
'resource': 'http://www.home-assistant.io',
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
dict({
'data': dict({
'advanced': dict({
'value_template': '{{ value }}',
}),
'index': 0,
'select': '.release-date',
}),
'subentry_id': '01JZQ1G63X2DX66GZ9ZTFY9PEH',
'subentry_type': 'entity',
'title': 'Current version',
'unique_id': None,
}),
]),
'title': 'Mock Title',
'unique_id': None,
'version': 2,
})
# ---
# name: test_migrate_from_version_1_to_2[pre_migration_config_entry]
ConfigEntrySnapshot({
'data': dict({
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'scrape',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
'encoding': 'UTF-8',
'method': 'GET',
'password': 'pass',
'resource': 'http://www.home-assistant.io',
'sensor': list([
dict({
'index': 0,
'name': 'Current version',
'select': '.release-date',
'unique_id': 'a0bde946-5c96-11f0-b55f-0242ac110002',
'value_template': '{{ value }}',
}),
]),
'timeout': 10.0,
'username': 'user',
'verify_ssl': True,
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Mock Title',
'unique_id': None,
'version': 1,
})
# ---

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import uuid
from homeassistant import config_entries
from homeassistant.components.rest.data import ( # pylint: disable=hass-component-root-import
@@ -14,25 +13,21 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo
)
from homeassistant.components.scrape import DOMAIN
from homeassistant.components.scrape.const import (
CONF_ADVANCED,
CONF_AUTH,
CONF_ENCODING,
CONF_INDEX,
CONF_SELECT,
DEFAULT_ENCODING,
DEFAULT_VERIFY_SSL,
)
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_METHOD,
CONF_NAME,
CONF_PASSWORD,
CONF_PAYLOAD,
CONF_RESOURCE,
CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
@@ -44,7 +39,7 @@ from . import MockRestData
from tests.common import MockConfigEntry
async def test_form(
async def test_entry_and_subentry(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form."""
@@ -59,47 +54,55 @@ async def test_form(
"homeassistant.components.rest.RestData",
return_value=get_data,
) as mock_data:
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
},
)
await hass.async_block_till_done()
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["version"] == 1
assert result3["options"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["version"] == 2
assert result["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
}
assert len(mock_data.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
entry_id = result["result"].entry_id
result = await hass.config_entries.subentries.async_init(
(entry_id, "entity"), context={"source": config_entries.SOURCE_USER}
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_INDEX: 0, CONF_SELECT: ".current-version h1", CONF_ADVANCED: {}},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_INDEX: 0,
CONF_SELECT: ".current-version h1",
CONF_ADVANCED: {},
}
async def test_form_with_post(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
@@ -116,44 +119,32 @@ async def test_form_with_post(
"homeassistant.components.rest.RestData",
return_value=get_data,
) as mock_data:
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_PAYLOAD: "POST",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
},
)
await hass.async_block_till_done()
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["version"] == 1
assert result3["options"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["version"] == 2
assert result["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_PAYLOAD: "POST",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
}
assert len(mock_data.mock_calls) == 1
@@ -176,74 +167,68 @@ async def test_flow_fails(
"homeassistant.components.rest.RestData",
side_effect=HomeAssistantError,
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
},
)
assert result2["errors"] == {"base": "resource_error"}
assert result["errors"] == {"base": "resource_error"}
with patch(
"homeassistant.components.rest.RestData",
return_value=MockRestData("test_scrape_sensor_no_data"),
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
},
)
assert result2["errors"] == {"base": "resource_error"}
assert result["errors"] == {"base": "no_data"}
with patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
):
result3 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
},
},
)
await hass.async_block_till_done()
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "https://www.home-assistant.io"
assert result4["options"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "https://www.home-assistant.io"
assert result["options"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
CONF_AUTH: {},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
}
@@ -257,16 +242,8 @@ async def test_options_resource_flow(
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "resource"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "resource"
assert result["step_id"] == "init"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
@@ -275,11 +252,15 @@ async def test_options_resource_flow(
user_input={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
CONF_AUTH: {
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
},
CONF_ADVANCED: {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
},
},
)
await hass.async_block_till_done()
@@ -288,19 +269,15 @@ async def test_options_resource_flow(
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
CONF_AUTH: {
CONF_USERNAME: "secret_username",
CONF_PASSWORD: "secret_password",
},
CONF_ADVANCED: {
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10.0,
CONF_ENCODING: "UTF-8",
},
}
await hass.async_block_till_done()
@@ -311,351 +288,3 @@ async def test_options_resource_flow(
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Hidden Version: 2021.12.10"
async def test_options_add_remove_sensor_flow(
hass: HomeAssistant, loaded_entry: MockConfigEntry
) -> None:
"""Test options flow to add and remove a sensor."""
state = hass.states.get("sensor.current_version")
assert state.state == "Current Version: 2021.12.10"
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "add_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "add_sensor"
mocker = MockRestData("test_scrape_sensor2")
with (
patch("homeassistant.components.rest.RestData", return_value=mocker),
patch(
"homeassistant.components.scrape.config_flow.uuid.uuid1",
return_value=uuid.UUID("3699ef88-69e6-11ed-a1eb-0242ac120003"),
),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
{
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
},
],
}
await hass.async_block_till_done()
# Check the entity was updated, with the new entity
assert len(hass.states.async_all()) == 2
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Hidden Version: 2021.12.10"
state = hass.states.get("sensor.template")
assert state.state == "Trying to get"
# Now remove the original sensor
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "remove_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "remove_sensor"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_INDEX: ["0"],
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Template",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120003",
},
],
}
await hass.async_block_till_done()
# Check the original entity was removed, with only the new entity left
assert len(hass.states.async_all()) == 1
# Check the state of the new entity
state = hass.states.get("sensor.template")
assert state.state == "Trying to get"
async def test_options_edit_sensor_flow(
hass: HomeAssistant, loaded_entry: MockConfigEntry
) -> None:
"""Test options flow to edit a sensor."""
state = hass.states.get("sensor.current_version")
assert state.state == "Current Version: 2021.12.10"
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
mocker = MockRestData("test_scrape_sensor2")
with patch("homeassistant.components.rest.RestData", return_value=mocker):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: "template",
CONF_INDEX: 0.0,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current version",
CONF_SELECT: "template",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}
await hass.async_block_till_done()
# Check the entity was updated
assert len(hass.states.async_all()) == 1
# Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version")
assert state.state == "Trying to get"
async def test_sensor_options_add_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}
async def test_sensor_options_remove_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: True,
CONF_TIMEOUT: 10,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}

View File

@@ -2,14 +2,18 @@
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@@ -193,3 +197,137 @@ async def test_resource_template(
await hass.async_block_till_done(wait_background_tasks=True)
state = hass.states.get("sensor.template_sensor")
assert state.state == "Second"
async def test_migrate_from_future(
hass: HomeAssistant,
get_resource_config: dict[str, Any],
get_sensor_config: tuple[dict[str, Any], ...],
get_data: MockRestData,
) -> None:
"""Test migration from future version fails."""
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options=get_resource_config,
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
subentries_data=get_sensor_config,
version=3,
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_migrate_from_version_1_to_2(
hass: HomeAssistant,
get_data: MockRestData,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test migration from version 1.1 to 2.1 with config subentries."""
@dataclass(frozen=True, kw_only=True)
class MockConfigSubentry(ConfigSubentry):
"""Container for a configuration subentry."""
subentry_id: str = "01JZQ1G63X2DX66GZ9ZTFY9PEH"
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options={
"encoding": "UTF-8",
"method": "GET",
"resource": "http://www.home-assistant.io",
"username": "user",
"password": "pass",
"sensor": [
{
"index": 0,
"name": "Current version",
"select": ".release-date",
"unique_id": "a0bde946-5c96-11f0-b55f-0242ac110002",
"value_template": "{{ value }}",
}
],
"timeout": 10.0,
"verify_ssl": True,
},
entry_id="01JZN04ZJ9BQXXGXDS05WS7D6P",
version=1,
)
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN, "a0bde946-5c96-11f0-b55f-0242ac110002")},
manufacturer="Scrape",
name="Current version",
)
entity_registry.async_get_or_create(
SENSOR_DOMAIN,
DOMAIN,
"a0bde946-5c96-11f0-b55f-0242ac110002",
config_entry=config_entry,
device_id=device.id,
original_name="Current version",
has_entity_name=True,
suggested_object_id="current_version",
)
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
name="pre_migration_config_entry"
)
with (
patch(
"homeassistant.components.rest.RestData",
return_value=get_data,
),
patch("homeassistant.components.scrape.ConfigSubentry", MockConfigSubentry),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
assert config_entry.state is ConfigEntryState.LOADED
assert hass.config_entries.async_get_entry(config_entry.entry_id) == snapshot(
name="post_migration_config_entry"
)
device = device_registry.async_get(device.id)
assert device == snapshot(name="device_registry")
entity = entity_registry.async_get("sensor.current_version")
assert entity == snapshot(name="entity_registry")
assert config_entry.subentries == {
"01JZQ1G63X2DX66GZ9ZTFY9PEH": MockConfigSubentry(
data={
"advanced": {"value_template": "{{ value }}"},
"index": 0,
"select": ".release-date",
},
subentry_id="01JZQ1G63X2DX66GZ9ZTFY9PEH",
subentry_type="entity",
title="Current version",
unique_id=None,
),
}
assert device.config_entries == {"01JZN04ZJ9BQXXGXDS05WS7D6P"}
assert device.config_entries_subentries == {
"01JZN04ZJ9BQXXGXDS05WS7D6P": {
"01JZQ1G63X2DX66GZ9ZTFY9PEH",
},
}
assert entity.config_entry_id == config_entry.entry_id
assert entity.config_subentry_id == "01JZQ1G63X2DX66GZ9ZTFY9PEH"
state = hass.states.get("sensor.current_version")
assert state.state == "January 17, 2022"

View File

@@ -18,7 +18,6 @@ from homeassistant.components.scrape.const import (
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
@@ -604,7 +603,7 @@ async def test_setup_config_entry(
entity = entity_registry.async_get("sensor.current_version")
assert entity.unique_id == "3699ef88-69e6-11ed-a1eb-0242ac120002"
assert entity.unique_id == "01JZN07D8D23994A49YKS649S7"
async def test_templates_with_yaml(hass: HomeAssistant) -> None:
@@ -688,27 +687,38 @@ async def test_templates_with_yaml(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
"get_config",
("get_resource_config", "get_sensor_config"),
[
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: 10,
CONF_ENCODING: DEFAULT_ENCODING,
SENSOR_DOMAIN: [
(
{
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: "GET",
"auth": {},
"advanced": {
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: 10,
CONF_ENCODING: DEFAULT_ENCODING,
},
},
(
{
CONF_SELECT: ".current-version h1",
CONF_NAME: "Current version",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
}
],
}
"data": {
CONF_SELECT: ".current-version h1",
CONF_INDEX: 0,
"advanced": {
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_AVAILABILITY: '{{ states("sensor.input1")=="on" }}',
CONF_ICON: 'mdi:o{{ "n" if states("sensor.input1")=="on" else "ff" }}',
CONF_PICTURE: 'o{{ "n" if states("sensor.input1")=="on" else "ff" }}.jpg',
},
},
# "subentry_id": "01JZN07D8D23994A49YKS649S7",
"subentry_type": "entity",
"title": "Current version",
"unique_id": None,
},
),
)
],
)
async def test_availability(

View File

@@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
create_target_condition,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@@ -22,17 +22,7 @@ from tests.components.common import (
@pytest.fixture
async def target_sirens(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple siren entities associated with different targets."""
return await target_entities(hass, "siren")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets.
Note: The switches are used to ensure that only siren entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "switch")
return await target_entities(hass, "siren", domain_excluded="switch")
@pytest.mark.parametrize(
@@ -61,18 +51,19 @@ async def test_siren_conditions_gated_by_labs_flag(
condition="siren.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="siren.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_siren_state_condition_behavior_any(
hass: HomeAssistant,
target_sirens: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
@@ -81,39 +72,17 @@ async def test_siren_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the siren state condition with the 'any' behavior."""
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
# Set all sirens, including the tested siren, to the initial state
for eid in target_sirens["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_sirens,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
# Set state for switches to ensure that they don't impact the condition
for state in states:
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other sirens also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -127,11 +96,13 @@ async def test_siren_state_condition_behavior_any(
condition="siren.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="siren.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -146,32 +117,13 @@ async def test_siren_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the siren state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_sirens["included_entities"]) - {entity_id}
# Set all sirens, including the tested siren, to the initial state
for eid in target_sirens["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_sirens,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]

View File

@@ -42,7 +42,6 @@
'attributes': ReadOnlyDict({
'friendly_name': 'Robot Vacuum',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'repeat': <RepeatMode.ALL: 'all'>,
'supported_features': <MediaPlayerEntityFeature: 284045>,
'volume_level': 0.2,
@@ -106,7 +105,6 @@
'device_class': 'speaker',
'friendly_name': 'Soundbar',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_artist': 'Rick Astley',
'media_title': 'Never Gonna Give You Up',
'source': 'wifi',
@@ -172,7 +170,6 @@
'device_class': 'speaker',
'friendly_name': 'Galaxy Home Mini',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'supported_features': <MediaPlayerEntityFeature: 318477>,
@@ -230,7 +227,6 @@
'device_class': 'speaker',
'friendly_name': 'Elliots Rum',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_artist': 'David Guetta',
'media_title': 'Forever Young',
'supported_features': <MediaPlayerEntityFeature: 21517>,
@@ -288,7 +284,6 @@
'device_class': 'speaker',
'friendly_name': 'Soundbar Living',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'media_artist': '',
'media_title': '',
'source': 'HDMI1',
@@ -346,7 +341,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Soundbar 1',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 1420>,
}),
'context': <ANY>,
@@ -407,7 +401,6 @@
'device_class': 'tv',
'friendly_name': '[TV] Samsung 8 Series (49)',
'is_volume_muted': True,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'source': 'HDMI1',
'source_list': list([
'digitalTv',

View File

@@ -51,7 +51,6 @@
'media_player.test_client_1_snapcast_client',
]),
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'latency': 6,
'media_album_artist': 'Test Album Artist 1, Test Album Artist 2',
'media_album_name': 'Test Album',
@@ -128,7 +127,6 @@
'media_player.test_client_2_snapcast_client',
]),
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'latency': 6,
'media_content_type': <MediaType.MUSIC: 'music'>,
'source': 'test_stream_2',

View File

@@ -46,7 +46,6 @@
'media_player.zone_a',
]),
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_content_type': <MediaType.MUSIC: 'music'>,
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,

View File

@@ -45,7 +45,6 @@
'attributes': ReadOnlyDict({
'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=7bb89748322acb6c',
'friendly_name': 'Spotify spotify_1',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Permanent Waves',
'media_artist': 'Rush',
'media_content_id': 'spotify:track:4e9hUiLsN4mx61ARosFi7p',
@@ -119,7 +118,6 @@
'attributes': ReadOnlyDict({
'entity_picture': '/api/media_player_proxy/media_player.spotify_spotify_1?token=mock-token&cache=cf1e6e1e830f08d3',
'friendly_name': 'Spotify spotify_1',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_artist': 'Safety Third',
'media_content_id': 'spotify:episode:3o0RYoo5iOMKSmEbunsbvW',
'media_content_type': <MediaType.PODCAST: 'podcast'>,

View File

@@ -44,7 +44,6 @@
'group_members': list([
]),
'is_volume_muted': True,
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_duration': 1,
'media_position': 1,
'query_result': dict({

View File

@@ -16,25 +16,14 @@ from tests.components.common import (
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple light entities associated with different targets.
Note: The lights are used to ensure that only switch entities are considered
in the condition evaluation and not other toggle entities.
"""
return await target_entities(hass, "light")
@pytest.fixture
async def target_switches(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple switch entities associated with different targets."""
return await target_entities(hass, "switch")
return await target_entities(hass, "switch", domain_excluded="light")
@pytest.fixture
@@ -69,17 +58,18 @@ async def test_switch_conditions_gated_by_labs_flag(
condition="switch.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="switch.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_switch_state_condition_behavior_any(
hass: HomeAssistant,
target_lights: dict[str, list[str]],
target_switches: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
@@ -89,39 +79,17 @@ async def test_switch_state_condition_behavior_any(
states: list[ConditionStateDescription],
) -> None:
"""Test the switch state condition with the 'any' behavior."""
other_entity_ids = set(target_switches["included_entities"]) - {entity_id}
# Set all switches, including the tested switch, to the initial state
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_any(
hass,
target_entities=target_switches,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="any",
condition_options=condition_options,
states=states,
)
# Set state for lights to ensure that they don't impact the condition
for state in states:
for eid in target_lights["included_entities"]:
set_or_remove_state(hass, eid, state["included_state"])
await hass.async_block_till_done()
assert condition(hass) is False
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
# Check if changing other lights also passes the condition
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
@@ -135,11 +103,13 @@ async def test_switch_state_condition_behavior_any(
condition="switch.is_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="switch.is_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
@@ -154,37 +124,17 @@ async def test_switch_state_condition_behavior_all(
states: list[ConditionStateDescription],
) -> None:
"""Test the switch state condition with the 'all' behavior."""
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
other_entity_ids = set(target_switches["included_entities"]) - {entity_id}
# Set all switches, including the tested switch, to the initial state
for eid in target_switches["included_entities"]:
set_or_remove_state(hass, eid, states[0]["included_state"])
await hass.async_block_till_done()
condition = await create_target_condition(
await assert_condition_behavior_all(
hass,
target_entities=target_switches,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
target=condition_target_config,
behavior="all",
condition_options=condition_options,
states=states,
)
for state in states:
included_state = state["included_state"]
set_or_remove_state(hass, entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true_first_entity"]
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert condition(hass) == state["condition_true"]
CONDITION_STATES = [
*parametrize_condition_states_any(

View File

@@ -42,7 +42,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Elon Musk',
'media_artist': 'Walter Isaacson',
'media_duration': 651.0,
@@ -66,7 +65,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_album_name': '',
'media_artist': '',
'media_playlist': '',
@@ -126,7 +124,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Elon Musk',
'media_artist': 'Walter Isaacson',
'media_duration': 651.0,

View File

@@ -42,7 +42,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Elon Musk',
'media_artist': 'Walter Isaacson',
'media_duration': 651.0,
@@ -66,7 +65,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'media_album_name': '',
'media_artist': '',
'media_duration': 0.0,
@@ -127,7 +125,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Elon Musk',
'media_artist': 'Walter Isaacson',
'media_duration': 651.0,
@@ -151,7 +148,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.OFF: 'off'>,
'supported_features': <MediaPlayerEntityFeature: 16437>,
}),
'context': <ANY>,
@@ -167,7 +163,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Test Album',
'media_artist': 'Test Artist',
'media_duration': 60,

View File

@@ -42,7 +42,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.IDLE: 'idle'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
'volume_level': 0.2258032258064516,
}),
@@ -59,7 +58,6 @@
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Test Media player',
'last_non_buffering_state': <MediaPlayerState.PLAYING: 'playing'>,
'media_album_name': 'Album',
'media_artist': 'Artist',
'media_duration': 60.0,

View File

@@ -5,6 +5,7 @@ import logging
from typing import Any
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.timer import (
@@ -33,7 +34,6 @@ from homeassistant.components.timer import (
STATUS_IDLE,
STATUS_PAUSED,
Timer,
_format_timedelta,
)
from homeassistant.const import (
ATTR_EDITABLE,
@@ -132,20 +132,27 @@ async def test_config_options(hass: HomeAssistant) -> None:
assert state_3 is not None
assert state_1.state == STATUS_IDLE
assert ATTR_ICON not in state_1.attributes
assert ATTR_FRIENDLY_NAME not in state_1.attributes
assert state_1.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:00",
}
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
assert state_2.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
}
assert state_3.state == STATUS_IDLE
assert str(cv.time_period(DEFAULT_DURATION)) == state_3.attributes.get(
CONF_DURATION
)
assert state_3.attributes == {
ATTR_DURATION: str(cv.time_period(DEFAULT_DURATION)),
ATTR_EDITABLE: False,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_methods_and_events(hass: HomeAssistant) -> None:
"""Test methods and events."""
hass.set_state(CoreState.starting)
@@ -155,13 +162,17 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
results: list[tuple[Event, str]] = []
results: list[tuple[Event, State | None]] = []
@callback
def fake_event_listener(event: Event):
"""Fake event listener for trigger."""
results.append((event, hass.states.get("timer.test1").state))
results.append((event, hass.states.get("timer.test1")))
hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener)
@@ -170,102 +181,142 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener)
finish_10 = (utcnow() + timedelta(seconds=10)).isoformat()
finish_5 = (utcnow() + timedelta(seconds=5)).isoformat()
steps = [
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_RESTARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_RESTARTED,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": EVENT_TIMER_CANCELLED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": None,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": None,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": EVENT_TIMER_FINISHED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": EVENT_TIMER_FINISHED,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": None,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": None,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:10"},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_CANCEL,
"state": STATUS_IDLE,
"event": EVENT_TIMER_CANCELLED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": EVENT_TIMER_CANCELLED,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_STARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_10,
ATTR_REMAINING: "0:00:10",
},
"expected_event": EVENT_TIMER_STARTED,
},
{
"call": SERVICE_CHANGE,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_CHANGED,
"data": {CONF_DURATION: -5},
"call_data": {CONF_DURATION: -5},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_CHANGED,
},
{
"call": SERVICE_START,
"state": STATUS_ACTIVE,
"event": EVENT_TIMER_RESTARTED,
"data": {},
"call_data": {},
"expected_state": STATUS_ACTIVE,
"expected_extra_attributes": {
ATTR_FINISHES_AT: finish_5,
ATTR_REMAINING: "0:00:05",
},
"expected_event": EVENT_TIMER_RESTARTED,
},
{
"call": SERVICE_PAUSE,
"state": STATUS_PAUSED,
"event": EVENT_TIMER_PAUSED,
"data": {},
"call_data": {},
"expected_state": STATUS_PAUSED,
"expected_extra_attributes": {ATTR_REMAINING: "0:00:05"},
"expected_event": EVENT_TIMER_PAUSED,
},
{
"call": SERVICE_FINISH,
"state": STATUS_IDLE,
"event": EVENT_TIMER_FINISHED,
"data": {},
"call_data": {},
"expected_state": STATUS_IDLE,
"expected_extra_attributes": {},
"expected_event": EVENT_TIMER_FINISHED,
},
]
@@ -275,22 +326,38 @@ async def test_methods_and_events(hass: HomeAssistant) -> None:
await hass.services.async_call(
DOMAIN,
step["call"],
{CONF_ENTITY_ID: "timer.test1", **step["data"]},
{CONF_ENTITY_ID: "timer.test1", **step["call_data"]},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
if step["state"] is not None:
assert state.state == step["state"]
if step["expected_state"] is not None:
assert state.state == step["expected_state"]
assert (
state.attributes
== {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
| step["expected_extra_attributes"]
)
if step["event"] is not None:
if step["expected_event"] is not None:
expected_events += 1
last_result = results[-1]
event, state = last_result
assert event.event_type == step["event"]
assert state == step["state"]
assert event.event_type == step["expected_event"]
assert state.state == step["expected_state"]
assert (
state.attributes
== {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
| step["expected_extra_attributes"]
)
assert len(results) == expected_events
@@ -302,7 +369,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
}
await hass.services.async_call(
DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -311,8 +381,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert state.attributes[ATTR_REMAINING] == "0:00:10"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
await hass.services.async_call(
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -321,8 +395,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
@@ -342,8 +418,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:15"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_REMAINING: "0:00:15",
}
with pytest.raises(
HomeAssistantError,
@@ -376,8 +456,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:12"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=12)).isoformat(),
ATTR_REMAINING: "0:00:12",
}
await hass.services.async_call(
DOMAIN,
@@ -388,8 +472,12 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes[ATTR_DURATION] == "0:00:15"
assert state.attributes[ATTR_REMAINING] == "0:00:14"
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:15",
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=14)).isoformat(),
ATTR_REMAINING: "0:00:14",
}
await hass.services.async_call(
DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True
@@ -398,8 +486,10 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
}
with pytest.raises(
HomeAssistantError,
@@ -415,11 +505,16 @@ async def test_start_service(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == "0:00:10"
assert ATTR_REMAINING not in state.attributes
assert state.attributes == {
ATTR_EDITABLE: False,
ATTR_DURATION: "0:00:10",
}
async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_wait_till_timer_expires(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test for a timer to end."""
hass.set_state(CoreState.starting)
@@ -428,6 +523,10 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
}
results = []
@@ -450,6 +549,12 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=20)).isoformat(),
ATTR_REMAINING: "0:00:20",
}
assert results[-1].event_type == EVENT_TIMER_STARTED
assert len(results) == 1
@@ -465,23 +570,41 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=15)).isoformat(),
ATTR_REMAINING: "0:00:15",
}
assert results[-1].event_type == EVENT_TIMER_CHANGED
assert len(results) == 2
async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
freezer.tick(10)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=5)).isoformat(),
ATTR_REMAINING: "0:00:15",
}
async_fire_time_changed(hass, utcnow() + timedelta(seconds=20))
freezer.tick(20)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
}
assert results[-1].event_type == EVENT_TIMER_FINISHED
assert len(results) == 3
@@ -496,6 +619,10 @@ async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> Non
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
async def test_config_reload(
@@ -538,13 +665,18 @@ async def test_config_reload(
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is None
assert state_1.state == STATUS_IDLE
assert ATTR_ICON not in state_1.attributes
assert ATTR_FRIENDLY_NAME not in state_1.attributes
assert state_1.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
}
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:10"
assert state_2.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World",
ATTR_ICON: "mdi:work",
}
with patch(
"homeassistant.config.load_yaml_config_file",
@@ -589,15 +721,21 @@ async def test_config_reload(
assert entity_registry.async_get_entity_id(DOMAIN, DOMAIN, "test_3") is not None
assert state_2.state == STATUS_IDLE
assert state_2.attributes.get(ATTR_FRIENDLY_NAME) == "Hello World reloaded"
assert state_2.attributes.get(ATTR_ICON) == "mdi:work-reloaded"
assert state_2.attributes.get(ATTR_DURATION) == "0:00:20"
assert state_2.attributes == {
ATTR_DURATION: "0:00:20",
ATTR_EDITABLE: False,
ATTR_FRIENDLY_NAME: "Hello World reloaded",
ATTR_ICON: "mdi:work-reloaded",
}
assert state_3.state == STATUS_IDLE
assert ATTR_ICON not in state_3.attributes
assert ATTR_FRIENDLY_NAME not in state_3.attributes
assert state_3.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_timer_restarted_event(hass: HomeAssistant) -> None:
"""Ensure restarted event is called after starting a paused or running timer."""
hass.set_state(CoreState.starting)
@@ -607,6 +745,10 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
results = []
@@ -628,6 +770,12 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_TIMER_STARTED
assert len(results) == 1
@@ -639,6 +787,12 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_TIMER_RESTARTED
assert len(results) == 2
@@ -650,6 +804,11 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_PAUSED
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_TIMER_PAUSED
assert len(results) == 3
@@ -661,11 +820,18 @@ async def test_timer_restarted_event(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_TIMER_RESTARTED
assert len(results) == 4
@pytest.mark.freeze_time("2023-06-05 17:47:50")
async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
"""Ensure timer's state changes when it restarted."""
hass.set_state(CoreState.starting)
@@ -675,6 +841,10 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
}
results = []
@@ -692,6 +862,12 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_STATE_CHANGED
assert len(results) == 1
@@ -703,6 +879,12 @@ async def test_state_changed_when_timer_restarted(hass: HomeAssistant) -> None:
state = hass.states.get("timer.test1")
assert state
assert state.state == STATUS_ACTIVE
assert state.attributes == {
ATTR_DURATION: "0:00:10",
ATTR_EDITABLE: False,
ATTR_FINISHES_AT: (utcnow() + timedelta(seconds=10)).isoformat(),
ATTR_REMAINING: "0:00:10",
}
assert results[-1].event_type == EVENT_STATE_CHANGED
assert len(results) == 2
@@ -713,8 +895,11 @@ async def test_load_from_storage(hass: HomeAssistant, storage_setup) -> None:
assert await storage_setup()
state = hass.states.get(f"{DOMAIN}.timer_from_storage")
assert state.state == STATUS_IDLE
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
assert state.attributes.get(ATTR_EDITABLE)
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
}
async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> None:
@@ -723,12 +908,18 @@ async def test_editable_state_attribute(hass: HomeAssistant, storage_setup) -> N
state = hass.states.get(f"{DOMAIN}.{DOMAIN}_from_storage")
assert state.state == STATUS_IDLE
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "timer from storage"
assert state.attributes.get(ATTR_EDITABLE)
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
}
state = hass.states.get(f"{DOMAIN}.from_yaml")
assert not state.attributes.get(ATTR_EDITABLE)
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: False,
}
async def test_ws_list(
@@ -797,7 +988,12 @@ async def test_update(
timer_entity_id = f"{DOMAIN}.{DOMAIN}_{timer_id}"
state = hass.states.get(timer_entity_id)
assert state.attributes[ATTR_FRIENDLY_NAME] == "timer from storage"
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:00",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
)
@@ -827,8 +1023,13 @@ async def test_update(
}
state = hass.states.get(timer_entity_id)
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33))
assert state.attributes[ATTR_RESTORE]
assert state.state == STATUS_IDLE
assert state.attributes == {
ATTR_DURATION: "0:00:33",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "timer from storage",
ATTR_RESTORE: True,
}
async def test_ws_create(
@@ -862,7 +1063,11 @@ async def test_ws_create(
state = hass.states.get(timer_entity_id)
assert state.state == STATUS_IDLE
assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(42))
assert state.attributes == {
ATTR_DURATION: "0:00:42",
ATTR_EDITABLE: True,
ATTR_FRIENDLY_NAME: "New Timer",
}
assert (
entity_registry.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
)
@@ -919,10 +1124,12 @@ async def test_restore_paused(hass: HomeAssistant) -> None:
await entity.async_added_to_hass()
await hass.async_block_till_done()
assert entity.state == STATUS_PAUSED
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
assert entity.extra_state_attributes[ATTR_RESTORE]
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
@pytest.mark.freeze_time("2023-06-05 17:47:50")
@@ -967,10 +1174,13 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert entity.state == STATUS_ACTIVE
assert entity.extra_state_attributes[ATTR_DURATION] == "0:00:30"
assert entity.extra_state_attributes[ATTR_REMAINING] == "0:00:15"
assert entity.extra_state_attributes[ATTR_FINISHES_AT] == finish.isoformat()
assert entity.extra_state_attributes[ATTR_RESTORE]
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:00:30",
ATTR_EDITABLE: True,
ATTR_FINISHES_AT: finish.isoformat(),
ATTR_REMAINING: "0:00:15",
ATTR_RESTORE: True,
}
assert len(events) == 1
@@ -1013,8 +1223,9 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non
await hass.async_block_till_done()
assert entity.state == STATUS_IDLE
assert entity.extra_state_attributes[ATTR_DURATION] == "0:01:00"
assert ATTR_REMAINING not in entity.extra_state_attributes
assert ATTR_FINISHES_AT not in entity.extra_state_attributes
assert entity.extra_state_attributes[ATTR_RESTORE]
assert entity.extra_state_attributes == {
ATTR_DURATION: "0:01:00",
ATTR_EDITABLE: True,
ATTR_RESTORE: True,
}
assert len(events) == 1

View File

@@ -0,0 +1,129 @@
"""Test update conditions."""
from typing import Any
import pytest
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
target_entities,
)
@pytest.fixture
async def target_updates(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple update entities associated with different targets."""
return await target_entities(hass, "update", domain_excluded="switch")
@pytest.mark.parametrize(
"condition",
[
"update.is_available",
"update.is_not_available",
],
)
async def test_update_conditions_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
) -> None:
"""Test the update conditions are gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("update"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_any(
condition="update.is_available",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_any(
condition="update.is_not_available",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_update_state_condition_behavior_any(
hass: HomeAssistant,
target_updates: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the update state condition with the 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_updates,
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.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("update"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_all(
condition="update.is_available",
target_states=[STATE_ON],
other_states=[STATE_OFF],
excluded_entities_from_other_domain=True,
),
*parametrize_condition_states_all(
condition="update.is_not_available",
target_states=[STATE_OFF],
other_states=[STATE_ON],
excluded_entities_from_other_domain=True,
),
],
)
async def test_update_state_condition_behavior_all(
hass: HomeAssistant,
target_updates: dict[str, list[str]],
condition_target_config: dict,
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the update state condition with the 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_updates,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -53,7 +53,6 @@
'device_class': 'speaker',
'friendly_name': 'Vizio',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'sound_mode': 'Music',
'sound_mode_list': list([
'Music',
@@ -131,7 +130,6 @@
'device_class': 'tv',
'friendly_name': 'Vizio',
'is_volume_muted': False,
'last_non_buffering_state': <MediaPlayerState.ON: 'on'>,
'sound_mode': 'Music',
'sound_mode_list': list([
'Music',

Some files were not shown because too many files have changed in this diff Show More