Compare commits

...

16 Commits

Author SHA1 Message Date
Erik cb0440b290 Make TriggerNotTriggeredAction a regular function 2026-06-17 13:08:31 +02:00
Erik c1409baf89 Call did_not_trigger when entity triggers do not match 2026-06-17 10:34:27 +02:00
Erik 0088f1f071 Add optional callback did_not_trigger to triggers 2026-06-17 10:34:08 +02:00
Erik Montnemery 7aba1daa16 Adjust language in condition history manager comments (#174106) 2026-06-17 09:23:32 +02:00
Jan Bouwhuis 12397cc4c1 Rename advanced settings/options in MQTT subentry translation strings (#174071) 2026-06-17 09:22:25 +02:00
Josef Zweck 73cdf7e067 Revert "Add pyserial-asyncio and pyserial-asyncio-fast to deprecated packages" (#174110) 2026-06-17 09:18:44 +02:00
Franck Nijhof 4e2cfecd96 Filter out closed sites in Amber Electric config flow (#174084) 2026-06-17 09:18:19 +02:00
Åke Strandberg 4625f7de27 Aqvify has reached gold tier (#174018) 2026-06-17 09:11:42 +02:00
Brett Adams 53a1db405c Improve test coverage of Teslemetry offline polling (#174108)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:47:08 +02:00
Paul Bottein ff7262d36f Fix Yoto quality scale comments (#174088) 2026-06-17 08:43:08 +02:00
tronikos 54feb95b76 Gemini: Update TTS model to gemini-3.1 and adjust configuration options (#174094) 2026-06-17 08:42:00 +02:00
epenet d9e2b49c0c Fix incorrect use of entity component constants in template (#172532) 2026-06-17 07:55:57 +02:00
renovate[bot] 4f9051464d Update cryptography to 48.0.1 (#174096) 2026-06-17 07:34:00 +02:00
Paulus Schoutsen 87894fd623 Activate venv before running python commands (#174093) 2026-06-17 07:32:22 +02:00
Franck Nijhof 34a70a9210 Clean up deprecated solar_rising entity from sun integration (#174079) 2026-06-17 06:44:16 +02:00
Paulus Schoutsen c9fb6a13fb Remove stale requirements_test_all.txt reference (#174095) 2026-06-17 05:08:20 +02:00
46 changed files with 1081 additions and 372 deletions
@@ -34,11 +34,13 @@ def generate_site_selector_name(site: Site) -> str:
def filter_sites(sites: list[Site]) -> list[Site]:
"""Deduplicates the list of sites."""
"""Filter out closed sites and deduplicate the list of sites."""
filtered: list[Site] = []
filtered_nmi: set[str] = set()
for site in sorted(sites, key=lambda site: site.status):
if site.status == SiteStatus.CLOSED:
continue
if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi:
filtered.append(site)
filtered_nmi.add(site.nmi)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["pyaqvify==0.0.11"]
}
@@ -53,28 +53,42 @@ rules:
test-coverage: done
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
discovery:
status: exempt
comment: |
Discovery not possible, as device is connected via 4G only. No LAN connection.
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: done
comment: |
No known limitations
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category:
status: done
comment: |
None of current sensors should be set as diagnostic
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
No repair issues are created.
stale-devices: done
# Platinum
async-dependency: todo
inject-websession: todo
@@ -976,6 +976,51 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
return None
return await self.async_trigger(run_variables, context, skip_condition)
@callback
def _handle_not_triggered(
self,
run_variables: dict[str, Any],
info: trigger_helper.NotTriggeredInfo,
context: Context | None = None,
) -> None:
"""Record a trace for a trigger that evaluated a change but did not fire.
This is the diagnostic sibling of async_trigger: a trigger calls it - in
certain interesting cases - when it does not run the action, so the user
can see in the trace why the automation was not triggered.
"""
if not self._is_enabled:
return
# Create a new context referring to the old context.
parent_id = None if context is None else context.id
trigger_context = Context(parent_id=parent_id)
with trace_automation(
self.hass,
self.unique_id,
self.raw_config,
self._blueprint_inputs,
trigger_context,
self._trace_config,
not_triggered=True,
) as automation_trace:
automation_trace.set_trace(trace_get())
trigger_description = run_variables.get("trigger", {}).get("description")
automation_trace.set_trigger_description(trigger_description)
# Record the trigger and its diagnostics as the trigger step.
if "idx" in run_variables.get("trigger", {}):
trigger_path = f"trigger/{run_variables['trigger']['idx']}"
else:
trigger_path = "trigger"
trace_element = TraceElement(run_variables, trigger_path)
trace_element.set_result(**info.as_dict())
trace_append_element(trace_element)
script_execution_set("not_triggered")
async def _async_attach_triggers(
self, home_assistant_start: bool
) -> Callable[[], None] | None:
@@ -1004,6 +1049,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
self._log_callback,
home_assistant_start,
variables,
did_not_trigger=self._handle_not_triggered,
)
+8 -1
View File
@@ -26,10 +26,13 @@ class AutomationTrace(ActionTrace):
config: ConfigType | None,
blueprint_inputs: ConfigType | None,
context: Context,
*,
not_triggered: bool = False,
) -> None:
"""Container for automation trace."""
super().__init__(item_id, config, blueprint_inputs, context)
self._trigger_description: str | None = None
self.not_triggered = not_triggered
def set_trigger_description(self, trigger: str) -> None:
"""Set trigger description."""
@@ -53,9 +56,13 @@ def trace_automation(
blueprint_inputs: ConfigType | None,
context: Context,
trace_config: ConfigType,
*,
not_triggered: bool = False,
) -> Generator[AutomationTrace]:
"""Trace action execution of automation with automation_id."""
trace = AutomationTrace(automation_id, config, blueprint_inputs, context)
trace = AutomationTrace(
automation_id, config, blueprint_inputs, context, not_triggered=not_triggered
)
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
try:
+12 -3
View File
@@ -27,7 +27,12 @@ from homeassistant.helpers.event import (
async_track_time_interval,
)
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -393,7 +398,9 @@ class SingleEntityEventTrigger(Trigger):
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
@@ -444,7 +451,9 @@ class EventTrigger(Trigger):
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
@@ -434,49 +434,56 @@ async def google_generative_ai_config_option_schema(
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type != "tts":
schema.update(
{
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
default=RECOMMENDED_TOP_P,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
vol.Optional(
CONF_TOP_K,
description={"suggested_value": options.get(CONF_TOP_K)},
default=RECOMMENDED_TOP_K,
): int,
vol.Optional(
CONF_MAX_TOKENS,
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HARASSMENT_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_HATE_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_HATE_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_SEXUAL_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_SEXUAL_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
vol.Optional(
CONF_DANGEROUS_BLOCK_THRESHOLD,
description={
"suggested_value": options.get(CONF_DANGEROUS_BLOCK_THRESHOLD)
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
}
)
if subentry_type == "conversation":
schema.update(
{
@@ -21,7 +21,7 @@ CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
RECOMMENDED_TTS_MODEL = "models/gemini-3.1-flash-tts-preview"
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
@@ -18,7 +18,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
from .const import (
CONF_CHAT_MODEL,
CONF_TEMPERATURE,
LOGGER,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TTS_MODEL,
)
from .entity import GoogleGenerativeAILLMBaseEntity
from .helpers import convert_to_wav
@@ -191,7 +197,10 @@ class GoogleGenerativeAITextToSpeechEntity(
self, message: str, language: str, options: dict[str, Any]
) -> TtsAudioType:
"""Load tts audio file from the engine."""
config = self.create_generate_content_config()
config = types.GenerateContentConfig()
config.temperature = self.subentry.data.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
config.response_modalities = ["AUDIO"]
config.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
+21 -21
View File
@@ -1124,7 +1124,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]:
if user_data.get(CONF_MIN_KELVIN, DEFAULT_MIN_KELVIN) >= user_data.get(
CONF_MAX_KELVIN, DEFAULT_MAX_KELVIN
):
errors["advanced_settings"] = "max_below_min_kelvin"
errors["other_settings"] = "max_below_min_kelvin"
return errors
@@ -1217,7 +1217,7 @@ def validate_text_platform_config(
and CONF_MAX in config
and config[CONF_MIN] > config[CONF_MAX]
):
errors["text_advanced_settings"] = "max_below_min"
errors["text_other_settings"] = "max_below_min"
return errors
@@ -1506,7 +1506,7 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OPTIONS: PlatformField(
selector=OPTIONS_SELECTOR,
@@ -1678,13 +1678,13 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
CONF_OFF_DELAY: PlatformField(
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.BUTTON: {
@@ -3125,7 +3125,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_SHORT: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3133,7 +3133,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=2,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_FLASH_TIME_LONG: PlatformField(
selector=FLASH_TIME_SELECTOR,
@@ -3141,7 +3141,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
validator=cv.positive_int,
default=10,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_TRANSITION: PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -3149,21 +3149,21 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
default=False,
validator=cv.boolean,
conditions=({CONF_SCHEMA: "json"},),
section="advanced_settings",
section="other_settings",
),
CONF_MAX_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MAX_KELVIN,
section="advanced_settings",
section="other_settings",
),
CONF_MIN_KELVIN: PlatformField(
selector=KELVIN_SELECTOR,
required=False,
validator=cv.positive_int,
default=DEFAULT_MIN_KELVIN,
section="advanced_settings",
section="other_settings",
),
},
Platform.LOCK: {
@@ -3372,7 +3372,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TIMEOUT_SELECTOR,
required=False,
validator=cv.positive_int,
section="advanced_settings",
section="other_settings",
),
},
Platform.SIREN: {
@@ -3437,7 +3437,7 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
required=False,
validator=validate(cv.template),
error="invalid_template",
section="siren_advanced_settings",
section="siren_other_settings",
),
},
Platform.SWITCH: {
@@ -3516,26 +3516,26 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
selector=TEXT_SIZE_SELECTOR,
required=True,
default=0,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MAX: PlatformField(
selector=TEXT_SIZE_SELECTOR,
required=True,
default=255,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_MODE: PlatformField(
selector=TEXT_MODE_SELECTOR,
required=True,
default=TextSelectorType.TEXT.value,
section="text_advanced_settings",
section="text_other_settings",
),
CONF_PATTERN: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=validate(cv.is_regex),
error="invalid_regular_expression",
section="text_advanced_settings",
section="text_other_settings",
),
},
Platform.TIME: {
@@ -3798,10 +3798,10 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
MQTT_DEVICE_PLATFORM_FIELDS = {
CONF_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
CONF_SW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_HW_VERSION: PlatformField(
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
selector=TEXT_SELECTOR, required=False, section="other_settings"
),
CONF_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
@@ -4686,8 +4686,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
if user_input is not None:
new_device_data: dict[str, Any] = user_input.copy()
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
if "advanced_settings" in new_device_data:
new_device_data |= new_device_data.pop("advanced_settings")
if "other_settings" in new_device_data:
new_device_data |= new_device_data.pop("other_settings")
if not errors:
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
if self.source == SOURCE_RECONFIGURE:
+40 -40
View File
@@ -184,17 +184,6 @@
},
"description": "Enter the MQTT device details:",
"sections": {
"advanced_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Advanced device settings"
},
"mqtt_settings": {
"data": {
"message_expiry_interval": "Message Expiry Interval",
@@ -205,6 +194,17 @@
"qos": "The Quality of Service value the device's entities should use."
},
"name": "MQTT settings"
},
"other_settings": {
"data": {
"hw_version": "Hardware version",
"sw_version": "Software version"
},
"data_description": {
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"sw_version": "The software version of the device. E.g. '2025.1.0'."
},
"name": "Other device settings"
}
},
"title": "Configure MQTT device details"
@@ -286,14 +286,14 @@
},
"description": "Please configure specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"other_settings": {
"data": {
"suggested_display_precision": "Suggested display precision"
},
"data_description": {
"suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)"
},
"name": "Advanced options"
"name": "Other settings"
}
},
"title": "Configure MQTT device \"{mqtt_device}\""
@@ -438,29 +438,6 @@
},
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
"sections": {
"advanced_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Advanced settings"
},
"alarm_control_panel_payload_settings": {
"data": {
"payload_arm_away": "Payload \"arm away\"",
@@ -916,14 +893,37 @@
},
"name": "Lock payload settings"
},
"siren_advanced_settings": {
"other_settings": {
"data": {
"expire_after": "Expire after",
"flash": "Flash support",
"flash_time_long": "Flash time long",
"flash_time_short": "Flash time short",
"max_kelvin": "Max Kelvin",
"min_kelvin": "Min Kelvin",
"off_delay": "OFF delay",
"transition": "Transition support"
},
"data_description": {
"expire_after": "If set, it defines the number of seconds after the sensors state expires, if its not updated. After expiry, the sensors state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)",
"flash": "Enable the flash feature for this light",
"flash_time_long": "The duration, in seconds, of a \"long\" flash.",
"flash_time_short": "The duration, in seconds, of a \"short\" flash.",
"max_kelvin": "The maximum color temperature in Kelvin.",
"min_kelvin": "The minimum color temperature in Kelvin.",
"off_delay": "For sensors that only send \"on\" state updates (like PIRs), this variable sets a delay in seconds after which the sensors state will be updated back to \"off\".",
"transition": "Enable the transition feature for this light"
},
"name": "Other settings"
},
"siren_other_settings": {
"data": {
"command_off_template": "Command \"off\" template"
},
"data_description": {
"command_off_template": "The [template]({command_templating_url}) for \"off\" state changes. By default the \"[Command template]({url}#command_template)\" will be used. [Learn more.]({url}#command_off_template)"
},
"name": "Advanced siren settings"
"name": "Other siren settings"
},
"target_humidity_settings": {
"data": {
@@ -985,7 +985,7 @@
},
"name": "Target temperature settings"
},
"text_advanced_settings": {
"text_other_settings": {
"data": {
"max": "Maximum length",
"min": "Minimum length",
@@ -998,7 +998,7 @@
"mode": "Mode of the text input",
"pattern": "A valid regex pattern"
},
"name": "Advanced text entity settings"
"name": "Other text entity settings"
},
"valve_payload_settings": {
"data": {
+8 -1
View File
@@ -5,7 +5,7 @@ import logging
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -50,6 +50,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
"""Set up from a config entry."""
# Remove deprecated solar_rising sensor entity (removed in 2026.1)
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(
Platform.SENSOR, DOMAIN, f"{entry.entry_id}-solar_rising"
):
ent_reg.async_remove(entity_id)
sun = Sun(hass)
component = EntityComponent[Sun](_LOGGER, DOMAIN, hass)
await component.async_add_entities([sun])
+16 -10
View File
@@ -1,15 +1,12 @@
"""Support for Template fans."""
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.fan import (
ATTR_DIRECTION,
ATTR_OSCILLATING,
ATTR_PERCENTAGE,
ATTR_PRESET_MODE,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN as FAN_DOMAIN,
@@ -100,6 +97,15 @@ FAN_CONFIG_ENTRY_SCHEMA = FAN_COMMON_SCHEMA.extend(
)
class FanScriptVariable(StrEnum):
"""Variables for scripts."""
DIRECTION = "direction"
OSCILLATING = "oscillating"
PERCENTAGE = "percentage"
PRESET_MODE = "preset_mode"
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -235,8 +241,8 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
await self.async_run_script(
self._action_scripts[CONF_ON_ACTION],
run_variables={
ATTR_PERCENTAGE: percentage,
ATTR_PRESET_MODE: preset_mode,
FanScriptVariable.PERCENTAGE: percentage,
FanScriptVariable.PRESET_MODE: preset_mode,
},
context=self._context,
)
@@ -267,7 +273,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
await self.async_run_script(
script,
run_variables={ATTR_PERCENTAGE: self._attr_percentage},
run_variables={FanScriptVariable.PERCENTAGE: self._attr_percentage},
context=self._context,
)
@@ -284,7 +290,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
await self.async_run_script(
script,
run_variables={ATTR_PRESET_MODE: self._attr_preset_mode},
run_variables={FanScriptVariable.PRESET_MODE: self._attr_preset_mode},
context=self._context,
)
@@ -302,7 +308,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_OSCILLATING: self.oscillating},
run_variables={FanScriptVariable.OSCILLATING: self.oscillating},
context=self._context,
)
@@ -318,7 +324,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
) is not None:
await self.async_run_script(
script,
run_variables={ATTR_DIRECTION: direction},
run_variables={FanScriptVariable.DIRECTION: direction},
context=self._context,
)
if CONF_DIRECTION not in self._templates:
+1 -2
View File
@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.number import (
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
@@ -161,7 +160,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
if set_value := self._action_scripts.get(CONF_SET_VALUE):
await self.async_run_script(
set_value,
run_variables={ATTR_VALUE: value},
run_variables={"value": value},
context=self._context,
)
+3 -5
View File
@@ -6,8 +6,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.select import (
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN,
ENTITY_ID_FORMAT,
SelectEntity,
@@ -48,7 +46,7 @@ SCRIPT_FIELDS = (CONF_SELECT_OPTION,)
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Required(ATTR_OPTIONS): cv.template,
vol.Required(CONF_OPTIONS): cv.template,
vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_STATE): cv.template,
}
@@ -147,7 +145,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
await self.async_run_script(
select_option,
run_variables={ATTR_OPTION: option},
run_variables={"option": option},
context=self._context,
)
@@ -175,7 +173,7 @@ class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect):
"""Select entity based on trigger data."""
domain = SELECT_DOMAIN
extra_template_keys_complex = (ATTR_OPTIONS,)
extra_template_keys_complex = (CONF_OPTIONS,)
def __init__(
self,
+5 -5
View File
@@ -9,7 +9,6 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
CONF_STATE_CLASS,
DEVICE_CLASSES_SCHEMA,
DOMAIN as SENSOR_DOMAIN,
@@ -50,13 +49,14 @@ from .schemas import (
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
CONF_LAST_RESET = "last_reset"
DEFAULT_NAME = "Template Sensor"
def validate_last_reset(val):
"""Run extra validation checks."""
if (
val.get(ATTR_LAST_RESET) is not None
val.get(CONF_LAST_RESET) is not None
and val.get(CONF_STATE_CLASS) != SensorStateClass.TOTAL
):
raise vol.Invalid(
@@ -78,7 +78,7 @@ SENSOR_COMMON_SCHEMA = vol.Schema(
SENSOR_YAML_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(ATTR_LAST_RESET): cv.template,
vol.Optional(CONF_LAST_RESET): cv.template,
}
)
.extend(SENSOR_COMMON_SCHEMA.schema)
@@ -204,10 +204,10 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
self._validate_state,
)
self.setup_template(
ATTR_LAST_RESET,
CONF_LAST_RESET,
"_attr_last_reset",
validate_datetime(
self, ATTR_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
self, CONF_LAST_RESET, SensorDeviceClass.TIMESTAMP, require_tzinfo=False
),
)
+1 -2
View File
@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
DOMAIN as VACUUM_DOMAIN,
SERVICE_CLEAN_SPOT,
SERVICE_LOCATE,
@@ -389,7 +388,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED):
await self.async_run_script(
script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context
script, run_variables={"fan_speed": fan_speed}, context=self._context
)
+4 -1
View File
@@ -20,6 +20,7 @@ from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
make_entity_target_state_trigger,
)
from homeassistant.helpers.typing import ConfigType
@@ -66,7 +67,9 @@ class TimeRemainingTrigger(Trigger):
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
scheduled: dict[str, CALLBACK_TYPE] = {}
+9 -2
View File
@@ -16,7 +16,12 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
from . import TodoItem, TodoListEntity
@@ -140,7 +145,9 @@ class ItemTriggerBase(Trigger, abc.ABC):
self._target = config.target
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
+2 -2
View File
@@ -58,8 +58,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
await store.async_save(
{
key: list(traces.values())
for key, traces in hass.data[DATA_TRACE].items()
key: list(trace_bucket.all_traces())
for key, trace_bucket in hass.data[DATA_TRACE].items()
}
)
except HomeAssistantError as exc:
+31 -2
View File
@@ -2,6 +2,8 @@
import abc
from collections import deque
from collections.abc import Iterator
from dataclasses import dataclass
import datetime as dt
from typing import Any
@@ -16,7 +18,7 @@ from homeassistant.helpers.trace import (
from homeassistant.util import dt as dt_util, uuid as uuid_util
from homeassistant.util.limited_size_dict import LimitedSizeDict
type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]]
type TraceData = dict[str, TraceBuckets]
class BaseTrace(abc.ABC):
@@ -25,6 +27,9 @@ class BaseTrace(abc.ABC):
context: Context
key: str
run_id: str
# True for traces recording that a trigger evaluated a relevant change but
# did not fire. These are counted separately from actual runs.
not_triggered: bool = False
def as_dict(self) -> dict[str, Any]:
"""Return an dictionary version of this ActionTrace for saving."""
@@ -42,6 +47,27 @@ class BaseTrace(abc.ABC):
"""Return a brief dictionary version of this ActionTrace."""
@dataclass(slots=True)
class TraceBuckets:
"""The run and not-triggered traces for a single script or automation.
Not-triggered traces (a trigger evaluated a change but did not fire) are
counted and size-limited separately so they never evict actual run traces.
"""
runs: LimitedSizeDict[str, BaseTrace]
not_triggered: LimitedSizeDict[str, BaseTrace]
def bucket(self, not_triggered: bool) -> LimitedSizeDict[str, BaseTrace]:
"""Return the bucket holding traces of the requested kind."""
return self.not_triggered if not_triggered else self.runs
def all_traces(self) -> Iterator[BaseTrace]:
"""Yield all traces, runs first then not-triggered."""
yield from self.runs.values()
yield from self.not_triggered.values()
class ActionTrace(BaseTrace):
"""Base container for a script or automation trace."""
@@ -123,7 +149,7 @@ class ActionTrace(BaseTrace):
last_step = list(self._trace)[-1]
domain, item_id = self.key.split(".", 1)
result = {
result: dict[str, Any] = {
"last_step": last_step,
"run_id": self.run_id,
"state": self._state,
@@ -135,6 +161,8 @@ class ActionTrace(BaseTrace):
"domain": domain,
"item_id": item_id,
}
if self.not_triggered:
result["not_triggered"] = True
if self._error is not None:
result["error"] = str(self._error)
@@ -159,6 +187,7 @@ class RestoredTrace(BaseTrace):
self.context = context
self.key = f"{extended_dict['domain']}.{extended_dict['item_id']}"
self.run_id = extended_dict["run_id"]
self.not_triggered = short_dict.get("not_triggered", False)
self._dict = extended_dict
self._short_dict = short_dict
+38 -23
View File
@@ -9,7 +9,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .const import DATA_TRACE, DATA_TRACE_STORE, DATA_TRACES_RESTORED
from .models import ActionTrace, BaseTrace, RestoredTrace, TraceData
from .models import ActionTrace, BaseTrace, RestoredTrace, TraceBuckets, TraceData
_LOGGER = logging.getLogger(__name__)
@@ -21,7 +21,9 @@ async def async_get_trace(
# Restore saved traces if not done
await async_restore_traces(hass)
return hass.data[DATA_TRACE][key][run_id].as_extended_dict()
trace_bucket = hass.data[DATA_TRACE][key]
trace = trace_bucket.runs.get(run_id) or trace_bucket.not_triggered[run_id]
return trace.as_extended_dict()
async def async_list_contexts(
@@ -31,7 +33,7 @@ async def async_list_contexts(
# Restore saved traces if not done
await async_restore_traces(hass)
values: Mapping[str, LimitedSizeDict[str, BaseTrace] | None] | TraceData
values: Mapping[str, TraceBuckets | None] | TraceData
if key is not None:
values = {key: hass.data[DATA_TRACE].get(key)}
else:
@@ -44,16 +46,16 @@ async def async_list_contexts(
return {
trace.context.id: _trace_id(trace.run_id, key)
for key, traces in values.items()
if traces is not None
for trace in traces.values()
for key, trace_bucket in values.items()
if trace_bucket is not None
for trace in trace_bucket.all_traces()
}
def _get_debug_traces(hass: HomeAssistant, key: str) -> list[dict[str, Any]]:
"""Return a serializable list of debug traces for a script or automation."""
if traces_for_key := hass.data[DATA_TRACE].get(key):
return [trace.as_short_dict() for trace in traces_for_key.values()]
if trace_bucket := hass.data[DATA_TRACE].get(key):
return [trace.as_short_dict() for trace in trace_bucket.all_traces()]
return []
@@ -79,14 +81,23 @@ async def async_list_traces(
def async_store_trace(
hass: HomeAssistant, trace: ActionTrace, stored_traces: int
) -> None:
"""Store a trace if its key is valid."""
"""Store a trace if its key is valid.
Run traces and not-triggered traces are kept in separate, independently
size-limited buckets so a flood of not-triggered traces never evicts runs.
"""
if key := trace.key:
traces = hass.data[DATA_TRACE]
if key not in traces:
traces[key] = LimitedSizeDict(size_limit=stored_traces)
else:
traces[key].size_limit = stored_traces
traces[key][trace.run_id] = trace
traces[key] = TraceBuckets(
runs=LimitedSizeDict(size_limit=stored_traces),
not_triggered=LimitedSizeDict(size_limit=stored_traces),
)
trace_bucket = traces[key]
trace_bucket.runs.size_limit = stored_traces
trace_bucket.not_triggered.size_limit = stored_traces
bucket = trace_bucket.bucket(trace.not_triggered)
bucket[trace.run_id] = trace
def _async_store_restored_trace(hass: HomeAssistant, trace: RestoredTrace) -> None:
@@ -94,9 +105,12 @@ def _async_store_restored_trace(hass: HomeAssistant, trace: RestoredTrace) -> No
key = trace.key
traces = hass.data[DATA_TRACE]
if key not in traces:
traces[key] = LimitedSizeDict()
traces[key][trace.run_id] = trace
traces[key].move_to_end(trace.run_id, last=False)
traces[key] = TraceBuckets(
runs=LimitedSizeDict(), not_triggered=LimitedSizeDict()
)
bucket = traces[key].bucket(trace.not_triggered)
bucket[trace.run_id] = trace
bucket.move_to_end(trace.run_id, last=False)
async def async_restore_traces(hass: HomeAssistant) -> None:
@@ -116,17 +130,18 @@ async def async_restore_traces(hass: HomeAssistant) -> None:
for key, traces in restored_traces.items():
# Add stored traces in reversed order to prioritize the newest traces
for json_trace in reversed(traces):
if (
(stored_traces := hass.data[DATA_TRACE].get(key))
and stored_traces.size_limit is not None
and len(stored_traces) >= stored_traces.size_limit
):
break
try:
trace = RestoredTrace(json_trace)
# Catch any exception to not blow up if the stored trace is invalid
except Exception:
_LOGGER.exception("Failed to restore trace")
continue
# Runs and not-triggered traces are capped independently, so check
# the bucket this trace belongs to rather than breaking the loop.
if (trace_bucket := hass.data[DATA_TRACE].get(key)) is not None:
bucket = trace_bucket.bucket(trace.not_triggered)
if bucket.size_limit is not None and len(bucket) >= bucket.size_limit:
continue
_async_store_restored_trace(hass, trace)
@@ -5,7 +5,7 @@ rules:
comment: This integration does not register custom service actions.
appropriate-polling:
status: done
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
comment: Live state is pushed over MQTT; the coordinator polls REST every 5 min only for the device roster and player config (firmware, day/night times).
brands: done
common-modules: done
config-flow-test-coverage: done
@@ -48,7 +48,7 @@ rules:
diagnostics: done
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
comment: Cloud connection; DHCP discovery only triggers setup, no network address is persisted to update.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
@@ -70,7 +70,7 @@ rules:
comment: Authorization is the only configuration; reauth covers re-linking the account.
repair-issues:
status: exempt
comment: No repair issues are raised yet.
comment: Auth failures go through the reauth flow; other errors are transient and retried by the coordinator.
stale-devices: todo
# Platinum
+4 -1
View File
@@ -39,6 +39,7 @@ from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
@@ -127,7 +128,9 @@ class LegacyZoneTrigger(Trigger):
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id: list[str] = self._options[CONF_ENTITY_ID]
@@ -20,7 +20,12 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
from ..const import (
@@ -168,7 +173,9 @@ class EventTrigger(Trigger):
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
dev_reg = dr.async_get(self._hass)
@@ -14,7 +14,12 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import (
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
from ..config_validation import VALUE_SCHEMA
@@ -225,7 +230,9 @@ class ValueUpdatedTrigger(Trigger):
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
return await async_attach_trigger(self._hass, self._options, run_action)
+11 -8
View File
@@ -490,11 +490,11 @@ class _HistoryPrimingManager:
The flush a condition relies on must begin after that condition started
tracking its entities, or the read could miss a change still queued in the
recorder and compute too generous an anchor. A condition therefore never
rides a flush that was already running when it arrived (the lobby); it waits
that one out and joins the next, and re-attempts if the flush it rode was
cancelled before completing. This mirrors `ReloadServiceHelper` minus its
target de-duplication, which does not apply because each condition reads its
own entities.
relies on a flush that was already running when it arrived (the lobby); it
waits that one out and joins the next, re-attempting if the flush it waited
for was cancelled before completing. This mirrors `ReloadServiceHelper`
minus its target de-duplication, which does not apply because each condition
reads its own entities.
"""
def __init__(self, hass: HomeAssistant) -> None:
@@ -516,11 +516,13 @@ class _HistoryPrimingManager:
async def _async_flush(self) -> None:
"""Return once a recorder flush that began no earlier than this call ends.
The first condition of a generation performs the flush; the rest ride it.
The first condition of a generation performs the flush; the rest rely on
it.
"""
async with self._flush_condition:
# Lobby: a flush already running began before we arrived, so it may
# not capture our entity's queued changes. Wait it out, don't ride it.
# not capture our entity's queued changes. Wait it out, don't rely on
# it.
if self._flushing:
await self._flush_condition.wait()
@@ -530,7 +532,8 @@ class _HistoryPrimingManager:
# First past the lobby this generation: we run the flush.
self._flushing = True
break
# A peer began a fresh flush after we cleared the lobby; ride it.
# A peer began a fresh flush after we cleared the lobby; wait for
# it.
await self._flush_condition.wait()
if self._flush_ok:
return
+128 -7
View File
@@ -4,6 +4,7 @@ import abc
import asyncio
from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable, Mapping
from contextvars import copy_context
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import functools
@@ -302,8 +303,15 @@ class Trigger(abc.ABC):
self,
action: TriggerAction,
action_payload_builder: TriggerActionPayloadBuilder,
*,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action."""
"""Attach the trigger to an action.
The optional ``did_not_trigger`` reporter is the sibling of the action
runner: triggers may call it - in certain interesting cases - when they
evaluate a relevant change but decide not to fire.
"""
@callback
def run_action(
@@ -316,11 +324,13 @@ class Trigger(abc.ABC):
payload = action_payload_builder(extra_trigger_payload, description)
return self._hass.async_create_task(action(payload, context))
return await self.async_attach_runner(run_action)
return await self.async_attach_runner(run_action, did_not_trigger)
@abc.abstractmethod
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@@ -527,7 +537,9 @@ class EntityTriggerBase(Trigger):
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
@@ -577,11 +589,25 @@ class EntityTriggerBase(Trigger):
if not from_state or not to_state:
return
@callback
def report_not_triggered(reason: str, **data: Any) -> None:
"""Report why this state change did not fire the trigger."""
if did_not_trigger is None:
return
did_not_trigger(
NotTriggeredInfo(reason=reason, data=data), event.context
)
# 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
):
report_not_triggered(
"new_state_not_a_match",
entity_id=entity_id,
to_state=to_state.state,
)
return
# The trigger should never fire if the origin state is excluded
@@ -590,6 +616,12 @@ class EntityTriggerBase(Trigger):
from_state.state in self._excluded_from_states
or not self.is_valid_transition(from_state, to_state)
):
report_not_triggered(
"transition_not_a_match",
entity_id=entity_id,
from_state=from_state.state,
to_state=to_state.state,
)
return
# Count against the targeted entity states as of this event, not
@@ -603,6 +635,9 @@ class EntityTriggerBase(Trigger):
target_state_change_data.targeted_entity_states,
)
if matches != included:
report_not_triggered(
"not_all_targets_matched", matches=matches, included=included
)
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
@@ -613,6 +648,9 @@ class EntityTriggerBase(Trigger):
target_state_change_data.targeted_entity_states,
)
if matches != 1:
report_not_triggered(
"behavior_first_not_satisfied", matches=matches
)
return
@callback
@@ -1205,6 +1243,27 @@ class TriggerConfig:
options: dict[str, Any] | None = None
@dataclass(slots=True, frozen=True)
class NotTriggeredInfo:
"""Diagnostics describing why a trigger evaluated a change but did not fire.
Passed by a trigger to its ``did_not_trigger`` reporter, the sibling of the
action runner that is called - in certain interesting cases - when the
trigger does not fire. ``reason`` is a stable, machine-readable code; the
optional ``data`` carries the evaluated context for the trace.
"""
reason: str
data: Mapping[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
"""Return a JSON-serializable dict for storing in a trace."""
result: dict[str, Any] = {"reason": self.reason}
if self.data is not None:
result["data"] = dict(self.data)
return result
class TriggerActionRunner(Protocol):
"""Protocol type for the trigger action runner helper callback."""
@@ -1222,6 +1281,39 @@ class TriggerActionRunner(Protocol):
"""
class TriggerNotTriggeredReporter(Protocol):
"""Protocol type for the did_not_trigger reporter passed to a trigger runner.
A trigger calls this to report that it evaluated a relevant change but
decided not to fire, supplying diagnostics for tracing.
"""
@callback
def __call__(
self,
info: NotTriggeredInfo,
context: Context | None = None,
) -> None:
"""Report that the trigger did not fire."""
class TriggerNotTriggeredAction(Protocol):
"""Protocol type for the did_not_trigger consumer callback.
Sibling of the action callback. Invoked - instead of the action - when a
trigger evaluated a relevant change but reported it did not fire.
"""
@callback
def __call__(
self,
run_variables: dict[str, Any],
info: NotTriggeredInfo,
context: Context | None = None,
) -> None:
"""Define did_not_trigger consumer callback type."""
class TriggerActionPayloadBuilder(Protocol):
"""Protocol type for the trigger action payload builder."""
@@ -1506,6 +1598,7 @@ async def _async_attach_trigger_cls(
conf: ConfigType,
action: Callable,
trigger_info: TriggerInfo,
did_not_trigger: TriggerNotTriggeredAction | None = None,
) -> CALLBACK_TYPE:
"""Initialize a new Trigger class and attach it."""
@@ -1526,6 +1619,26 @@ async def _async_attach_trigger_cls(
payload.update(trigger_variables.async_render(hass, payload))
return payload
report_not_triggered: TriggerNotTriggeredReporter | None = None
if did_not_trigger is not None:
not_triggered_action = did_not_trigger
@callback
def report_not_triggered(
info: NotTriggeredInfo, context: Context | None = None
) -> None:
"""Forward a did-not-fire report to the consumer."""
run_variables = {
"trigger": {
**trigger_info["trigger_data"],
CONF_PLATFORM: trigger_key,
}
}
# The consumer records a trace using the trace context variables.
# Run it in a copied context so it does not disturb the trace of the
# run that produced this state change (e.g. a chained automation).
copy_context().run(not_triggered_action, run_variables, info, context)
# Wrap sync action so that it is always async.
# This simplifies the Trigger action runner interface by
# always returning a coroutine, removing the need for
@@ -1564,7 +1677,9 @@ async def _async_attach_trigger_cls(
options=conf.get(CONF_OPTIONS),
),
)
return await trigger.async_attach_action(action, action_payload_builder)
return await trigger.async_attach_action(
action, action_payload_builder, did_not_trigger=report_not_triggered
)
async def async_initialize_triggers(
@@ -1576,8 +1691,14 @@ async def async_initialize_triggers(
log_cb: Callable,
home_assistant_start: bool = False,
variables: TemplateVarsType = None,
did_not_trigger: TriggerNotTriggeredAction | None = None,
) -> CALLBACK_TYPE | None:
"""Initialize triggers."""
"""Initialize triggers.
The optional ``did_not_trigger`` consumer is the sibling of ``action``,
invoked - for new-style triggers that support it - when a trigger evaluates
a relevant change but reports it did not fire. Old-style triggers ignore it.
"""
triggers: list[asyncio.Task[CALLBACK_TYPE]] = []
for idx, conf in enumerate(trigger_config):
# Skip triggers that are not enabled
@@ -1613,7 +1734,7 @@ async def async_initialize_triggers(
)
trigger_cls = trigger_descriptors[relative_trigger_key]
coro = _async_attach_trigger_cls(
hass, trigger_cls, trigger_key, conf, action, info
hass, trigger_cls, trigger_key, conf, action, info, did_not_trigger
)
else:
action_wrapper = _trigger_action_wrapper(hass, action, conf)
+1 -1
View File
@@ -29,7 +29,7 @@ cached-ipaddress==1.1.2
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
cryptography==48.0.1
dbus-fast==5.0.16
file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
+1 -3
View File
@@ -31,9 +31,7 @@ DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = {
}
DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = {
# old_package_name: (reason, breaks_in_ha_version)
"pyserial": ("should be replaced by serialx", "2027.1"),
"pyserial-asyncio": ("should be replaced by serialx", "2027.1"),
"pyserial-asyncio-fast": ("should be replaced by serialx", "2027.1"),
"pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"),
}
_LOGGER = logging.getLogger(__name__)
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies = [
"lru-dict==1.4.1",
"PyJWT==2.12.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==48.0.0",
"cryptography==48.0.1",
"Pillow==12.2.0",
"propcache==0.5.2",
"pyOpenSSL==26.2.0",
+1 -1
View File
@@ -21,7 +21,7 @@ bcrypt==5.0.0
certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
cryptography==48.0.1
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
+4
View File
@@ -6,6 +6,10 @@ set -e
cd "$(realpath "$(dirname "$0")/..")"
if [ ! -n "$VIRTUAL_ENV" ]; then
source .venv/bin/activate
fi
echo "Installing development dependencies..."
uv pip install \
-e . \
+1 -1
View File
@@ -17,7 +17,7 @@ from script.hassfest.model import Config, Integration
# Requirements which can't be installed on all systems because they
# rely on additional system packages. Requirements listed in
# EXCLUDED_REQUIREMENTS_ALL will be commented-out in
# requirements_all.txt and requirements_test_all.txt.
# requirements_all.txt.
EXCLUDED_REQUIREMENTS_ALL = {
"atenpdu", # depends on pysnmp which is not maintained at this time
"avion",
@@ -216,34 +216,15 @@ async def test_single_site(hass: HomeAssistant, single_site_api: Mock) -> None:
async def test_single_closed_site_no_closed_date(
hass: HomeAssistant, single_site_closed_no_close_date_api: Mock
) -> None:
"""Test single closed site with no closed date."""
initial_result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert initial_result.get("type") is FlowResultType.FORM
assert initial_result.get("step_id") == "user"
# Test filling in API key
"""Test single closed site with no closed date is filtered out."""
enter_api_key_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_API_TOKEN: API_KEY},
)
assert enter_api_key_result.get("type") is FlowResultType.FORM
assert enter_api_key_result.get("step_id") == "site"
select_site_result = await hass.config_entries.flow.async_configure(
enter_api_key_result["flow_id"],
{CONF_SITE_ID: "01FG0AGP818PXK0DWHXJRRT2DH", CONF_SITE_NAME: "Home"},
)
# Show available sites
assert select_site_result.get("type") is FlowResultType.CREATE_ENTRY
assert select_site_result.get("title") == "Home"
data = select_site_result.get("data")
assert data
assert data[CONF_API_TOKEN] == API_KEY
assert data[CONF_SITE_ID] == "01FG0AGP818PXK0DWHXJRRT2DH"
assert enter_api_key_result.get("step_id") == "user"
assert enter_api_key_result.get("errors") == {"api_token": "no_site"}
async def test_single_site_rejoin(
@@ -333,13 +314,9 @@ async def test_unknown_error(hass: HomeAssistant, api_error: Mock) -> None:
assert result.get("errors") == {"api_token": "unknown_error"}
async def test_site_deduplication(single_site_rejoin_api: Mock) -> None:
"""Test site deduplication."""
async def test_site_filtering(single_site_rejoin_api: Mock) -> None:
"""Test that closed sites are filtered out and remaining sites are deduplicated."""
filtered = filter_sites(single_site_rejoin_api.get_sites())
assert len(filtered) == 2
assert (
next(s for s in filtered if s.nmi == "11111111111").status == SiteStatus.ACTIVE
)
assert (
next(s for s in filtered if s.nmi == "11111111112").status == SiteStatus.CLOSED
)
assert len(filtered) == 1
assert filtered[0].nmi == "11111111111"
assert filtered[0].status == SiteStatus.ACTIVE
+258
View File
@@ -32,8 +32,10 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
)
from homeassistant.core import (
CALLBACK_TYPE,
Context,
CoreState,
Event,
HomeAssistant,
ServiceCall,
State,
@@ -55,16 +57,26 @@ from homeassistant.helpers.script import (
SCRIPT_MODE_SINGLE,
_async_stop_scripts_at_shutdown,
)
from homeassistant.helpers.trigger import (
NotTriggeredInfo,
Trigger,
TriggerActionRunner,
TriggerNotTriggeredReporter,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util, yaml as yaml_util
from tests.common import (
MockConfigEntry,
MockModule,
MockUser,
assert_setup_component,
async_capture_events,
async_fire_time_changed,
async_mock_service,
mock_integration,
mock_platform,
mock_restore_cache,
)
from tests.components.logbook.common import MockRow, mock_humanify
@@ -4214,3 +4226,249 @@ async def test_automation_changed_entity_id(
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(calls) == 2
class _MockDiagnosticTrigger(Trigger):
"""A new-style trigger that fires on demand and otherwise reports why not."""
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return config
async def async_attach_runner(
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Fire on `mock_diag_event` with `fire`, else report not-triggered."""
@callback
def handle_event(event: Event) -> None:
if event.data.get("fire"):
run_action({"extra": "fired"}, "mock fired", event.context)
elif did_not_trigger is not None:
did_not_trigger(
NotTriggeredInfo(reason="mock_reason", data={"x": 1}),
event.context,
)
return self._hass.bus.async_listen("mock_diag_event", handle_event)
async def _setup_diagnostic_automation(
hass: HomeAssistant, stored_traces: int | None = None
) -> list[Any]:
"""Set up an automation driven by the mock diagnostic trigger."""
async def async_get_triggers(
hass: HomeAssistant,
) -> dict[str, type[Trigger]]:
return {"diag": _MockDiagnosticTrigger}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
calls = async_mock_service(hass, "test", "automation")
config: dict[str, Any] = {
"id": "diag_auto",
"trigger": {"platform": "test.diag"},
"action": {"service": "test.automation"},
}
if stored_traces is not None:
config["trace"] = {"stored_traces": stored_traces}
assert await async_setup_component(
hass, automation.DOMAIN, {automation.DOMAIN: config}
)
await hass.async_block_till_done()
return calls
async def test_automation_records_not_triggered_trace(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A non-firing evaluation records a not-triggered trace with diagnostics."""
calls = await _setup_diagnostic_automation(hass)
client = await hass_ws_client()
msg_id = 0
def next_id() -> int:
nonlocal msg_id
msg_id += 1
return msg_id
hass.bus.async_fire("mock_diag_event", {"fire": False})
await hass.async_block_till_done()
# The action did not run, but a not-triggered trace was recorded.
assert len(calls) == 0
await client.send_json(
{
"id": next_id(),
"type": "trace/list",
"domain": "automation",
"item_id": "diag_auto",
}
)
response = await client.receive_json()
assert response["success"]
traces = response["result"]
assert len(traces) == 1
assert traces[0]["not_triggered"] is True
assert traces[0]["script_execution"] == "not_triggered"
await client.send_json(
{
"id": next_id(),
"type": "trace/get",
"domain": "automation",
"item_id": "diag_auto",
"run_id": traces[0]["run_id"],
}
)
response = await client.receive_json()
assert response["success"]
trace = response["result"]
assert trace["trace"]["trigger/0"][0]["result"] == {
"reason": "mock_reason",
"data": {"x": 1},
}
async def test_not_triggered_traces_counted_separately(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Not-triggered traces are capped independently from run traces."""
calls = await _setup_diagnostic_automation(hass, stored_traces=2)
client = await hass_ws_client()
msg_id = 0
def next_id() -> int:
nonlocal msg_id
msg_id += 1
return msg_id
# Fire more non-firing and firing evaluations than the stored-traces limit.
for _ in range(3):
hass.bus.async_fire("mock_diag_event", {"fire": False})
await hass.async_block_till_done()
for _ in range(3):
hass.bus.async_fire("mock_diag_event", {"fire": True})
await hass.async_block_till_done()
assert len(calls) == 3
await client.send_json(
{
"id": next_id(),
"type": "trace/list",
"domain": "automation",
"item_id": "diag_auto",
}
)
response = await client.receive_json()
assert response["success"]
traces = response["result"]
not_triggered = [trace for trace in traces if trace.get("not_triggered")]
runs = [trace for trace in traces if not trace.get("not_triggered")]
# Each bucket is capped at 2 independently; neither evicts the other.
assert len(not_triggered) == 2
assert len(runs) == 2
async def test_not_triggered_trace_isolated_from_chained_run(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A not-triggered trace must not corrupt the trace of the run that caused it.
Automation "parent" fires the event that automation "child"'s trigger
evaluates and declines. The child's not-triggered handler runs inline in the
parent's context; without a copied context its ``trace_clear()`` would
redirect the parent's remaining action steps into the child's trace.
"""
async def async_get_triggers(
hass: HomeAssistant,
) -> dict[str, type[Trigger]]:
return {"diag": _MockDiagnosticTrigger}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: [
{
"id": "parent",
"trigger": {"platform": "event", "event_type": "chain_start"},
"action": [
# The child trigger evaluates this and declines to fire.
{"event": "mock_diag_event"},
# The parent's own step after the chained evaluation.
{"event": "chain_marker"},
],
},
{
"id": "child",
"trigger": {"platform": "test.diag"},
"action": {"event": "child_ran"},
},
]
},
)
await hass.async_block_till_done()
child_ran = async_capture_events(hass, "child_ran")
hass.bus.async_fire("chain_start")
await hass.async_block_till_done()
# The child trigger evaluated the event but did not fire.
assert len(child_ran) == 0
client = await hass_ws_client()
msg_id = 0
def next_id() -> int:
nonlocal msg_id
msg_id += 1
return msg_id
async def _get_only_trace(item_id: str) -> dict[str, Any]:
await client.send_json(
{
"id": next_id(),
"type": "trace/list",
"domain": "automation",
"item_id": item_id,
}
)
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 1
run_id = response["result"][0]["run_id"]
await client.send_json(
{
"id": next_id(),
"type": "trace/get",
"domain": "automation",
"item_id": item_id,
"run_id": run_id,
}
)
response = await client.receive_json()
assert response["success"]
return response["result"]
# The parent keeps all of its own steps; action/1 must not leak away into
# the child's trace. This fails if the context is not copied.
parent_trace = await _get_only_trace("parent")
assert set(parent_trace["trace"]) == {"trigger/0", "action/0", "action/1"}
# The child's not-triggered trace holds only its own trigger step.
child_trace = await _get_only_trace("child")
assert child_trace["not_triggered"] is True
assert set(child_trace["trace"]) == {"trigger/0"}
@@ -79,7 +79,7 @@
'labels': set({
}),
'manufacturer': 'Google',
'model': 'gemini-2.5-flash-preview-tts',
'model': 'gemini-3.1-flash-tts-preview',
'model_id': None,
'name': 'Google AI TTS',
'name_by_user': None,
@@ -68,17 +68,17 @@ def get_models_pager():
)
model_15_pro.name = "models/gemini-1.5-pro-latest"
model_25_flash_tts = Mock(
model_31_flash_tts = Mock(
supported_actions=["generateContent"],
)
model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts"
model_31_flash_tts.name = "models/gemini-3.1-flash-tts-preview"
async def models_pager():
yield model_25_flash
yield model_20_flash
yield model_15_flash
yield model_15_pro
yield model_25_flash_tts
yield model_31_flash_tts
return models_pager()
@@ -278,13 +278,6 @@ async def test_creating_subentry(
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL,
CONF_TEMPERATURE: 1.0,
CONF_TOP_P: 1.0,
CONF_TOP_K: 1,
CONF_MAX_TOKENS: 1024,
CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE",
},
),
(
@@ -14,11 +14,7 @@ from homeassistant.components import tts
from homeassistant.components.google_generative_ai_conversation.const import (
CONF_CHAT_MODEL,
DOMAIN,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
)
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
@@ -185,28 +181,6 @@ async def test_tts_service_speak(
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=None,
),
)
@@ -254,27 +228,5 @@ async def test_tts_service_speak_error(
)
),
temperature=RECOMMENDED_TEMPERATURE,
top_k=RECOMMENDED_TOP_K,
top_p=RECOMMENDED_TOP_P,
max_output_tokens=RECOMMENDED_MAX_TOKENS,
safety_settings=[
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
types.SafetySetting(
category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=None,
),
)
+17 -17
View File
@@ -2754,7 +2754,7 @@ async def test_migrate_config_entry(
{
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"advanced_settings": {"expire_after": 1200, "off_delay": 5},
"other_settings": {"expire_after": 1200, "off_delay": 5},
},
(
(
@@ -3418,10 +3418,10 @@ async def test_migrate_config_entry(
(
{
"command_topic": "test-topic",
"advanced_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
"other_settings": {"max_kelvin": 2000, "min_kelvin": 2000},
},
{
"advanced_settings": "max_below_min_kelvin",
"other_settings": "max_below_min_kelvin",
},
),
),
@@ -3700,7 +3700,7 @@ async def test_migrate_config_entry(
{
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"advanced_settings": {"expire_after": 30},
"other_settings": {"expire_after": 30},
},
(
(
@@ -3766,7 +3766,7 @@ async def test_migrate_config_entry(
"available_tones": ["Happy hour", "Cooling alarm"],
"support_duration": True,
"support_volume_set": True,
"siren_advanced_settings": {
"siren_other_settings": {
"command_off_template": "{{ value }}",
},
},
@@ -3827,7 +3827,7 @@ async def test_migrate_config_entry(
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"retain": False,
"text_advanced_settings": {
"text_other_settings": {
"min": 0,
"max": 10,
"mode": "password",
@@ -3849,26 +3849,26 @@ async def test_migrate_config_entry(
(
{
"command_topic": "test-topic",
"text_advanced_settings": {
"text_other_settings": {
"min": 20,
"max": 10,
"mode": "password",
"pattern": "^[a-z_]*$",
},
},
{"text_advanced_settings": "max_below_min"},
{"text_other_settings": "max_below_min"},
),
(
{
"command_topic": "test-topic",
"text_advanced_settings": {
"text_other_settings": {
"min": 0,
"max": 10,
"mode": "password",
"pattern": "(",
},
},
{"text_advanced_settings": "invalid_regular_expression"},
{"text_other_settings": "invalid_regular_expression"},
),
),
"Milk notifier MOTD",
@@ -4761,7 +4761,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
"device_class": "battery",
"state_class": "measurement",
"unit_of_measurement": "%",
"advanced_settings": {"suggested_display_precision": 1},
"other_settings": {"suggested_display_precision": 1},
},
{
"state_topic": "test-topic1-updated",
@@ -5225,21 +5225,21 @@ async def test_subentry_reconfigure_update_device_properties(
"model_id": {"suggested_value": "mn002"},
"manufacturer": {"suggested_value": "Milk Masters"},
"configuration_url": {"suggested_value": "https://example.com"},
"advanced_settings": None,
"other_settings": None,
"mqtt_settings": None,
}
advanced_settings_key_descriptions = {
other_settings_key_descriptions = {
key: key.description
for key, value in result["data_schema"]
.schema["advanced_settings"]
.schema["other_settings"]
.schema.schema.items()
}
assert advanced_settings_key_descriptions == {
assert other_settings_key_descriptions == {
"sw_version": {"suggested_value": "1.0"},
"hw_version": {"suggested_value": "2.1 rev a"},
}
assert result["data_schema"].schema["advanced_settings"].options == {
assert result["data_schema"].schema["other_settings"].options == {
"collapsed": False
}
@@ -5264,7 +5264,7 @@ async def test_subentry_reconfigure_update_device_properties(
result["flow_id"],
user_input={
"name": "Beer notifier",
"advanced_settings": {"sw_version": "1.1"},
"other_settings": {"sw_version": "1.1"},
"model": "Beer bottle XL",
"model_id": "bn003",
"manufacturer": "Beer Masters",
+30 -1
View File
@@ -10,8 +10,9 @@ import pytest
from homeassistant.components import sun
from homeassistant.components.sun import entity
from homeassistant.const import EVENT_STATE_CHANGED
from homeassistant.const import EVENT_STATE_CHANGED, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -245,3 +246,31 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
assert hass.states.get(entity.ENTITY_ID) is None
async def test_cleanup_deprecated_solar_rising(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that the deprecated solar_rising entity is removed on setup."""
config_entry = MockConfigEntry(domain=sun.DOMAIN)
config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
Platform.SENSOR,
sun.DOMAIN,
unique_id=f"{config_entry.entry_id}-solar_rising",
config_entry=config_entry,
)
assert entity_registry.async_get_entity_id(
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
)
now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC)
with freeze_time(now):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert not entity_registry.async_get_entity_id(
Platform.SENSOR, sun.DOMAIN, f"{config_entry.entry_id}-solar_rising"
)
-1
View File
@@ -9,7 +9,6 @@ UNIQUE_ID = "abc-123"
CONFIG_V1 = {CONF_ACCESS_TOKEN: "abc-123"}
WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None}
WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None}
PRODUCTS = load_json_object_fixture("products.json", DOMAIN)
PRODUCTS_MODERN = load_json_object_fixture("products.json", DOMAIN)
+18
View File
@@ -51,6 +51,7 @@ from .const import (
UNIQUE_ID,
VEHICLE_DATA,
VEHICLE_DATA_ALT,
VEHICLE_DATA_ASLEEP,
)
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -193,6 +194,23 @@ async def test_vehicle_stream(
assert state.state == STATE_OFF
async def test_vehicle_asleep_polling(
hass: HomeAssistant,
mock_vehicle_data: AsyncMock,
mock_legacy: AsyncMock,
) -> None:
"""Polling an offline/asleep vehicle loads and reports disconnected."""
mock_vehicle_data.return_value = VEHICLE_DATA_ASLEEP
entry = await setup_platform(hass, [Platform.BINARY_SENSOR])
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get("binary_sensor.test_status")
assert state is not None
assert state.state == STATE_OFF
async def test_no_live_status(
hass: HomeAssistant,
mock_live_status: AsyncMock,
+85 -2
View File
@@ -1,7 +1,7 @@
"""Test Trace websocket API."""
import asyncio
from collections import defaultdict
from collections import defaultdict, deque
import json
from typing import Any
from unittest.mock import patch
@@ -9,9 +9,12 @@ from unittest.mock import patch
import pytest
from pytest_unordered import unordered
from homeassistant.components.trace.const import DEFAULT_STORED_TRACES
from homeassistant.components.trace import ActionTrace
from homeassistant.components.trace.const import DATA_TRACE, DEFAULT_STORED_TRACES
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Context, CoreState, HomeAssistant, callback
from homeassistant.helpers.json import ExtendedJSONEncoder
from homeassistant.helpers.trace import TraceElement
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.setup import async_setup_component
from homeassistant.util.uuid import random_uuid_hex
@@ -1660,3 +1663,83 @@ async def test_trace_blueprint_automation(
assert trace["script_execution"] == "error"
assert trace["item_id"] == "sun"
assert trace.get("trigger", UNDEFINED) == "event 'blueprint_event'"
class _DiagnosticActionTrace(ActionTrace):
"""Automation-domain trace used to exercise not-triggered serialization."""
_domain = "automation"
def _serialize_trace(not_triggered: bool, reason: str) -> dict[str, Any]:
"""Build a trace serialized the way the trace Store persists it to disk."""
trace = _DiagnosticActionTrace("diag", {"id": "diag"}, None, Context())
trace.not_triggered = not_triggered
element = TraceElement({"trigger": {"idx": "0"}}, "trigger/0")
element.set_result(reason=reason)
trace.set_trace({"trigger/0": deque([element])})
trace.finished()
return json.loads(json.dumps(trace.as_dict(), cls=ExtendedJSONEncoder))
async def test_not_triggered_trace_serialization(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
) -> None:
"""Not-triggered traces serialize with their flag and restore to their bucket."""
run = _serialize_trace(False, "ran")
not_triggered = _serialize_trace(True, "mock_reason")
# Serialization: only the not-triggered trace carries the flag, and its
# diagnostics survive in the extended dict.
assert "not_triggered" not in run["short_dict"]
assert not_triggered["short_dict"]["not_triggered"] is True
assert not_triggered["extended_dict"]["trace"]["trigger/0"][0]["result"] == {
"reason": "mock_reason"
}
hass_storage["trace.saved_traces"] = {
"version": 1,
"minor_version": 1,
"key": "trace.saved_traces",
"data": {"automation.diag": [run, not_triggered]},
}
assert await async_setup_component(hass, "trace", {})
client = await hass_ws_client()
# Restore (lazily, on the first query) and confirm the flag round-trips.
await client.send_json(
{"id": 1, "type": "trace/list", "domain": "automation", "item_id": "diag"}
)
response = await client.receive_json()
assert response["success"]
assert {
trace["run_id"]: trace.get("not_triggered", False)
for trace in response["result"]
} == {
run["short_dict"]["run_id"]: False,
not_triggered["short_dict"]["run_id"]: True,
}
# Restored traces are routed back into their separate buckets.
trace_bucket = hass.data[DATA_TRACE]["automation.diag"]
assert list(trace_bucket.runs) == [run["short_dict"]["run_id"]]
assert list(trace_bucket.not_triggered) == [not_triggered["short_dict"]["run_id"]]
# The restored not-triggered trace keeps its diagnostics.
await client.send_json(
{
"id": 2,
"type": "trace/get",
"domain": "automation",
"item_id": "diag",
"run_id": not_triggered["short_dict"]["run_id"],
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["not_triggered"] is True
assert response["result"]["trace"]["trigger/0"][0]["result"] == {
"reason": "mock_reason"
}
+140 -4
View File
@@ -69,11 +69,13 @@ from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityTriggerBase,
NotTriggeredInfo,
PluggableAction,
StatelessEntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
TriggerNotTriggeredReporter,
_async_get_trigger_platform,
async_initialize_triggers,
async_validate_trigger_config,
@@ -156,7 +158,9 @@ class _MockTrigger(Trigger):
self._options = config.options or {}
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach the trigger to a bus event."""
raw_template = self._options.get("option_template")
@@ -809,7 +813,9 @@ async def test_platform_multiple_triggers(
"""Mock trigger 1."""
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
run_action({"extra": "test_trigger_1"}, "trigger 1 desc")
@@ -818,7 +824,9 @@ async def test_platform_multiple_triggers(
"""Mock trigger 2."""
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
"""Attach a trigger."""
run_action({"extra": "test_trigger_2"}, "trigger 2 desc")
@@ -968,7 +976,9 @@ async def test_get_trigger_platform_registers_triggers(
"""Mock trigger."""
async def async_attach_runner(
self, run_action: TriggerActionRunner
self,
run_action: TriggerActionRunner,
did_not_trigger: TriggerNotTriggeredReporter | None = None,
) -> CALLBACK_TYPE:
return lambda: None
@@ -3962,6 +3972,132 @@ async def _arm_off_to_on_trigger(
)
async def _arm_off_to_on_trigger_with_diagnostics(
hass: HomeAssistant,
entity_ids: list[str],
behavior: str,
calls: list[dict[str, Any]],
reports: list[tuple[dict[str, Any], NotTriggeredInfo]],
) -> CALLBACK_TYPE:
"""Set up _OffToOnTrigger with both an action and a did_not_trigger reporter."""
async def async_get_triggers(
hass: HomeAssistant,
) -> dict[str, type[Trigger]]:
return {"off_to_on": _OffToOnTrigger}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
trigger_config = {
CONF_PLATFORM: "test.off_to_on",
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
CONF_OPTIONS: {ATTR_BEHAVIOR: behavior},
}
log = logging.getLogger(__name__)
@callback
def action(run_variables: dict[str, Any], context: Context | None = None) -> None:
calls.append(run_variables["trigger"])
@callback
def did_not_trigger(
run_variables: dict[str, Any],
info: NotTriggeredInfo,
context: Context | None = None,
) -> None:
reports.append((run_variables["trigger"], info))
validated_config = await async_validate_trigger_config(hass, [trigger_config])
return await async_initialize_triggers(
hass,
validated_config,
action,
domain="test",
name="test_off_to_on",
log_cb=log.log,
did_not_trigger=did_not_trigger,
)
async def test_entity_trigger_reports_did_not_trigger(hass: HomeAssistant) -> None:
"""An entity trigger reports diagnostics for changes that do not fire it."""
entity_id = "test.entity"
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
reports: list[tuple[dict[str, Any], NotTriggeredInfo]] = []
unsub = await _arm_off_to_on_trigger_with_diagnostics(
hass, [entity_id], BEHAVIOR_EACH, calls, reports
)
# A matching change fires the action and reports nothing.
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
assert reports == []
# An invalid transition (already on) does not fire: "transition_not_a_match".
hass.states.async_set(entity_id, STATE_ON, {"brightness": 100})
await hass.async_block_till_done()
assert len(calls) == 1
assert len(reports) == 1
trigger_payload, info = reports[0]
assert trigger_payload[CONF_PLATFORM] == "test.off_to_on"
assert info.reason == "transition_not_a_match"
assert info.data == {
"entity_id": entity_id,
"from_state": STATE_ON,
"to_state": STATE_ON,
}
# A non-target new state does not fire: "new_state_not_a_match".
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
assert len(calls) == 1
assert len(reports) == 2
_trigger_payload, info = reports[1]
assert info.reason == "new_state_not_a_match"
assert info.data == {"entity_id": entity_id, "to_state": STATE_OFF}
unsub()
async def test_entity_trigger_reports_did_not_trigger_behavior_all(
hass: HomeAssistant,
) -> None:
"""Behavior 'all' reports when not every targeted entity matches."""
entity_1 = "test.entity_1"
entity_2 = "test.entity_2"
hass.states.async_set(entity_1, STATE_OFF)
hass.states.async_set(entity_2, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
reports: list[tuple[dict[str, Any], NotTriggeredInfo]] = []
unsub = await _arm_off_to_on_trigger_with_diagnostics(
hass, [entity_1, entity_2], BEHAVIOR_ALL, calls, reports
)
# Only one of the two targets is on, so the trigger does not fire.
hass.states.async_set(entity_1, STATE_ON)
await hass.async_block_till_done()
assert calls == []
assert len(reports) == 1
_trigger_payload, info = reports[0]
assert info.reason == "not_all_targets_matched"
assert info.data == {"matches": 1, "included": 2}
# Now both are on: the trigger fires and reports nothing further.
hass.states.async_set(entity_2, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
assert len(reports) == 1
unsub()
def _set_or_remove_state(
hass: HomeAssistant, entity_id: str, state: str | None
) -> None:
+4 -40
View File
@@ -679,30 +679,12 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
" 2020.12, please create a bug report at https://github.com/home-assistant/"
"core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test_component%22",
),
(
"pyserial",
False,
"Detected that custom integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),
(
"pyserial>=3.5",
True,
"Detected that integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),
(
"pyserial-asyncio",
False,
"Detected that custom integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"which should be replaced by pyserial-asyncio-fast. This will stop"
" working in Home Assistant 2026.7, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),
@@ -710,26 +692,8 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None:
"pyserial-asyncio>=0.6",
True,
"Detected that integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),
(
"pyserial-asyncio-fast",
False,
"Detected that custom integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),
(
"pyserial-asyncio-fast>=0.6",
True,
"Detected that integration",
"which should be replaced by serialx. This will stop"
" working in Home Assistant 2027.1, please create a bug report at "
"which should be replaced by pyserial-asyncio-fast. This will stop"
" working in Home Assistant 2026.7, please create a bug report at "
"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+"
"label%3A%22integration%3A+test_component%22",
),