mirror of
https://github.com/home-assistant/core.git
synced 2026-06-30 02:25:31 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6206754422 | |||
| 81dcee72e0 | |||
| 6ed7e315fd |
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user