Compare commits

..

3 Commits

Author SHA1 Message Date
Erik 6206754422 Adjust design according to review feedback 2026-06-29 23:06:46 +02:00
Erik 81dcee72e0 Address review comments 2026-06-29 22:40:26 +02:00
Erik 6ed7e315fd Report errors in numerical entity triggers 2026-06-29 10:28:01 +02:00
35 changed files with 833 additions and 275 deletions
-1
View File
@@ -54,4 +54,3 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
-1
View File
@@ -43,4 +43,3 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
- Do not add section or divider comments (e.g. `# --- XYZ Triggers ---`) inside or outside of functions, since those can easily become stale and be misleading.
- When catching exceptions, try-clauses should be as small as possible, i.e. avoid wrapping large blocks of code in a try-clause, and avoid catching exceptions from functions that are not expected to raise them.
@@ -298,7 +298,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
):
self.options.pop(CONF_LLM_HASS_API)
if not errors:
return await self.async_step_additional()
return await self.async_step_advanced()
return self.async_show_form(
step_id="init",
@@ -308,10 +308,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors=errors or None,
)
async def async_step_additional(
async def async_step_advanced(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage additional options."""
"""Manage advanced options."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
@@ -360,7 +360,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
return await self.async_step_model()
return self.async_show_form(
step_id="additional",
step_id="advanced",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(step_schema), self.options
),
@@ -48,16 +48,16 @@
"user": "Add AI task"
},
"step": {
"additional": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::additional::data::prompt_caching%]"
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]"
},
"data_description": {
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::additional::data_description::chat_model%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::additional::data_description::prompt_caching%]"
"chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]",
"prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::additional::title%]"
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
},
"init": {
"data": {
@@ -115,7 +115,7 @@
"user": "Add conversation agent"
},
"step": {
"additional": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"prompt_caching": "Caching strategy"
@@ -124,7 +124,7 @@
"chat_model": "The model to serve the responses.",
"prompt_caching": "Optimize your API cost and response times based on your usage."
},
"title": "Additional settings"
"title": "Advanced settings"
},
"init": {
"data": {
+21 -4
View File
@@ -8,6 +8,7 @@ from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
@@ -30,7 +31,11 @@ class CounterBaseIntegerTrigger(EntityTriggerBase):
_schema = ENTITY_STATE_TRIGGER_SCHEMA
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state is valid."""
return _is_integer_state(state)
@@ -63,7 +68,11 @@ class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (max_value := state.attributes.get(CONF_MAXIMUM)) is None:
return False
@@ -74,7 +83,11 @@ class CounterMinReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its minimum value."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (min_value := state.attributes.get(CONF_MINIMUM)) is None:
return False
@@ -85,7 +98,11 @@ class CounterResetTrigger(CounterValueBaseTrigger):
"""Trigger for reset of counter entities."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state(s)."""
if (init_state := state.attributes.get(CONF_INITIAL)) is None:
return False
+10 -2
View File
@@ -5,7 +5,11 @@ from typing import override
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
@@ -24,7 +28,11 @@ class CoverTriggerBase(EntityTriggerBase):
return state.state
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[state.domain]
return self._get_value(state) == domain_spec.target_value
@@ -20,7 +20,7 @@ from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADDITIONAL_OPTIONS,
CONF_ADVANCED_OPTIONS,
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
@@ -39,7 +39,7 @@ from .const import (
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
vol.Required(CONF_ADDITIONAL_OPTIONS): section(
vol.Required(CONF_ADVANCED_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_RESOLVER): cv.string,
@@ -117,13 +117,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
hostname = user_input[CONF_HOSTNAME]
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
additional_options = user_input[CONF_ADDITIONAL_OPTIONS]
resolver = additional_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = additional_options.get(
advanced_options = user_input[CONF_ADVANCED_OPTIONS]
resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = advanced_options.get(
CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6
)
port = additional_options.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = additional_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
port = advanced_options.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = advanced_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
validate = await async_validate_hostname(
hostname, resolver, resolver_ipv6, port, port_ipv6
+1 -1
View File
@@ -12,7 +12,7 @@ CONF_PORT_IPV6 = "port_ipv6"
CONF_IPV4 = "ipv4"
CONF_IPV6 = "ipv6"
CONF_IPV6_V4 = "ipv6_v4"
CONF_ADDITIONAL_OPTIONS = "additional_options"
CONF_ADVANCED_OPTIONS = "advanced_options"
DEFAULT_HOSTNAME = "myip.opendns.com"
DEFAULT_IPV6 = False
+9 -9
View File
@@ -15,7 +15,7 @@
"hostname": "The hostname for which to perform the DNS query."
},
"sections": {
"additional_options": {
"advanced_options": {
"data": {
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
@@ -63,16 +63,16 @@
"step": {
"init": {
"data": {
"port": "[%key:component::dnsip::config::step::user::sections::additional_options::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::additional_options::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data::resolver_ipv6%]"
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver_ipv6%]"
},
"data_description": {
"port": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::additional_options::data_description::resolver_ipv6%]"
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver_ipv6%]"
},
"description": "Optionally change resolvers and ports."
}
+10 -2
View File
@@ -10,7 +10,11 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
@@ -19,7 +23,11 @@ class DoorbellRangTrigger(StatelessEntityTriggerBase):
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
+6 -1
View File
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
NotTriggeredReasonReporter,
StatelessEntityTriggerBase,
Trigger,
TriggerConfig,
@@ -42,7 +43,11 @@ class EventReceivedTrigger(StatelessEntityTriggerBase):
self._event_types = set(self._options[CONF_EVENT_TYPE])
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the event type matches one of the configured types."""
return state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
+5 -14
View File
@@ -11,7 +11,7 @@ from typing import Any, TypedDict, cast, override
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError, FritzConnectionException
from fritzconnection.core.exceptions import FritzActionError
from fritzconnection.lib.fritzcall import FritzCall
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
@@ -267,7 +267,9 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) = self._update_device_info()
if self.fritz_status.has_wan_support:
self.device_conn_type = self.fritz_status.connection_service
self.device_conn_type = (
self.fritz_status.get_default_connection_service().connection_service
)
self.device_is_router = self.fritz_status.has_wan_enabled
self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services
@@ -680,18 +682,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
async def async_trigger_reconnect(self) -> None:
"""Trigger device reconnect."""
try:
await self.hass.async_add_executor_job(
self.connection.call_action,
f"{self.device_conn_type}1",
"ForceTermination",
)
except FritzConnectionException as ex:
# ignore UPnPError:
# errorCode: 707
# errorDescription: DisconnectInProgress
if "disconnectinprogress" not in str(ex).lower():
raise
await self.hass.async_add_executor_job(self.connection.reconnect)
async def async_trigger_set_guest_password(
self, password: str | None, length: int
@@ -20,7 +20,7 @@ from .const import (
CONF_MIN_STATE_DURATION,
CONF_START,
PLATFORMS,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import HistoryStatsUpdateCoordinator
from .data import HistoryStats
@@ -44,8 +44,8 @@ async def async_setup_entry(
min_state_duration: timedelta
if duration_dict := entry.options.get(CONF_DURATION):
duration = timedelta(**duration_dict)
additional_settings = entry.options.get(SECTION_ADDITIONAL_SETTINGS, {})
if min_state_duration_dict := additional_settings.get(CONF_MIN_STATE_DURATION):
advanced_settings = entry.options.get(SECTION_ADVANCED_SETTINGS, {})
if min_state_duration_dict := advanced_settings.get(CONF_MIN_STATE_DURATION):
min_state_duration = timedelta(**min_state_duration_dict)
else:
min_state_duration = timedelta(0)
@@ -121,13 +121,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=3
)
if config_entry.minor_version < 4:
# The "advanced_settings" section was renamed to "additional_settings"
if (additional := options.pop("advanced_settings", None)) is not None:
options[SECTION_ADDITIONAL_SETTINGS] = additional
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=4
)
_LOGGER.debug(
"Migration to version %s.%s successful",
@@ -44,7 +44,7 @@ from .const import (
CONF_TYPE_TIME,
DEFAULT_NAME,
DOMAIN,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import HistoryStatsUpdateCoordinator
from .data import HistoryStats
@@ -149,7 +149,7 @@ def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
mode=SelectSelectorMode.DROPDOWN,
),
),
vol.Optional(SECTION_ADDITIONAL_SETTINGS): section(
vol.Optional(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Optional(CONF_MIN_STATE_DURATION): DurationSelector(
@@ -189,7 +189,7 @@ OPTIONS_FLOW = {
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for History stats."""
MINOR_VERSION = 4
MINOR_VERSION = 3
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
@@ -290,8 +290,8 @@ async def ws_start_preview(
start = validated_data.get(CONF_START)
end = validated_data.get(CONF_END)
duration = validated_data.get(CONF_DURATION)
additional_settings = validated_data.get(SECTION_ADDITIONAL_SETTINGS, {})
min_state_duration = additional_settings.get(CONF_MIN_STATE_DURATION)
advanced_settings = validated_data.get(SECTION_ADVANCED_SETTINGS, {})
min_state_duration = advanced_settings.get(CONF_MIN_STATE_DURATION)
state_class = validated_data.get(CONF_STATE_CLASS)
history_stats = HistoryStats(
@@ -18,4 +18,4 @@ CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
DEFAULT_NAME = "unnamed statistics"
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
@@ -28,7 +28,7 @@
},
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
"sections": {
"additional_settings": {
"advanced_settings": {
"data": { "min_state_duration": "Minimum state duration" },
"data_description": {
"min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds."
@@ -93,14 +93,14 @@
},
"description": "[%key:component::history_stats::config::step::options::description%]",
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::additional_settings::data::min_state_duration%]"
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data::min_state_duration%]"
},
"data_description": {
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::additional_settings::data_description::min_state_duration%]"
"min_state_duration": "[%key:component::history_stats::config::step::options::sections::advanced_settings::data_description::min_state_duration%]"
},
"name": "[%key:component::history_stats::config::step::options::sections::additional_settings::name%]"
"name": "[%key:component::history_stats::config::step::options::sections::advanced_settings::name%]"
}
}
}
@@ -9,6 +9,7 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
make_entity_transition_trigger,
)
@@ -60,7 +61,11 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
return self.is_muted(from_state) != self.is_muted(to_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state."""
if not self._has_volume_attributes(state):
return False
@@ -374,7 +374,7 @@
},
"get_queue": {
"description": "Retrieves the details of the currently active queue of a Music Assistant player.",
"name": "Get playerQueue details"
"name": "Get playerQueue details (advanced)"
},
"play_announcement": {
"description": "Plays an announcement on a Music Assistant player with more fine-grained control options.",
@@ -326,7 +326,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options.update(user_input)
if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input:
options.pop(CONF_LLM_HASS_API)
return await self.async_step_additional()
return await self.async_step_advanced()
return self.async_show_form(
step_id="init",
@@ -335,10 +335,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
),
)
async def async_step_additional(
async def async_step_advanced(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage additional options."""
"""Manage advanced options."""
options = self.options
errors: dict[str, str] = {}
@@ -374,7 +374,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
return await self.async_step_model()
return self.async_show_form(
step_id="additional",
step_id="advanced",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(step_schema), options
),
@@ -47,18 +47,18 @@
"user": "Add AI task"
},
"step": {
"additional": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::max_tokens%]",
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::store_responses%]",
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::temperature%]",
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data::top_p%]"
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]",
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::store_responses%]",
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]",
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]"
},
"data_description": {
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::data_description::store_responses%]"
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]"
},
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::additional::title%]"
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]"
},
"init": {
"data": {
@@ -109,7 +109,7 @@
"user": "Add conversation agent"
},
"step": {
"additional": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
@@ -120,7 +120,7 @@
"data_description": {
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
},
"title": "Additional settings"
"title": "Advanced settings"
},
"init": {
"data": {
@@ -34,7 +34,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .util import sanitize_container_name
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
@@ -264,7 +263,7 @@ class PortainerCoordinator(
# Map containers, started and stopped
for container in containers:
container_name = sanitize_container_name(container.names[0])
container_name = self._get_container_name(container.names[0])
prev_container = (
prev_endpoint.containers.get(container_name)
if prev_endpoint
@@ -314,7 +313,7 @@ class PortainerCoordinator(
container_stats = dict(
zip(
(
sanitize_container_name(container.names[0])
self._get_container_name(container.names[0])
for container in active_containers
),
await asyncio.gather(
@@ -432,6 +431,10 @@ class PortainerCoordinator(
for stack_callback in self.new_stacks_callbacks:
stack_callback(new_stack_data)
def _get_container_name(self, container_name: str) -> str:
"""Sanitize to get a proper container name."""
return container_name.replace("/", " ").strip()
class PortainerDockerDiskSpaceCoordinator(
PortainerBaseCoordinator[dict[int, DockerSystemDF]]
+1 -2
View File
@@ -19,7 +19,6 @@ from .coordinator import (
PortainerStackData,
PortainerVolumeData,
)
from .util import sanitize_container_name
class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]):
@@ -96,7 +95,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
# According to Docker's API docs, the first name is unique
names = self._device_info.container.names
assert names, "Container names list unexpectedly empty"
self.device_name = sanitize_container_name(names[0])
self.device_name = names[0].replace("/", " ").strip()
self._attr_device_info = DeviceInfo(
identifiers={
@@ -1,6 +0,0 @@
"""Utility functions for the Portainer integration."""
def sanitize_container_name(container_name: str) -> str:
"""Sanitize to get a proper container name."""
return container_name.replace("/", " ").strip()
+4 -4
View File
@@ -32,8 +32,8 @@
"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 settings for the resource.",
"name": "Additional settings"
"description": "Provide additional advanced settings for the resource.",
"name": "Advanced settings"
},
"auth": {
"data": {
@@ -117,8 +117,8 @@
"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 settings for the sensor.",
"name": "Additional settings"
"description": "Provide additional advanced settings for the sensor.",
"name": "Advanced settings"
}
}
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["verisure"],
"requirements": ["vsure==2.8.0"]
"requirements": ["vsure==2.7.1"]
}
+2 -2
View File
@@ -55,7 +55,7 @@
"migration_strategy_recommended": "This is the quickest option to migrate to a new adapter."
},
"menu_options": {
"migration_strategy_advanced": "Migrate manually",
"migration_strategy_advanced": "Advanced migration",
"migration_strategy_recommended": "Migrate automatically (recommended)"
},
"title": "Migrate to a new adapter"
@@ -74,7 +74,7 @@
"setup_strategy_recommended": "This is the quickest option to create a new network and get started."
},
"menu_options": {
"setup_strategy_advanced": "Set up manually",
"setup_strategy_advanced": "Advanced setup",
"setup_strategy_recommended": "Set up automatically (recommended)"
},
"title": "Set up Zigbee"
+21 -4
View File
@@ -36,6 +36,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTriggerBase,
NotTriggeredReasonReporter,
Trigger,
TriggerActionRunner,
TriggerConfig,
@@ -211,7 +212,11 @@ class EnteredZoneTrigger(ZoneTriggerBase):
return not self._in_target_zone(from_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the entity is now in the selected zone."""
return self._in_target_zone(state)
@@ -225,7 +230,11 @@ class LeftZoneTrigger(ZoneTriggerBase):
return self._in_target_zone(from_state)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the entity is no longer in the selected zone."""
return not self._in_target_zone(state)
@@ -279,7 +288,11 @@ class OccupancyDetectedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions to an occupied state."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the zone is occupied."""
return self._is_occupied(state)
@@ -293,7 +306,11 @@ class OccupancyClearedTrigger(_ZoneOccupancyTriggerBase):
"""Trigger when a zone transitions from occupied to unoccupied."""
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the zone is empty (count == 0)."""
return self._occupancy_count(state) == 0
+179 -25
View File
@@ -373,6 +373,10 @@ ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
)
def _report_not_triggered_noop(reason: str, /, **data: Any) -> None:
"""Swallow a not-triggered report; used when diagnostics are not wanted."""
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
@@ -430,7 +434,11 @@ class EntityTriggerBase(Trigger):
"""
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the state is a target state for the trigger.
Called only after `state.state` has been filtered against
@@ -438,6 +446,12 @@ class EntityTriggerBase(Trigger):
check. Default: any non-excluded state is a target. Override
to restrict (specific to_states, value within a threshold,
etc.).
When the state cannot fire the trigger, subclasses may use
`report_not_triggered` to record an interesting reason - e.g. a
non-numeric value or an unsupported unit - in the automation trace.
It defaults to a no-op, so callers that don't collect diagnostics
(e.g. `count_matches`) can omit it.
"""
return True
@@ -480,7 +494,7 @@ class EntityTriggerBase(Trigger):
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
if self.is_valid_state(state, _report_not_triggered_noop):
matches += 1
return matches, included
@@ -508,7 +522,7 @@ class EntityTriggerBase(Trigger):
if (
to_state is None
or to_state.state in self._excluded_states
or not self.is_valid_state(to_state)
or not self.is_valid_state(to_state, _report_not_triggered_noop)
):
pending_timers.pop(entity_id)()
return
@@ -592,11 +606,19 @@ class EntityTriggerBase(Trigger):
if not from_state or not to_state:
return
# The trigger should never fire if the new state is excluded
# or not a target state.
if to_state.state in self._excluded_states or not self.is_valid_state(
to_state
):
if to_state.state in self._excluded_states:
return
@callback
def report_not_triggered(reason: str, /, **data: Any) -> None:
"""Report why this evaluated change did not fire the trigger."""
if did_not_trigger is None:
return
did_not_trigger(
NotTriggeredInfo(reason=reason, data=data), event.context
)
if not self.is_valid_state(to_state, report_not_triggered):
return
# The trigger should never fire if the origin state is excluded
@@ -707,7 +729,11 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected state."""
return self._get_tracked_value(state) in self._to_states
@@ -728,7 +754,11 @@ class EntityTransitionTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state matches the expected states."""
return self._get_tracked_value(state) in self._to_states
@@ -747,7 +777,11 @@ class EntityOriginStateTriggerBase(EntityTriggerBase):
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check that the new state is different from the origin state."""
return bool(self._get_tracked_value(state) != self._from_state)
@@ -803,7 +837,11 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
return True
return unit == self._valid_unit
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -812,14 +850,29 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
if not self._is_valid_unit(state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)):
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
# Entity unit does not match the expected unit
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
try:
return float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
@override
@@ -840,11 +893,46 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
# Entity state is not a valid number
return None
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Called only when the tracked value is invalid. It mirrors the failure
modes of `_get_tracked_value` - which integrations override, so the
reason is derived here rather than reported inline: a state-sourced
value with an unsupported unit, otherwise a value that is not a number.
"""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if not self._is_valid_unit(unit):
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=unit,
)
return
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
@override
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Check if the new state or state attribute matches the expected one."""
# Handle missing or None value case first to avoid expensive exceptions
if (current_value := self._get_tracked_value(state)) is None:
self._report_tracked_value_problem(state, report_not_triggered)
return False
if self._threshold_type == NumericThresholdType.ANY:
@@ -853,20 +941,32 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
return True
if self._threshold_type == NumericThresholdType.ABOVE:
if (limit := self._get_threshold_value(self.threshold)) is None:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value > limit
if self._threshold_type == NumericThresholdType.BELOW:
if (limit := self._get_threshold_value(self.threshold)) is None:
if (
limit := self._get_threshold_value(self.threshold, report_not_triggered)
) is None:
# Entity not found or invalid number, don't trigger
return False
return current_value < limit
# Mode is BETWEEN or OUTSIDE
lower_limit = self._get_threshold_value(self.lower_threshold)
upper_limit = self._get_threshold_value(self.upper_threshold)
if lower_limit is None or upper_limit is None:
# Mode is BETWEEN or OUTSIDE. Evaluate the lower limit first so at most
# one not-triggered reason is reported per change.
lower_limit = self._get_threshold_value(
self.lower_threshold, report_not_triggered
)
if lower_limit is None:
# Entity not found or invalid number, don't trigger
return False
upper_limit = self._get_threshold_value(
self.upper_threshold, report_not_triggered
)
if upper_limit is None:
# Entity not found or invalid number, don't trigger
return False
between = lower_limit <= current_value <= upper_limit
@@ -886,7 +986,41 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@override
def _get_threshold_value(self, threshold: ThresholdConfig | None) -> float | None:
def _report_tracked_value_problem(
self, state: State, report_not_triggered: NotTriggeredReasonReporter
) -> None:
"""Report why `_get_tracked_value` rejected this state.
Mirrors the with-unit failure modes: a value that is not a number,
otherwise a unit that cannot be converted to the base unit.
"""
domain_spec = self._domain_specs[state.domain]
raw_value: Any
if domain_spec.value_source is None:
raw_value = state.state
else:
raw_value = state.attributes.get(domain_spec.value_source)
try:
float(raw_value)
except TypeError, ValueError:
report_not_triggered(
"entity_value_not_numeric",
entity_id=state.entity_id,
value=raw_value,
)
return
report_not_triggered(
"entity_unit_not_supported",
entity_id=state.entity_id,
unit=self._get_entity_unit(state),
)
@override
def _get_threshold_value(
self,
threshold: ThresholdConfig | None,
report_not_triggered: NotTriggeredReasonReporter,
) -> float | None:
"""Get threshold value from float or entity state."""
if threshold is None:
return None
@@ -899,19 +1033,32 @@ class EntityNumericalStateTriggerWithUnitBase(EntityNumericalStateTriggerBase):
if not (state := self._hass.states.get(threshold.entity)): # type: ignore[arg-type]
# Entity not found
report_not_triggered(
"threshold_entity_not_found",
entity_id=threshold.entity,
)
return None
try:
value = float(state.state)
except TypeError, ValueError:
# Entity state is not a valid number
report_not_triggered(
"threshold_value_not_numeric",
entity_id=threshold.entity,
value=state.state,
)
return None
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
try:
return self._unit_converter.convert(
value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT), self._base_unit
)
return self._unit_converter.convert(value, unit, self._base_unit)
except HomeAssistantError:
# Unit conversion failed (i.e. incompatible units), treat as invalid number
report_not_triggered(
"threshold_unit_not_supported",
entity_id=threshold.entity,
unit=unit,
)
return None
@override
@@ -1008,7 +1155,7 @@ class EntityNumericalStateCrossedThresholdTriggerBase(EntityNumericalStateTrigge
@override
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the tracked value crossed into the threshold range."""
return not self.is_valid_state(from_state)
return not self.is_valid_state(from_state, _report_not_triggered_noop)
def _make_numerical_state_crossed_threshold_with_unit_schema(
@@ -1288,6 +1435,13 @@ class TriggerNotTriggeredReporter(Protocol):
"""Report that the trigger did not fire."""
class NotTriggeredReasonReporter(Protocol):
"""Reports why an evaluated change did not fire an entity trigger."""
def __call__(self, reason: str, /, **data: Any) -> None:
"""Report, with diagnostic data, why the change did not fire."""
class TriggerNotTriggeredAction(Protocol):
"""Protocol type for the did_not_trigger consumer callback.
+1 -1
View File
@@ -3313,7 +3313,7 @@ volkszaehler==0.4.0
volvocarsapi==0.4.3
# homeassistant.components.verisure
vsure==2.8.0
vsure==2.7.1
# homeassistant.components.vasttrafik
vtjp==0.2.1
+12 -12
View File
@@ -275,9 +275,9 @@ async def test_subentry_options_thinking_budget_more_than_max(
},
)
assert options["type"] is FlowResultType.FORM
assert options["step_id"] == "additional"
assert options["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
options = await hass.config_entries.subentries.async_configure(
options["flow_id"],
{"chat_model": "claude-sonnet-4-5"},
@@ -330,9 +330,9 @@ async def test_subentry_web_search_user_location(
},
)
assert options["type"] is FlowResultType.FORM
assert options["step_id"] == "additional"
assert options["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
options = await hass.config_entries.subentries.async_configure(
options["flow_id"],
{
@@ -424,7 +424,7 @@ async def test_model_list(
},
)
assert options["type"] is FlowResultType.FORM
assert options["step_id"] == "additional"
assert options["step_id"] == "advanced"
assert options["data_schema"].schema["chat_model"].config["options"] == snapshot
@@ -447,9 +447,9 @@ async def test_invalid_model(
},
)
assert options["type"] is FlowResultType.FORM
assert options["step_id"] == "additional"
assert options["step_id"] == "advanced"
# Configure additional step but with api error
# Configure advanced step but with api error
with patch(
"homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.retrieve",
new_callable=AsyncMock,
@@ -877,12 +877,12 @@ async def test_ai_task_subentry_not_loaded(
assert result.get("reason") == "entry_not_loaded"
async def test_creating_ai_task_subentry_additional(
async def test_creating_ai_task_subentry_advanced(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test creating an AI task subentry with additional settings."""
"""Test creating an AI task subentry with advanced settings."""
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
@@ -891,7 +891,7 @@ async def test_creating_ai_task_subentry_additional(
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "init"
# Go to additional settings
# Go to advanced settings
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
@@ -901,9 +901,9 @@ async def test_creating_ai_task_subentry_additional(
)
assert result2.get("type") is FlowResultType.FORM
assert result2.get("step_id") == "additional"
assert result2.get("step_id") == "advanced"
# Configure additional settings
# Configure advanced settings
result3 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
+6 -6
View File
@@ -8,7 +8,7 @@ import pytest
from homeassistant import config_entries
from homeassistant.components.dnsip.config_flow import DATA_SCHEMA
from homeassistant.components.dnsip.const import (
CONF_ADDITIONAL_OPTIONS,
CONF_ADVANCED_OPTIONS,
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
@@ -50,7 +50,7 @@ async def test_form(hass: HomeAssistant) -> None:
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOSTNAME: "home-assistant.io", CONF_ADDITIONAL_OPTIONS: {}},
{CONF_HOSTNAME: "home-assistant.io", CONF_ADVANCED_OPTIONS: {}},
)
await hass.async_block_till_done()
@@ -71,7 +71,7 @@ async def test_form(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_with_additional_options(hass: HomeAssistant) -> None:
async def test_form_with_advanced_options(hass: HomeAssistant) -> None:
"""Test we can submit the form with custom resolver and port options."""
result = await hass.config_entries.flow.async_init(
@@ -95,7 +95,7 @@ async def test_form_with_additional_options(hass: HomeAssistant) -> None:
result["flow_id"],
{
CONF_HOSTNAME: "home-assistant.io",
CONF_ADDITIONAL_OPTIONS: {
CONF_ADVANCED_OPTIONS: {
CONF_RESOLVER: "8.8.8.8",
CONF_RESOLVER_IPV6: "2620:119:53::53",
CONF_PORT: 53,
@@ -136,7 +136,7 @@ async def test_form_error(hass: HomeAssistant) -> None:
result["flow_id"],
{
CONF_HOSTNAME: "home-assistant.io",
CONF_ADDITIONAL_OPTIONS: {},
CONF_ADVANCED_OPTIONS: {},
},
)
await hass.async_block_till_done()
@@ -185,7 +185,7 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None:
result["flow_id"],
{
CONF_HOSTNAME: "home-assistant.io",
CONF_ADDITIONAL_OPTIONS: {},
CONF_ADVANCED_OPTIONS: {},
},
)
await hass.async_block_till_done()
+12 -25
View File
@@ -483,44 +483,31 @@ async def test_trigger_methods(
) -> None:
"""Test trigger methods delegate to correct underlying calls."""
# test async_trigger_firmware_update
fritz_tools.connection.call_action = MagicMock(
return_value={"NewX_AVM-DE_UpdateState": True}
)
assert await fritz_tools.async_trigger_firmware_update() is True
# test async_trigger_reboot
fritz_tools.connection.reboot = MagicMock()
await fritz_tools.async_trigger_reboot()
fritz_tools.connection.reboot.assert_called_once()
# test async_trigger_set_guest_password
fritz_tools.connection.reconnect = MagicMock()
fritz_tools.fritz_guest_wifi.set_password = MagicMock()
await fritz_tools.async_trigger_set_guest_password("new-password", 20)
fritz_tools.fritz_guest_wifi.set_password.assert_called_once_with(
"new-password", 20
)
# test async_trigger_reconnect
fritz_tools.connection.call_action = MagicMock(
side_effect=FritzConnectionException(
"UPnPError:\nerrorCode: 707\nerrorDescription: DisconnectInProgress"
)
)
await fritz_tools.async_trigger_reconnect()
fritz_tools.connection.call_action.assert_called_with(
"WANPPPConnection1", "ForceTermination"
)
# test async_trigger_dial
fritz_tools.fritz_call.dial = MagicMock()
fritz_tools.fritz_call.hangup = MagicMock()
assert await fritz_tools.async_trigger_firmware_update() is True
await fritz_tools.async_trigger_reboot()
await fritz_tools.async_trigger_reconnect()
await fritz_tools.async_trigger_set_guest_password("new-password", 20)
with patch(
"homeassistant.components.fritz.coordinator.asyncio.sleep",
new=AsyncMock(),
) as sleep_mock:
await fritz_tools.async_trigger_dial("012345", 1)
fritz_tools.connection.reboot.assert_called_once()
fritz_tools.connection.reconnect.assert_called_once()
fritz_tools.fritz_guest_wifi.set_password.assert_called_once_with(
"new-password", 20
)
fritz_tools.fritz_call.dial.assert_called_once_with("012345")
sleep_mock.assert_awaited_once_with(1)
fritz_tools.fritz_call.hangup.assert_called_once()
@@ -10,11 +10,9 @@ from homeassistant.components.history_stats.config_flow import (
)
from homeassistant.components.history_stats.const import (
CONF_END,
CONF_MIN_STATE_DURATION,
CONF_START,
DEFAULT_NAME,
DOMAIN,
SECTION_ADDITIONAL_SETTINGS,
)
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -478,46 +476,6 @@ async def test_migration_1_2(
)
@pytest.mark.usefixtures("recorder_mock")
async def test_migration_1_3(
hass: HomeAssistant,
sensor_entity_entry: er.RegistryEntry,
) -> None:
"""Test migration from v1.3 renames advanced_settings to additional_settings."""
history_stats_config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
CONF_NAME: DEFAULT_NAME,
CONF_ENTITY_ID: sensor_entity_entry.entity_id,
CONF_STATE: ["on"],
CONF_TYPE: "count",
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
CONF_END: "{{ utcnow() }}",
"advanced_settings": {CONF_MIN_STATE_DURATION: {"seconds": 30}},
},
title="My history stats",
version=1,
minor_version=3,
)
history_stats_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
await hass.async_block_till_done()
assert history_stats_config_entry.state is ConfigEntryState.LOADED
assert "advanced_settings" not in history_stats_config_entry.options
assert history_stats_config_entry.options[SECTION_ADDITIONAL_SETTINGS] == {
CONF_MIN_STATE_DURATION: {"seconds": 30}
}
assert history_stats_config_entry.version == 1
assert (
history_stats_config_entry.minor_version
== HistoryStatsConfigFlowHandler.MINOR_VERSION
)
@pytest.mark.usefixtures("recorder_mock")
async def test_migration_from_future_version(
hass: HomeAssistant,
@@ -246,9 +246,9 @@ async def test_subentry_unsupported_model(
)
await hass.async_block_till_done()
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -300,9 +300,9 @@ async def test_subentry_reasoning_effort_list(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -354,9 +354,9 @@ async def test_subentry_reasoning_summary_visibility(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -403,7 +403,7 @@ async def test_subentry_reasoning_summary_options(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
@@ -450,7 +450,7 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch(
CONF_LLM_HASS_API: ["assist"],
},
)
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
@@ -515,9 +515,9 @@ async def test_subentry_service_tier_list(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -561,9 +561,9 @@ async def test_subentry_unsupported_reasoning_effort(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -1144,9 +1144,9 @@ async def test_subentry_web_search_user_location(
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "additional"
assert subentry_flow["step_id"] == "advanced"
# Configure additional step
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
@@ -1292,12 +1292,12 @@ async def test_ai_task_subentry_not_loaded(
assert result.get("reason") == "entry_not_loaded"
async def test_creating_ai_task_subentry_additional(
async def test_creating_ai_task_subentry_advanced(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test creating an AI task subentry with additional settings."""
"""Test creating an AI task subentry with advanced settings."""
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
@@ -1306,7 +1306,7 @@ async def test_creating_ai_task_subentry_additional(
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "init"
# Go to additional settings
# Go to advanced settings
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
@@ -1316,9 +1316,9 @@ async def test_creating_ai_task_subentry_additional(
)
assert result2.get("type") is FlowResultType.FORM
assert result2.get("step_id") == "additional"
assert result2.get("step_id") == "advanced"
# Configure additional settings
# Configure advanced settings
result3 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
+455 -34
View File
@@ -74,6 +74,7 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityTriggerBase,
NotTriggeredInfo,
NotTriggeredReasonReporter,
PluggableAction,
StatelessEntityTriggerBase,
Trigger,
@@ -81,9 +82,11 @@ from homeassistant.helpers.trigger import (
TriggerConfig,
TriggerNotTriggeredReporter,
_async_get_trigger_platform,
_report_not_triggered_noop,
async_initialize_triggers,
async_validate_trigger_config,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_changed_with_unit_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
@@ -105,6 +108,13 @@ from tests.common import (
)
def _reported_reasons(
did_not_trigger_reports: list[NotTriggeredInfo],
) -> list[tuple[str, Any]]:
"""Return the (reason, data) pair of each recorded did-not-trigger report."""
return [(report.reason, report.data) for report in did_not_trigger_reports]
async def _arm_numerical_trigger(
hass: HomeAssistant,
trigger_cls: type[Trigger],
@@ -1837,14 +1847,23 @@ async def test_numerical_state_attribute_changed_error_handling(
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None})
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": value},
)
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the above sensor does not exist
hass.states.async_remove("sensor.above")
@@ -1852,7 +1871,10 @@ async def test_numerical_state_attribute_changed_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("threshold_entity_not_found", {"entity_id": "sensor.above"})
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the above sensor state is not numeric
for invalid_value in ("cat", None):
@@ -1861,7 +1883,17 @@ async def test_numerical_state_attribute_changed_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": None},
),
(
"threshold_value_not_numeric",
{"entity_id": "sensor.above", "value": str(invalid_value)},
),
]
did_not_trigger_reports.clear()
# Reset the above sensor state to a valid numeric value
hass.states.async_set("sensor.above", "10")
@@ -1872,7 +1904,11 @@ async def test_numerical_state_attribute_changed_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}),
("threshold_entity_not_found", {"entity_id": "sensor.below"}),
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the below sensor state is not numeric
for invalid_value in ("cat", None):
@@ -1881,7 +1917,17 @@ async def test_numerical_state_attribute_changed_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": None},
),
(
"threshold_value_not_numeric",
{"entity_id": "sensor.below", "value": str(invalid_value)},
),
]
did_not_trigger_reports.clear()
unsub()
@@ -2288,6 +2334,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
entity_did_not_trigger_reports,
)
)
# Both triggers report a non-numeric tracked value identically.
entity_not_numeric = (
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": None},
)
# 77°F = 25°C, within range (above 20, below 30) - should trigger numerical
# Entity automation won't trigger because sensor.above/below don't exist yet
@@ -2302,8 +2353,13 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
await hass.async_block_till_done()
assert len(numeric_calls) == 1
assert entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
# The entity-threshold trigger can't resolve its limits yet (sensors absent)
assert numeric_did_not_trigger_reports == []
assert _reported_reasons(entity_did_not_trigger_reports) == [
("threshold_entity_not_found", {"entity_id": "sensor.above"})
]
numeric_calls.clear()
entity_did_not_trigger_reports.clear()
# 59°F = 15°C, below 20°C - should NOT trigger
hass.states.async_set(
@@ -2316,7 +2372,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert numeric_did_not_trigger_reports == []
assert _reported_reasons(entity_did_not_trigger_reports) == [
("threshold_entity_not_found", {"entity_id": "sensor.above"})
]
entity_did_not_trigger_reports.clear()
# 95°F = 35°C, above 30°C - should NOT trigger
hass.states.async_set(
@@ -2329,7 +2389,11 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert numeric_did_not_trigger_reports == []
assert _reported_reasons(entity_did_not_trigger_reports) == [
("threshold_entity_not_found", {"entity_id": "sensor.above"})
]
entity_did_not_trigger_reports.clear()
# Set up entity limits referencing sensors that report in °F
hass.states.async_set(
@@ -2363,7 +2427,10 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric]
assert _reported_reasons(entity_did_not_trigger_reports) == [entity_not_numeric]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
@@ -2377,7 +2444,20 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": value},
)
]
assert _reported_reasons(entity_did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": value},
)
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the unit is incompatible
hass.states.async_set(
@@ -2390,7 +2470,20 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [
(
"entity_unit_not_supported",
{"entity_id": "test.test_entity", "unit": "invalid_unit"},
)
]
assert _reported_reasons(entity_did_not_trigger_reports) == [
(
"entity_unit_not_supported",
{"entity_id": "test.test_entity", "unit": "invalid_unit"},
)
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the above sensor does not exist
hass.states.async_remove("sensor.above")
@@ -2409,7 +2502,15 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
# The intermediate None reports a non-numeric value on both triggers; the
# missing threshold entity is reported only by the entity-threshold trigger.
assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
("threshold_entity_not_found", {"entity_id": "sensor.above"}),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the above sensor state is not numeric
for invalid_value in ("cat", None):
@@ -2436,7 +2537,18 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [
entity_not_numeric
]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
(
"threshold_value_not_numeric",
{"entity_id": "sensor.above", "value": str(invalid_value)},
),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the above sensor's unit is incompatible
hass.states.async_set(
@@ -2459,7 +2571,16 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
(
"threshold_unit_not_supported",
{"entity_id": "sensor.above", "unit": "invalid_unit"},
),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Reset the above sensor state to a valid numeric value
hass.states.async_set(
@@ -2485,7 +2606,13 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
("threshold_entity_not_found", {"entity_id": "sensor.below"}),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the below sensor state is not numeric
for invalid_value in ("cat", None):
@@ -2508,7 +2635,18 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [
entity_not_numeric
]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
(
"threshold_value_not_numeric",
{"entity_id": "sensor.below", "value": str(invalid_value)},
),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
# Test the trigger does not fire when the below sensor's unit is incompatible
hass.states.async_set(
@@ -2531,12 +2669,242 @@ async def test_numerical_state_attribute_changed_with_unit_error_handling(
)
await hass.async_block_till_done()
assert numeric_calls == entity_calls == []
assert numeric_did_not_trigger_reports == entity_did_not_trigger_reports == []
assert _reported_reasons(numeric_did_not_trigger_reports) == [entity_not_numeric]
assert _reported_reasons(entity_did_not_trigger_reports) == [
entity_not_numeric,
(
"threshold_unit_not_supported",
{"entity_id": "sensor.below", "unit": "invalid_unit"},
),
]
numeric_did_not_trigger_reports.clear()
entity_did_not_trigger_reports.clear()
for unsub in unsubs:
unsub()
# State-sourced numerical triggers: brightness-style (percentage) and
# temperature-style (with unit conversion to a base unit).
_PERCENT_CHANGED_TRIGGER = make_entity_numerical_state_changed_trigger(
{"test": DomainSpec()}, "%"
)
_TEMPERATURE_CHANGED_TRIGGER = make_entity_numerical_state_changed_with_unit_trigger(
{"test": DomainSpec()}, UnitOfTemperature.CELSIUS, TemperatureConverter
)
@pytest.mark.parametrize(
(
"trigger_cls",
"good_unit",
"bad_state",
"bad_unit",
"expected_reason",
"expected_data",
),
[
pytest.param(
_PERCENT_CHANGED_TRIGGER,
"%",
"cat",
"%",
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": "cat"},
id="non-numeric",
),
pytest.param(
_PERCENT_CHANGED_TRIGGER,
"%",
"50",
"kg",
"entity_unit_not_supported",
{"entity_id": "test.test_entity", "unit": "kg"},
id="unsupported-unit",
),
pytest.param(
_TEMPERATURE_CHANGED_TRIGGER,
"°C",
"cat",
"°C",
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": "cat"},
id="with-unit-non-numeric",
),
pytest.param(
_TEMPERATURE_CHANGED_TRIGGER,
"°C",
"50",
"kg",
"entity_unit_not_supported",
{"entity_id": "test.test_entity", "unit": "kg"},
id="with-unit-incompatible-unit",
),
],
)
async def test_numerical_trigger_reports_invalid_tracked_value(
hass: HomeAssistant,
trigger_cls: type[Trigger],
good_unit: str,
bad_state: str,
bad_unit: str,
expected_reason: str,
expected_data: dict[str, Any],
) -> None:
"""Report a non-numeric value or unsupported unit on the tracked entity."""
calls: list[dict[str, Any]] = []
did_not_trigger_reports: list[NotTriggeredInfo] = []
hass.states.async_set(
"test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: good_unit}
)
await hass.async_block_till_done()
unsub = await _arm_numerical_trigger(
hass,
trigger_cls,
{"threshold": {"type": "any"}},
calls,
did_not_trigger_reports,
)
hass.states.async_set(
"test.test_entity", bad_state, {ATTR_UNIT_OF_MEASUREMENT: bad_unit}
)
await hass.async_block_till_done()
assert calls == []
assert _reported_reasons(did_not_trigger_reports) == [
(expected_reason, expected_data)
]
unsub()
@pytest.mark.parametrize(
(
"trigger_cls",
"good_unit",
"threshold_state",
"threshold_unit",
"expected_reason",
"expected_data",
),
[
pytest.param(
_PERCENT_CHANGED_TRIGGER,
"%",
"cat",
"%",
"threshold_value_not_numeric",
{"entity_id": "sensor.limit", "value": "cat"},
id="non-numeric",
),
pytest.param(
_PERCENT_CHANGED_TRIGGER,
"%",
"30",
"kg",
"threshold_unit_not_supported",
{"entity_id": "sensor.limit", "unit": "kg"},
id="unsupported-unit",
),
pytest.param(
_TEMPERATURE_CHANGED_TRIGGER,
"°C",
"cat",
"°C",
"threshold_value_not_numeric",
{"entity_id": "sensor.limit", "value": "cat"},
id="with-unit-non-numeric",
),
pytest.param(
_TEMPERATURE_CHANGED_TRIGGER,
"°C",
"30",
"kg",
"threshold_unit_not_supported",
{"entity_id": "sensor.limit", "unit": "kg"},
id="with-unit-incompatible-unit",
),
],
)
async def test_numerical_trigger_reports_invalid_threshold_entity(
hass: HomeAssistant,
trigger_cls: type[Trigger],
good_unit: str,
threshold_state: str,
threshold_unit: str,
expected_reason: str,
expected_data: dict[str, Any],
) -> None:
"""Report a non-numeric value or unsupported unit on a threshold entity."""
calls: list[dict[str, Any]] = []
did_not_trigger_reports: list[NotTriggeredInfo] = []
hass.states.async_set(
"sensor.limit", threshold_state, {ATTR_UNIT_OF_MEASUREMENT: threshold_unit}
)
hass.states.async_set(
"test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: good_unit}
)
await hass.async_block_till_done()
unsub = await _arm_numerical_trigger(
hass,
trigger_cls,
{"threshold": {"type": "above", "value": {"entity": "sensor.limit"}}},
calls,
did_not_trigger_reports,
)
hass.states.async_set(
"test.test_entity", "20", {ATTR_UNIT_OF_MEASUREMENT: good_unit}
)
await hass.async_block_till_done()
assert calls == []
assert _reported_reasons(did_not_trigger_reports) == [
(expected_reason, expected_data)
]
unsub()
async def test_numerical_trigger_reports_single_reason_for_between(
hass: HomeAssistant,
) -> None:
"""Two invalid between-thresholds yield a single diagnostic for the lower one."""
calls: list[dict[str, Any]] = []
did_not_trigger_reports: list[NotTriggeredInfo] = []
hass.states.async_set("sensor.low", "cat", {ATTR_UNIT_OF_MEASUREMENT: "%"})
hass.states.async_set("sensor.high", "dog", {ATTR_UNIT_OF_MEASUREMENT: "%"})
hass.states.async_set("test.test_entity", "10", {ATTR_UNIT_OF_MEASUREMENT: "%"})
await hass.async_block_till_done()
unsub = await _arm_numerical_trigger(
hass,
_PERCENT_CHANGED_TRIGGER,
{
"threshold": {
"type": "between",
"value_min": {"entity": "sensor.low"},
"value_max": {"entity": "sensor.high"},
}
},
calls,
did_not_trigger_reports,
)
hass.states.async_set("test.test_entity", "20", {ATTR_UNIT_OF_MEASUREMENT: "%"})
await hass.async_block_till_done()
assert calls == []
assert _reported_reasons(did_not_trigger_reports) == [
("threshold_value_not_numeric", {"entity_id": "sensor.low", "value": "cat"})
]
unsub()
@pytest.mark.parametrize(
("trigger_options", "expected_result"),
[
@@ -2979,8 +3347,11 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert len(calls) == 1
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None})
]
calls.clear()
did_not_trigger_reports.clear()
# Test the trigger does not fire when the attribute value is outside the limits
for value in (5, 95):
@@ -2993,14 +3364,23 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None})
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the attribute value is invalid
for value in ("cat", None):
hass.states.async_set("test.test_entity", "on", {"test_attribute": value})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": value},
)
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the lower sensor does not exist
hass.states.async_remove("sensor.lower")
@@ -3008,7 +3388,10 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("threshold_entity_not_found", {"entity_id": "sensor.lower"})
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the lower sensor state is not numeric
for invalid_value in ("cat", None):
@@ -3017,7 +3400,17 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": None},
),
(
"threshold_value_not_numeric",
{"entity_id": "sensor.lower", "value": str(invalid_value)},
),
]
did_not_trigger_reports.clear()
# Reset the lower sensor state to a valid numeric value
hass.states.async_set("sensor.lower", "10")
@@ -3028,7 +3421,11 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
("entity_value_not_numeric", {"entity_id": "test.test_entity", "value": None}),
("threshold_entity_not_found", {"entity_id": "sensor.upper"}),
]
did_not_trigger_reports.clear()
# Test the trigger does not fire when the upper sensor state is not numeric
for invalid_value in ("cat", None):
@@ -3037,7 +3434,17 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
hass.states.async_set("test.test_entity", "on", {"test_attribute": 50})
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_value_not_numeric",
{"entity_id": "test.test_entity", "value": None},
),
(
"threshold_value_not_numeric",
{"entity_id": "sensor.upper", "value": str(invalid_value)},
),
]
did_not_trigger_reports.clear()
unsub()
@@ -3418,7 +3825,13 @@ async def test_numerical_state_attribute_crossed_threshold_with_unit_error_handl
)
await hass.async_block_till_done()
assert calls == []
assert did_not_trigger_reports == []
assert _reported_reasons(did_not_trigger_reports) == [
(
"entity_unit_not_supported",
{"entity_id": "test.test_entity", "unit": "invalid_unit"},
)
]
did_not_trigger_reports.clear()
unsub()
@@ -3437,7 +3850,11 @@ def _make_trigger(
"""Accept any transition."""
return True
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Accept any state."""
return True
@@ -3582,13 +3999,13 @@ async def test_make_entity_target_state_trigger(
# Value changed to target — valid
assert trig.is_valid_transition(from_state, to_state)
assert trig.is_valid_state(to_state)
assert trig.is_valid_state(to_state, _report_not_triggered_noop)
# Value did not change — not a valid transition
assert not trig.is_valid_transition(from_state, from_state)
# Value not in to_states — not valid
assert not trig.is_valid_state(wrong_value_state)
assert not trig.is_valid_state(wrong_value_state, _report_not_triggered_noop)
@pytest.mark.parametrize(
@@ -3646,13 +4063,13 @@ async def test_make_entity_transition_trigger(
# Valid transition
assert trig.is_valid_transition(from_state, to_state)
assert trig.is_valid_state(to_state)
assert trig.is_valid_state(to_state, _report_not_triggered_noop)
# Wrong origin (not in from_states)
assert not trig.is_valid_transition(wrong_from, to_state)
# Wrong target (not in to_states)
assert not trig.is_valid_state(wrong_to)
assert not trig.is_valid_state(wrong_to, _report_not_triggered_noop)
# No change in tracked value — not a valid transition
assert not trig.is_valid_transition(from_state, from_state)
@@ -3697,7 +4114,7 @@ async def test_make_entity_origin_state_trigger(
# Valid: changed from expected origin to something else
assert trig.is_valid_transition(from_state, to_state)
assert trig.is_valid_state(to_state)
assert trig.is_valid_state(to_state, _report_not_triggered_noop)
# Wrong origin (not the expected from_state)
assert not trig.is_valid_transition(wrong_from, to_state)
@@ -3706,7 +4123,7 @@ async def test_make_entity_origin_state_trigger(
assert not trig.is_valid_transition(from_state, from_state)
# To-state still matches from_state — not valid
assert not trig.is_valid_state(from_state)
assert not trig.is_valid_state(from_state, _report_not_triggered_noop)
class _ActivatedTrigger(StatelessEntityTriggerBase):
@@ -3802,7 +4219,11 @@ class _OffToOnTrigger(EntityTriggerBase):
return False
return from_state.state != STATE_ON
def is_valid_state(self, state: State) -> bool:
def is_valid_state(
self,
state: State,
report_not_triggered: NotTriggeredReasonReporter,
) -> bool:
"""Valid if the state is 'on'."""
return state.state == STATE_ON